Technisch artikel

Tesseract.exe als OCR-engine: doorzoekbare PDFs met HotPDF

Een pipe die nooit end-of-file meldt is een eigenaardige bug om na te jagen in een OCR-functie. Eén thread in je applicatie leest de standaarduitvoer van een helperprogramma en wacht tot ReadFile faalt met ERROR_BROKEN_PIPE. Een andere thread start tesseract.exe via CreateProcess met bInheritHandles op True, want zo geef je een childproces een bestand mee voor zijn standaardfout. Het pipe-schrijfeinde van de helper was overneembaar, dus Tesseract bezit nu een kopie, en de lezende thread hangt tot Tesseract klaar is met zijn pagina — wat, op een 300 DPI-scan met twee taalmodellen geladen, niet snel gaat

Een externe recognizer brengt meer mee dan de hang: wat het kind kan zien, wat te doen met halfgeldige uitvoer, en hoe pixelboxen in doorzoekbare PDF-tekst worden omgezet. De ingebouwde template-matching-OCR-engine dekt met opzet machinedrukte ASCII-letters en cijfers. Namen met accenten, Chinese contracten en meertalige archieven hebben getrainde taalmodellen nodig, en Tesseract is de gebruikelijke kandidaat omdat het een commandline-programma is dat je naast je executable kunt installeren

Tesseract aansluiten op de HotPDF-OCR-tekstlaag

HotPDF wikkelt een lokaal geïnstalleerde Tesseract-executable als IHPDFOCREngine via HPDFCreateTesseractOCREngine in de unit HPDFTesseractRecognition. Je geeft die engine mee aan THotPDF.ApplyLoadedOCRTextLayer, die elke gevraagde pagina rendert, Tesseract één keer per pagina draait, de woorden filtert en in één transactie een onzichtbare Unicode-tekstlaag voor elke gevraagde pagina wegschrijft, of voor geen enkele. Coördinaatmapping, rotatie, confidence-filtering en de commit zijn de pipeline die de ingebouwde engine al gebruikt; alleen de herkenning verandert

HotPDF-OCR-pipeline per pagina: render de pagina op de ingestelde DPI, sla input.bmp op in een privé-HotPDF-OCR-map, start het Tesseract-childproces met tessedit_create_tsv, parseer de twaalfkoloms TSV, filter woorden op confidence en leg de onzichtbare tekstlaag vast voor alle gevraagde pagina's of geen
De adapter vervangt alleen de herkenning: rendering, parsing, validatie en de alles-of-niets-commit blijven in de bestaande tekstlaag-pipeline, dus downstream-code verandert nooit

De factory gooit een EArgumentException voordat er ook maar één pagina wordt aangeraakt. De executable en de tessdata-map moeten bestaan, de time-out moet tussen 1 en 3.600.000 milliseconden liggen, en de taal mag alleen ASCII-letters, cijfers, _ en + bevatten, want hij belandt op een commandoregel: eng+chi_sim is legitiem, een waarde met quotes of spaties is een injectie die wacht om te gebeuren. Een tweede overload neemt een THPDFTesseractOptions-record. Zijn Default geeft --psm 3, --oem 3, 60 seconden per pagina en een grens van 16.777.216 pixels, en hij weigert tpsOSDOnly en tpsAutoOnly, de twee modi die geen woorden teruggeven

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

function MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken): Boolean;
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default; // psm 3, oem 3, 60 s, 16.777.216 px
  TessOptions.TimeoutMilliseconds := 120000;    // geldt per pagina
  // geeft EArgumentException bij een ontbrekende executable of tessdata-map,
  // een foutieve taalidentificator of opties buiten hun bereiken
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // modellen samengevoegd met '+'
    TessOptions);
  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;
    // een lege paginalijst betekent elke pagina; pagina's die al tekst hebben worden overgeslagen
    Result := Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info);
    if Result then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words kept, ', Info.DroppedWordCount, ' below the threshold');
      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;

Reken de pixelrekenkunde één keer uit. Een A4-pagina op 300 DPI is zo'n 2481 bij 3508 pixels, ruwweg 8,7 miljoen, en past. Een A3-pagina op dezelfde resolutie is zo'n 3508 bij 4961, dik 17,4 miljoen, dus hij faalt met een pixelbudget-diagnostic en de run eindigt als otlsEngineError. De overload die alleen een time-out neemt past helemaal geen pixelgrens toe. Voor een dienst die bestanden van buiten accepteert, houd de options-overload aan en verhoog MaxPixels (tot 67.108.864) of verlaag THPDFOCRTextLayerOptions.DPI bewust wanneer grote formaten worden verwacht

