Technischer Artikel

Tesseract-OCR zu durchsuchbarer PDF in Delphi mit HotPDF

HotPDF macht gescannte PDF-Seiten mit Tesseract durchsuchbar, über HPDFCreateTesseractOCREngine, eine Factory, die eine lokal installierte Tesseract-Executable als IHPDFOCREngine verpackt. Diese Engine reichen Sie an ApplyLoadedOCRTextLayer weiter, das jede Seite rendert, Tesseract einmal pro Seite laufen lässt, seine TSV-Ausgabe auf Wortebene parst und die unsichtbare Unicode-Textebene für alle angeforderten Seiten in einer Transaktion committet – oder für keine

HotPDF-OCR-Pipeline pro Seite: Die Seite mit der konfigurierten DPI rendern, input.bmp in einem privaten HotPDF-OCR-Verzeichnis speichern, den Tesseract-Kindprozess mit tessedit_create_tsv starten, die zwölfspaltige TSV parsen, Wörter nach Confidence filtern und die unsichtbare Textebene für alle angeforderten Seiten oder keine committen
Der Adapter ersetzt nur die Erkennung: Rendern, Parsen, Validierung und der Alles-oder-Nichts-Commit bleiben in der bestehenden Textebenen-Pipeline, downstream-Code ändert sich also nie

Der Grund, warum dieser Adapter existiert, ist der Scope. Die eingebaute Template-Matching-OCR-Engine ist bewusst eng: maschinengedruckte ASCII-Buchstaben und Ziffern, sonst nichts. Rechnungen mit akzentuierten Namen, chinesische Verträge und mehrsprachige Archive brauchen einen echten Recognizer mit trainierten Sprachmodellen, und Tesseract ist der naheliegende Kandidat, denn es ist ein Kommandozeilenprogramm, das man neben seiner Anwendung bereitstellen kann. Ein externes Programm aus einer Dokumentbibliothek aufzurufen klingt trivial. Ist es nicht, und der größte Teil des interessanten Codes im Adapter handelt davon, was passiert, wenn das Programm sich falsch verhält, hängen bleibt, abgebrochen wird oder Dinge erbt, die es nie sehen sollte

Wie treibt HotPDF Tesseract aus einer Delphi-Anwendung an?

HotPDF läuft Tesseract als versteckten Kindprozess pro Seite, füttert ihn mit einer gerenderten Bitmap und liest eine TSV-Datei zurück, und legt das Ergebnis über dieselbe IHPDFOCREngine-Naht offen, die auch die eingebaute Engine nutzt. Nichts downstream ändert sich: Koordinatenmapping, Rotationsbehandlung, Unicode-Validierung, Confidence-Filterung und der atomare Commit sind die Textebenen-Pipeline, die Sie ohnehin haben. Die Factory wohnt in der Unit HPDFTesseractRecognition und validiert eifrig: Die Executable muss existieren, das tessdata-Verzeichnis muss existieren, das Timeout muss zwischen 1 und 3.600.000 Millisekunden liegen, und der Sprachbezeichner darf nur ASCII-Buchstaben, Ziffern, _ und + enthalten. Diese letzte Prüfung zählt, denn der Sprachstring landet auf einer Kommandozeile, und eng+chi_sim ist ein legitimer Tesseract-Wert, während alles mit Anführungszeichen oder Leerzeichen es nicht ist

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // wirft EArgumentException bei fehlender Executable, fehlendem tessdata,
  // einem schlechten Sprachbezeichner oder einem Timeout außerhalb 1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // mehrere Modelle, mit '+' verbunden
    120000);            // Limit pro Seite, Default ist 60000
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    Options.CancellationToken := Token;
    // eine leere Seitenliste heißt alle Seiten; Seiten mit Text werden per Default übersprungen
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

Für jede Seite legt Recognize ein privates Verzeichnis unter dem Temp-Pfad an, namens HotPDF-OCR-{GUID}, speichert die gerenderte Bitmap als input.bmp und startet tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, wobei jedes Pfadargument nach den Windows-Kommandozeilen-Escaping-Regeln für Backslashes und eingebettete Anführungszeichen gequotet wird. Der --dpi-Wert ist die Render-DPI aus THPDFOCRTextLayerOptions.DPI, Tesseract muss die Auflösung also nie aus den Bild-Metadaten raten, und --psm 3 fordert vollautomatische Seitensegmentierung. Die Engine meldet sich als Tesseract (local CLI), was in Info.EngineName landet. Tesseract und seine Sprachmodelle werden nicht mit HotPDF ausgeliefert; sie zu installieren ist die Aufgabe der Anwendung

Warum ist der TSV-Parser so streng?

Der TSV-Parser in HotPDF lässt die ganze Seite scheitern, sobald eine Zeile missgestaltet ist, denn eine teilweise geparste Wortliste erzeugt eine Textebene, die still mit dem Bild unvereinbar ist. Tesseracts TSV-Ausgabe hat einen festen Zwölf-Spalten-Header, von level bis text, und HotPDF vergleicht die erste Zeile nach dem Abschneiden eines optionalen Byte Order Marks gegen genau diesen Header. Jede folgende Zeile muss sich in exakt zwölf Felder teilen, und der Split stoppt nach dem elften Tab, sodass ein Tab im erkannten Text Teil des Wortes bleibt, statt eine dreizehnte Spalte zu erzeugen. Nur Level-5-Zeilen sind Wörter; die Level 1 bis 4 beschreiben Seiten, Blöcke, Absätze und Zeilen und werden übersprungen. Level-5-Zeilen, deren Text leer oder reiner Whitespace ist, werden ebenfalls übersprungen, denn ein leeres Wort hat zwar eine Box, aber nichts zum Lokalisieren oder Suchen. Alles andere wird hart geprüft: ganzzahlige Geometrie, eine mit invariantem en-US-Format geparste Confidence, damit eine deutsche Locale 93.5 nicht als Müll liest, eine Box, die vollständig in der Bitmap liegt, und eine Confidence zwischen 0 und 100. Ein einziger Fehler wirft, die Engine gibt False zurück, und das Wortarray wird geleert. Die Regressionstests enthalten exakt diesen Fall: Ein valides Wort, gefolgt von einer kaputten Zeile, muss null Wörter liefern, nicht eins

Sechs Tore, die jede Tesseract-TSV-Zeile in HotPDF passiert: exakter Zwölf-Spalten-Header, exakt zwölf Felder, nur Level 5, nicht-leerer Text, eine Box innerhalb der Bitmap und eine invariant geparste Confidence von 0 bis 100, wobei eine einzige kaputte Zeile die ganze Seite auf null Wörter fallen lässt
Eine teilweise geparste Wortliste würde still mit dem Bild unvereinbar sein, der Parser verweigert also die gesamte Seite bei der ersten missgestalteten Zeile, statt die bereits gelesenen Wörter zu behalten
// verdichtet aus der Level-5-Schleife in HPDFLocalTSVRecognition
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // Seiten-/Block-/Absatz-/Zeilen-Zeilen
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // Whitespace-Wörter haben keine Position
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // die Pipeline erwartet 0..1

Diese letzte Zeile interagiert mit einem Default, den man vielleicht nicht erwartet. Tesseract-Confidence läuft von 0 bis 100, die Pipeline arbeitet in 0 bis 1, und THPDFOCRTextLayerOptions.MinimumConfidence defaultet auf 0.5, jedes Tesseract-Wort unter 50 zählt also in Info.DroppedWordCount und erreicht die Seite nie. Auf einem sauberen 300-DPI-Scan ist das eine vernünftige Untergrenze. Auf einem verrauschten Fax kann das einen überraschenden Anteil der Seite fallen lassen, und der richtige Zug ist, sich die Dropped-Count anzuschauen, bevor man die Schwelle senkt, denn Wörter mit niedriger Confidence sind exakt die, die am ehesten falsch sind

Was erbt der Tesseract-Kindprozess?