Per pagina maakt de engine een privé HotPDF-OCR-{GUID}-map onder het temp-pad aan, slaat de bitmap op als input.bmp en draait tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, met --oem erbij wanneer een engine-modus is gezet. Paden worden gequote met de Windows-regels voor backslashes en ingebedde quotes, en --dpi draagt de renderresolutie zodat Tesseract hem nooit hoeft te gokken. Tesseract en zijn .traineddata-bestanden worden niet gebundeld; die provisioning is het werk van de applicatie. Op onze eigen Linux-testmachines draait de recognizer vanuit een user-level bundel van de binary, zijn shared-library-closure en de tessdata-map, want op /usr/bin/tesseract vertrouwen werkt alleen waar iemand het systeempakket heeft geïnstalleerd

Waarom leegt één kapotte TSV-rij de hele pagina?

De Tesseract-adapter laat de hele pagina vallen bij de eerste misvormde rij, want een gedeeltelijk geparsde woordenlijst produceert een tekstlaag die niet met de afbeelding klopt, en niets in de opgeslagen PDF zegt welke woorden zijn zoekgeraakt. De uitvoer moet decoderen als strenge UTF-8 zonder ingebedde NUL. De eerste regel moet gelijk zijn aan de header van Tesseract met twaalf kolommen, level tot text, nadat een optionele byte order mark is gestript. Elke rij moet splitsen in exact twaalf velden, en de splitsing stopt na de elfde tab, dus een tab in de herkende tekst belandt in het tekstveld in plaats van een dertiende kolom te maken, waar de controle op stuurtekens hieronder hem vervolgens afwijst

Alleen level 5-rijen zijn woorden; pagina-, blok-, alinea- en regelrijen worden overgeslagen, net als woorden die leeg zijn of pure witruimte. Een woord met een stuurteken (onder U+0020, of U+007F tot U+009F) laat de pagina vallen. De rest heeft integer-geometrie nodig, een confidence geparsd met invariante opmaak zodat een applicatie met een Duitse locale nog steeds 93.5 leest, een box volledig binnen de bitmap, en een confidence van 0 tot 100. Eén geldig woord gevolgd door één kapotte rij levert nul woorden op

Zes gates die elke Tesseract-TSV-rij in HotPDF passeert: exacte twaalfkoloms header, precies twaalf velden, alleen level 5, niet-lege tekst, een box binnen de bitmap, en confidence van 0 tot 100 invariant geparsd, waarbij één kapotte rij de hele pagina laat vallen tot nul woorden
Een gedeeltelijk geparsde woordenlijst zou geruisloos uit de pas lopen met de afbeelding, dus de parser weigert de hele pagina bij de eerste misvormde rij in plaats van de al gelezen woorden te houden

Eén vreemde rij laat dus de hele run vallen en laat elke pagina onaangetast. Dat is de juiste afweging. Een herhaling is goedkoop; een doorzoekbare PDF waarin een zoektocht naar een factuurnummer net de enige pagina mist die telt, komt doorgaans maanden later naar boven, in andermans handen

De laatste stap ontmoet ook een default die je misschien niet verwacht. Tesseract meldt confidence van 0 tot 100, de pipeline deelt door 100, en THPDFOCRTextLayerOptions.MinimumConfidence staat default op 0.5, dus elk woord onder de 50 belandt in Info.DroppedWordCount in plaats van op de pagina. Op een schone scan is die vloer zinnig. Op een ruisige fax kan hij een groot deel van de tekst weghalen, dus vergelijk Info.ReceivedWordCount met Info.AcceptedWordCount voordat je hem verlaagt

OCR opnieuw draaien op een bestand dat al tekst heeft

SkipPagesWithText staat default op True, en dat vangt mensen bij de tweede keer. Stel dat een batch met eng is verwerkt en nu eng+deu nodig heeft: elke pagina draagt al onzichtbare tekst, dus Info.SkippedPageCount is gelijk aan het aantal pagina's. HotPDF markeert de content stream die hij schrijft als zijn eigen OCR-laag, en ReplaceExistingOCRTextLayer gebruikt die markering om die pagina's opnieuw te herkennen, de oude laag te laten vallen en de originele pagina-inhoud te houden

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // wissel onze eerdere laag om
Options.UseOptionalContentGroup := True;      // laag genaamd 'OCR Text Layer'
Options.CancellationToken := Token;
if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  Writeln(Info.ReplacedPageCount, ' replaced, ',
    Info.SkippedPageCount, ' skipped, ',
    Info.RecognizedPageCount, ' recognized')
else
  Writeln(string(Info.Diagnostic));

Een bestand dat door een scannerdriver is ge-OCR'd draagt zo'n markering niet, dus zijn pagina's tellen nog steeds als pagina's met tekst. SkipPagesWithText uitzetten zou een tweede onzichtbare laag op de eerste stapelen, en elke zoektreffer zou dubbel verschijnen. Laat die bestanden met rust