Der Tesseract-Kindprozess erbt von HotPDF exakt zwei Handles: ein NUL-Handle für Standard Input und Output, und ein Datei-Handle für Standard Error. Diese Präzision ist der Punkt. CreateProcess mit bInheritHandles = True ist der Weg, Standard-Handles an ein Kind zu reichen, aber für sich genommen reicht er jedes vererbbare Handle im Host-Prozess weiter, inklusive Dateien, Pipes und Events, die von unwichtigem Code in Ihrer Anwendung geöffnet wurden. Das Kind hält diese Objekte dann am Leben, bis es endet, eine Datei bleibt also gesperrt oder eine Pipe sieht nie ihr Ende, während Tesseract sich durch eine Seite wühlt. HotPDF schließt diese Lücke mit einem erweiterten Startup-Record: STARTUPINFOEX, eine Attributliste mit PROC_THREAD_ATTRIBUTE_HANDLE_LIST, und das EXTENDED_STARTUPINFO_PRESENT-Creation-Flag. Mit der Handle-Liste muss bInheritHandles weiterhin True sein, aber nur die gelisteten Handles überschreiten die Grenze. Denselben Eindämmungs-Gedanken treibt die Isolation von PDF-Bild-Codecs in Worker-Prozessen an, wo das Kind untrusted Code ist; hier ist das Kind trusted, aber der Host ist nicht der einzige Besitzer seiner eigenen Handle-Tabelle

Handle-Vererbung des Tesseract-Kindprozesses in HotPDF: Ein schlichtes CreateProcess mit bInheritHandles reicht jedes vererbbare Datei-, Pipe- und Event-Handle an das Kind weiter, während STARTUPINFOEX mit PROC_THREAD_ATTRIBUTE_HANDLE_LIST die Menge auf ein NUL-Handle für stdin und stdout plus das stderr-Datei-Handle begrenzt
Ohne die Attributliste hält das Kind unwichtige Objekte am Leben, bis es endet, sperrt Dateien und hungert Pipes aus; mit ihr überschreiten nur die zwei gelisteten Handles die Grenze
// Konstanten namentlich gezeigt; die Quelle übergibt ihre numerischen Werte
// beide Handles werden mit bInheritHandle = True erzeugt
InheritedHandles[0] := NullHandle;    // stdin und stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt im privaten Verzeichnis
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // von der Handle-Liste verlangt
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

Warum kann ein abgebrochener OCR-Lauf wie ein Engine-Fehler aussehen?

Weil IHPDFOCREngine.Recognize ein einzelnes Boolean zurückgibt und False sowohl „Tesseract gescheitert“ als auch „der Nutzer hat Cancel gedrückt“ bedeutet. Der Adapter pollt das Cancellation-Token und das Timeout alle 25 Millisekunden, während das Kind läuft, und feuert das Token, wirft er innerhalb von Recognize, fängt seine eigene Exception, räumt auf und gibt False mit einer Diagnose zurück. Würde die Pipeline das als Engine-Fehler behandeln, sähe der Aufrufer otlsEngineError für einen Job, den der Nutzer bewusst gestoppt hat. ApplyLoadedOCRTextLayer prüft daher zuerst das Token, wann immer Recognize False zurückgibt, und wandelt das Ergebnis nur dann in einen Engine-Fehler um, wenn das Token nicht gesetzt war. Diese Reihenfolge bewahrt den Multi-Page-Vertrag: Erkennung, Validierung, Budget-Buchhaltung und Inhaltsaufbau laufen für jede angeforderte Seite, bevor die Graph-Transaktion geöffnet wird, ein Abbruch auf Seite 40 von 50 meldet also otlsCancelled und lässt das Dokument, eingeschlossen der ersten 39 Seiten, unangetastet. Es gibt keine teilweise durchsuchbare Datei, die man später erklären müsste, und der Rest der Fehlerbehandlung folgt demselben begrenzten Stil:

  • Das Timeout gilt pro Recognize-Aufruf, gemessen ab seinem Start, die Default 60.000 ms gelten also für jede Seite, nicht für das ganze Dokument
  • Ein Kind, das bei Timeout oder Abbruch noch läuft, wird terminiert, bis zu 5 Sekunden abgewartet, und sein privates Verzeichnis wird in einem finally-Block gelöscht
  • output.tsv ist auf 64 MiB gedeckelt und stderr.txt auf 1 MiB, geprüft während das Kind läuft sowie nach seinem Ende
  • Wortzahl und UTF-16-Code-Units werden pro Seite durch die verbleibenden MaxWordsPerPage-, MaxTotalWords- und MaxTextCodeUnits-Budgets gedeckelt, eine Überschreitung lässt den Lauf scheitern, statt die Wortliste zu stutzen
  • Standard Output geht nach NUL, weil Tesseract output.tsv schreibt, während Standard Error in eine Datei geht, sodass ein Exit-Code ungleich null mit bis zu 4.096 Zeichen der eigenen Klage der Engine gemeldet wird – meist der schnellste Weg zu erfahren, dass eine .traineddata-Datei fehlt

Wie die erkannten Wörter zu einer unsichtbaren Textebene werden

HotPDF schreibt Tesseract-Wörter als unsichtbaren Text mit Text-Rendering-Mode 3, dem Weder-Füllen-Noch-Stricheln-Mode aus ISO 32000-1 §9.3.6, die Seite zeigt also weiterhin das gescannte Bild, während Suche und Kopie auf den erkannten Wörtern arbeiten. Der Content-Stream öffnet BT mit 3 Tr, und jedes Wort bekommt eine Tm-Matrix an seiner Baseline, eine aus der Box-Höhe in Pixeln bei der Render-DPI abgeleitete Font-Größe und einen Tz-Horizontal-Scale, der den Glyphenzug auf die gemessene Box-Breite streckt, weshalb ein Such-Highlight auf dem Wort im Bild landet, statt darüber hinwegzutreiben

Tesseracts TSV hat Boxen, aber keine Baselines, der Adapter meldet also jedes Wort ohne eine, und die Pipeline schätzt die Baseline auf ein Fünftel der Box-Höhe über der Unterkante. Der Text selbst läuft durch einen geteilten, nicht eingebetteten Type0-Font mit Identity-H-Kodierung und einer generierten ToUnicode-CMap, ein CID pro verschiedenem Unicode-Skalar über den ganzen Lauf, weshalb Chinesisch, akzentuiertes Latein und Supplementary-Plane-Zeichen alle Kopie und Suche überleben. Dieses Design hat zwei Grenzen, die man vorab nennen sollte: Ein Lauf kann höchstens 65.535 verschiedene Skalare tragen, und der nicht eingebettete Font erfüllt die Font-Einbettungs-Anforderung von ISO 19005 nicht, PDF/A-Ausgabe braucht also einen separat eingebetteten konformen Font. Das Ergebnis zu prüfen ist simpel und wert, automatisiert zu werden: speichern, neu laden und den gewöhnlichen Loaded-Document-Text-Pfad aus Text aus einer geladenen PDF in Delphi extrahieren laufen lassen; kommen die Wörter auf den erwarteten Seiten zurück, ist die Ebene echt

RapidOCR und andere Engines auf demselben TSV-Protokoll

HotPDF nutzt denselben Prozess-Runner und TSV-Parser auch für RapidOCR, über HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), was für vereinfachte chinesische Scans die nützlichere Wahl ist. Die Kommandozeile ist identisch, nur dass der Bridge-Skript-Pfad nach der Python-Executable eingefügt wird und die Sprache auf chi_sim festgenagelt ist. HotPDF liefert die Bridge als tools/OCR/rapidocr_tsv.py aus; sie erwartet die Pakete rapidocr und onnxruntime plus drei lokale ONNX-Modelle, deaktiviert automatische Modell-Downloads und schreibt Tesseract-förmige TSV, die Delphi-Seite braucht also keinen zweiten Parser. Der in Info.EngineName gemeldete Engine-Name ist RapidOCR (local ONNX). Diese Form deutet das allgemeine Rezept an: Jeder Recognizer, den Sie in ein kleines Skript wickeln können, das die Tesseract-artige Argumentliste entgegennimmt und die zwölfspaltige TSV ausgibt, erbt Handle-Isolation, Timeout, Abbruch, Output-Budgets und den Alles-oder-Nichts-Commit gratis. Die Adapter sind Windows-only, laufen synchron eine Seite nach der anderen und richten das Bild weder schief aus noch bereiten sie es über das hinaus vor, was der Renderer produziert, die Bildqualität am Eingang setzt also weiterhin die Decken für das, was herauskommt

Die Tesseract- und RapidOCR-Adapter, der unsichtbare Textebenen-Writer, der Seiten-Renderer, der sie füttert, und die Textextraktion, die das Ergebnis verifiziert, erscheinen alle in derselben nativen VCL-Komponente für Delphi und C++Builder. Wenn Sie OCR in eine Dokumenterfassungs- oder Archivierungsanwendung einbauen, gibt Ihnen die HotPDF Delphi PDF component die Pipeline fertig, nur die OCR-Engine selbst bleibt zu installieren