Welke handles erft het Tesseract-proces over?

Het Tesseract-childproces erft exact twee handles van HotPDF: één geopend op NUL voor standaardinvoer en -uitvoer, en één voor stderr.txt in de privémap. De pipe-hang hierboven komt van CreateProcess zelf: bInheritHandles = True is nodig om standaardhandles door te geven, en op zichzelf geeft hij elke overneembare handle in de host door, inclusief pipes, bestanden en events geopend door ongerelateerde code. HotPDF gebruikt STARTUPINFOEX met een attributenlijst die PROC_THREAD_ATTRIBUTE_HANDLE_LIST en de vlag EXTENDED_STARTUPINFO_PRESENT draagt. bInheritHandles blijft True, maar alleen de opgesomde handles steken over

Handle-overerving van het Tesseract-childproces in HotPDF: een kale CreateProcess met bInheritHandles geeft elke overneembare bestands-, pipe- en event-handle door aan het kind, terwijl STARTUPINFOEX met PROC_THREAD_ATTRIBUTE_HANDLE_LIST de set beperkt tot een NUL-handle voor stdin en stdout plus de stderr-bestandshandle
Zonder de attributenlijst houdt het kind ongerelateerde objecten in leven tot het afsluit, met vergrendelde bestanden en verhongerde pipes tot gevolg; met de lijst steken alleen de twee opgesomde handles de grens over

Hetzelfde patroon hoort in elk ander childproces dat je applicatie start, want één launcher die alles overneemt is genoeg om elke pipereader in het proces te laten hangen:

// Patroon voor je eigen CreateProcess-aanroepen (fragment: NullHandle, ErrorHandle,
// Exe, CommandLine, WorkDir en ProcessInfo zijn elders gedeclareerd).
// Oudere Delphi-RTL's declareren de attributenlijstfuncties niet; voeg je eigen
// external-declaraties uit kernel32 toe als de compiler ze niet kan vinden
// Controleer elke BOOL-uitkomst in productiecode
type
  TStartupInfoExRec = record
    StartupInfo: TStartupInfo;
    AttributeList: Pointer;
  end;
const
  HANDLE_LIST_ATTRIBUTE = $00020002;   // PROC_THREAD_ATTRIBUTE_HANDLE_LIST
  EXTENDED_STARTUP_FLAG = $00080000;   // EXTENDED_STARTUPINFO_PRESENT
var
  Startup: TStartupInfoExRec;
  HandleList: array[0..1] of THandle;
  Size: NativeUInt;
begin
  FillChar(Startup, SizeOf(Startup), 0);
  HandleList[0] := NullHandle;         // gemaakt met bInheritHandle = True
  HandleList[1] := ErrorHandle;        // idem
  Size := 0;
  InitializeProcThreadAttributeList(nil, 1, 0, Size);
  GetMem(Startup.AttributeList, Size);
  InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, Size);
  UpdateProcThreadAttribute(Startup.AttributeList, 0, HANDLE_LIST_ATTRIBUTE,
    @HandleList[0], SizeOf(HandleList), nil, nil);
  Startup.StartupInfo.cb := SizeOf(Startup);
  // ... zet dwFlags en hStdInput / hStdOutput / hStdError zoals gewoonlijk ...
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // ook met een handlelijst vereist
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // nadat het child afsluit: DeleteProcThreadAttributeList, FreeMem, CloseHandle
end;

Bridge-scripts voegen nog een Windows-detail toe. Een Python-launcher kan een tweede interpreterproces starten, en het proces dat je maakte beëindigen laat dat ene draaien. Krijgt de engine een scriptpad, zoals de RapidOCR-adapter hieronder, dan start HotPDF het child gesuspendeerd, wijst het toe aan een job object met JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE en hervat het daarna, zodat het sluiten van de job de hele boom beëindigt. Hetzelfde inperkingsidee drijft PDF-image-codecs isoleren in werkerprocessen, waar het kind niet wordt vertrouwd; hier wordt het kind vertrouwd, maar jouw proces is niet de enige code die er handles in vasthoudt

Annulering, time-outs en de alles-of-niets-commit

IHPDFOCREngine.Recognize geeft één Boolean terug, en False dekt zowel "Tesseract faalde" als "de gebruiker drukte op Annuleren". De adapter polst de token en zijn time-out elke 25 milliseconden terwijl Tesseract draait, en bij annulering ruimt hij op en geeft False terug. Letterlijk genomen zou dat als otlsEngineError lezen, dus ApplyLoadedOCRTextLayer controleert de token eerst telkens als Recognize False geeft. Herkenning en contentconstructie ronden voor elke gevraagde pagina af voordat de documenttransactie opent, dus een annulering op pagina 40 van 50 meldt otlsCancelled en laat alle 50 pagina's zoals geladen. De andere limieten volgen dezelfde stijl:

  • De time-out loopt vanaf het begin van elke Recognize-aanroep, dus de default van 60.000 ms is een limiet per pagina
  • Een child dat bij time-out of annulering nog draait wordt beëindigd en tot 5 seconden afgewacht; de privémap wordt in een finally-blok verwijderd, met het verwijderen tot een seconde herhaald omdat uitvoerbestanden kort vergrendeld kunnen blijven nadat het proces eindigt
  • output.tsv is afgekapt op 64 MiB en stderr.txt op 1 MiB, gecontroleerd terwijl het child draait en nadat het eindigt
  • Woorden en UTF-16-code-eenheden per pagina worden begrensd door wat resteert van MaxWordsPerPage, MaxTotalWords en MaxTextCodeUnits; eroverheen gaan laat de run vallen in plaats van de lijst af te kappen
  • Een exitcode ongelijk nul komt terug met tot 4.096 tekens uit stderr.txt, de snelste manier om te leren dat een .traineddata-bestand ontbreekt

Hoe de onzichtbare tekstlaag er binnen de pagina uitziet

HotPDF schrijft de woorden met tekstweergavemodus 3 (noch vulling noch omlijning, ISO 32000-1 §9.3.6), dus de scan blijft zichtbaar terwijl zoeken en kopiëren op de woorden werken. De content stream opent BT met 3 Tr; elk woord krijgt een Tm-matrix op zijn basislijn, een fontgrootte van boxhoogte in pixels keer 72 gedeeld door de render-DPI, en een Tz-schaal die de run uitrekt naar de boxbreedte. Die rek houdt een zoekhighlight op het woord in de afbeelding

De TSV heeft boxen maar geen baselines, dus elke basislijn wordt op één vijfde van de boxhoogte boven de onderrand geplaatst. Tussen naburige woorden op een links-naar-rechts-regel in scripts met spatiescheiding zoals Latijns of Cyrillisch vult een uitgerekte spatie de opening, dus gekopieerde tekst houdt zijn woordgrenzen; Chinees krijgt die niet. Glyphs gaan door een gedeeld, niet-ingebed Type0-font met Identity-H-encoding en een gegenereerde ToUnicode-CMap, één CID per uniek Unicode-scalar, en zo overleven Chinees, Latijns met accenten en supplementary-plane-tekens kopiëren en zoeken. Eén run houdt tot 65.535 unieke scalars vast, en omdat ISO 19005 ingebedde fonts eist, heeft PDF/A-uitvoer een apart ingebed conforming font nodig. Om een resultaat te controleren, opslaan, herladen en het gewone pad uit tekst uit een geladen PDF extraheren in Delphi draaien; komen de verwachte woorden terug op de verwachte pagina's, dan is de laag echt

RapidOCR en de native DLL op hetzelfde contract

Voor scans in Vereenvoudigd Chinees hergebruikt HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) dezelfde processrunner en TSV-parser. Het bridge-scriptpad volgt de Python-executable en de taal is vastgelegd op chi_sim. HotPDF verscheept de bridge als tools/OCR/rapidocr_tsv.py; hij heeft de pakketten rapidocr en onnxruntime nodig, lokale ONNX-modellen en een fontbestand, weigert automatische downloads, en schrijft Tesseract-vormige TSV; een THPDFRapidOCROptions-overload zet modelbestanden, threadaantallen en een pixelgrens. De generieke factory achter beide adapters, HPDFCreateLocalTSVOCREngine in HPDFLocalTSVRecognition, accepteert elk programma dat de Tesseract-achtige argumenten neemt en de TSV van twaalf kolommen schrijft, en hij erft de handlelijst, de time-out, de annulering en de budgetten

HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) laadt de Tesseract C API in-process, meldt Tesseract (native DLL), controleert vooraf dat elk gevraagd .traineddata-bestand bestaat, en mapt annulering en time-out op de eigen progress monitor van Tesseract. Hij spaart een processtart en een BMP-omweg per pagina uit; de executable houdt de crashes en geheugengroei van de recognizer buiten je adresruimte. Voor een desktop-tool die aan de eigen scans van de gebruiker werkt is de DLL prima. Voor een dienst die documenten van vreemden opneemt is de commandline-engine de veiligere keuze. Hoe dan ook draait de herkenning op Windows, één pagina per keer, op de gerenderde bitmap, dus richt en maak moeilijke scans schoon voordat ze de engine bereiken

De OCR-adapters, de tekstlaagschrijver, de renderer die ze voedt en de extractie die de uitvoer controleert wonen allemaal in één native VCL-component voor Delphi en C++Builder. Als je capture- of archiveringsapplicatie doorzoekbare PDF-uitvoer nodig heeft, dekt de HotPDF Delphi PDF-component alles behalve de OCR-engine die je zelf installeert