Artykuł techniczny

Tesseract OCR do przeszukiwalnego PDF w Delphi z HotPDF

HotPDF zamienia zeskanowane strony PDF w przeszukiwalny PDF z Tesseract przez HPDFCreateTesseractOCREngine, fabrykę owijającą lokalnie zainstalowany plik wykonywalny Tesseract w IHPDFOCREngine. Ten silnik przekazujesz do ApplyLoadedOCRTextLayer, które renderuje każdą stronę, puszcza Tesseract raz na stronę, parsuje jego TSV na poziomie słów i komituje niewidoczną warstwę tekstu Unicode dla wszystkich żądanych stron w jednej transakcji albo dla żadnej

Pipeline OCR HotPDF na stronę: zrenderuj stronę w skonfigurowanym DPI, zapisz input.bmp w prywatnym katalogu HotPDF-OCR, odpal proces potomny Tesseract z tessedit_create_tsv, sparsuj dwunastokolumnowy TSV, odfiltruj słowa po pewności i skomituj niewidoczną warstwę tekstu dla wszystkich żądanych stron albo dla żadnej
Adapter wymienia tylko rozpoznawanie: renderowanie, parsowanie, walidacja i komit wszystko-albo-nic zostają w istniejącym pipeline warstwy tekstu, więc kod downstream się nie zmienia

Ten adapter istnieje przez zakres. Wbudowany silnik OCR z dopasowaniem szablonów jest celowo wąski: maszynowo drukowane litery i cyfry ASCII i nic poza tym. Faktury z nazwiskami z diakrytykami, chińskie kontrakty i archiwa wielojęzyczne potrzebują prawdziwego rozpoznawacza z wytrenowanymi modelami językowymi, a Tesseract to oczywisty kandydat, bo to program wiersza poleceń, który da się zaopatrzyć obok aplikacji. Wołanie zewnętrznego programu z biblioteki dokumentowej brzmi banalnie. Nie jest, i większość ciekawego kodu w adapterze dotyczy tego, co się dzieje, gdy program źle się zachowa, zawiesi, zostanie anulowany albo odziedziczy rzeczy, których widzieć nigdy nie powinien

Jak HotPDF steruje Tesseractem z aplikacji Delphi?

HotPDF puszcza Tesseract jako ukryty proces potomny na stronę, podając mu wyrenderowaną bitmapę i czytając z powrotem plik TSV, a wynik wystawia przez ten sam szew IHPDFOCREngine, którego używa silnik wbudowany. Nic downstream się nie zmienia: mapowanie współrzędnych, obsługa obrotu, walidacja Unicode, filtrowanie po pewności i komit atomowy to pipeline warstwy tekstu, który już masz. Fabryka mieszka w jednostce HPDFTesseractRecognition i waliduje z góry: plik wykonywalny musi istnieć, katalog tessdata musi istnieć, timeout musi być między 1 a 3 600 000 milisekund, a identyfikator języka może zawierać wyłącznie litery ASCII, cyfry, _ i +. To ostatnie sprawdzenie ma znaczenie, bo łańcuch języka ląduje na wierszu poleceń, a eng+chi_sim to legalna wartość Tesseract, podczas gdy cokolwiek z cudzysłowami albo spacjami nie jest

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // rzuca EArgumentException przy brakującym pliku wykonywalnym, brakującym tessdata,
  // złym identyfikatorze języka albo timeout poza 1..3600000 ms
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // kilka modeli sklejonych znakiłem '+'
    120000);            // limit na stronę, domyślnie 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;
    // pusta lista stron znaczy każdą stronę; strony z tekstem są domyślnie pomijane
    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;

Dla każdej strony Recognize tworzy prywatny katalog pod ścieżką temp o nazwie HotPDF-OCR-{GUID}, zapisuje wyrenderowaną bitmapę jako input.bmp i odpala tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, z każdym argumentem ścieżkowym ujętym w cudzysłów według windowsowych reguł escapowania wiersza poleceń dla ukośników wstecznych i wbudowanych cudzysłowów. Wartość --dpi to DPI renderowania z THPDFOCRTextLayerOptions.DPI, więc Tesseract nigdy nie musi zgadywać rozdzielczości z metadanych obrazu, a --psm 3 prosi o w pełni automatyczną segmentację strony. Silnik zgłasza się jako Tesseract (local CLI), i to ląduje w Info.EngineName. Tesseract i jego modele językowe nie są dowożone z HotPDF; instalacja to zadanie aplikacji

Dlaczego parser TSV jest taki rygorystyczny?

Parser TSV w HotPDF wywala całą stronę przy każdym zniekształconym wierszu, bo częściowo sparsowana lista słów daje warstwę tekstu, która po cichu rozmija się z obrazem. Wyjście TSV Tesseract ma stały dwunastokolumnowy nagłówek, od level po text, a HotPDF porównuje pierwszy wiersz z dokładnie tym nagłówkiem po zdjęciu opcjonalnego znacznika kolejności bajtów. Każdy kolejny wiersz musi się rozpaść na dokładnie dwanaście pól, a rozpad zatrzymuje się po jedenastym tabulatorze, żeby tabulator wewnątrz rozpoznanego tekstu został częścią słowa, zamiast tworzyć trzynastą kolumnę. Tylko wiersze poziomu 5 to słowa; poziomy od 1 do 4 opisują strony, bloki, akapity i wiersze i są pomijane. Wiersze poziomu 5 o tekście pustym albo czysto białym są też pomijane, bo puste słowo ma prostokąt, ale nie ma czego lokalizować albo szukać. Wszystko inne jest sprawdzane twardo: geometryczna liczba całkowita, pewność parsowana formatem niezmiennym en-US, żeby niemiecka lokalizacja nie czytała 93.5 jako śmieci, prostokąt leżący w całości wewnątrz bitmapy i pewność między 0 a 100. Pojedyncza porażka rzuca, silnik zwraca False, a tablica słów jest czyszczona. Testy regresyjne obejmują dokładnie ten przypadek: jedno poprawne słowo, po którym idzie zepsuty wiersz, musi dać zero słów, a nie jedno

Sześć bramek, przez które przechodzi każdy wiersz TSV Tesseract w HotPDF: dokładny dwunastokolumnowy nagłówek, dokładnie dwanaście pól, tylko poziom 5, tekst niepusty, prostokąt wewnątrz bitmapy i pewność od 0 do 100 parsowana niezmiennie, gdzie jeden zepsuty wiersz wywala całą stronę do zera słów
Częściowo sparsowana lista słów rozmijałaby się po cichu z obrazem, więc parser odrzuca całą stronę przy pierwszym zniekształconym wierszu, zamiast zachować już przeczytane słowa
// skondensowane z pętli poziomu 5 w 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;                 // wiersze strony/bloku/akapitu/wiersza
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // białe słowa nie mają pozycji
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;  // pipeline oczekuje 0..1

Ta ostatnia linia współgra z domyślną wartością, której możesz się nie spodziewać. Pewność Tesseract biegnie od 0 do 100, pipeline pracuje w 0 do 1, a THPDFOCRTextLayerOptions.MinimumConfidence ma domyślnie 0.5, więc każde słowo Tesseract poniżej 50 jest liczone w Info.DroppedWordCount i nigdy nie dochodzi do strony. Na czystym skanie 300 DPI to rozsądna podłoga. Na zaszumionym faksie może zrzucić zaskakujący udział strony, a właściwym ruchem jest spojrzenie na liczbę zrzuconych, zanim obniżysz próg, bo słowa o niskiej pewności to dokładnie te, które najbardziej ryzykują bycie błędnymi

Co dziedziczy proces potomny Tesseract?

Proces potomny Tesseract dziedziczy z HotPDF dokładnie dwa uchwyty: uchwyt NUL dla standardowego wejścia i wyjścia oraz uchwyt pliku dla standardowego błędu. Ta precyzja jest sednem. CreateProcess z bInheritHandles = True to sposób przekazywania standardowych uchwytów potomkowi, ale sam z siebie przekazuje każdy dziedziczalny uchwyt w procesie gospodarza, łącznie z plikami, potokami i zdarzeniami otwartymi przez niepowiązany kod w twojej aplikacji. Potomek trzyma potem te obiekty przy życiu do swojego wyjścia, więc plik pozostaje zablokowany albo potok nigdy nie widzi swojego końca, podczas gdy Tesseract miele przez stronę. HotPDF zamyka tę lukę rozszerzonym rekordem startowym: STARTUPINFOEX, listą atrybutów niosącą PROC_THREAD_ATTRIBUTE_HANDLE_LIST i flagą tworzenia EXTENDED_STARTUPINFO_PRESENT. Z listą uchwytów na miejscu bInheritHandles nadal musi być True, ale przez granicę przechodzą tylko wypisane uchwyty. To samo myślenie o zawieraniu napędza izolowanie kodeków obrazów PDF w procesach roboczych, gdzie potomek to kod niezaufany; tutaj potomek jest zaufany, ale gospodarz nie jest jedynym właścicielem własnej tabeli uchwytów

Dziedziczenie uchwytów przez proces potomny Tesseract w HotPDF: zwykłe CreateProcess z bInheritHandles przekazuje potomkowi każdy dziedziczalny uchwyt pliku, potoku i zdarzenia, podczas gdy STARTUPINFOEX z PROC_THREAD_ATTRIBUTE_HANDLE_LIST ogranicza zbiór do uchwytu NUL dla stdin i stdout plus uchwytu pliku stderr
Bez listy atrybutów potomek trzyma niepowiązane obiekty przy życiu do swojego wyjścia, blokując pliki i dusząc potoki; z nią przez granicę przechodzą tylko dwa wypisane uchwyty
// stałe pokazane nazwami; źródło podaje ich wartości liczbowe
// oba uchwyty tworzone z bInheritHandle = True
InheritedHandles[0] := NullHandle;    // stdin i stdout
InheritedHandles[1] := ErrorHandle;   // stderr.txt w prywatnym katalogu
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,                                        // wymagane przez listę uchwytów
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

Dlaczego anulowany bieg OCR może wyglądać jak awaria silnika?

Anulowany bieg OCR wygląda jak awaria silnika, bo IHPDFOCREngine.Recognize zwraca pojedynczy Boolean, a False znaczy i „Tesseract padł”, i „użytkownik nacisnął Anuluj”. Adapter polluje token anulowania i timeout co 25 milisekund, dopóki potomek biegnie, i gdy token odpala, rzuca wewnątrz Recognize, łapie własny wyjątek, sprząta i zwraca False z diagnostyką. Gdyby pipeline traktował to jako błąd silnika, wołający zobaczyłby otlsEngineError dla zadania, które użytkownik celowo zatrzymał. ApplyLoadedOCRTextLayer sprawdza więc token najpierw, ilekroć Recognize zwróci False, i dopiero gdy token nie był ustawiony, zamienia wynik na błąd silnika. Ta kolejność zachowuje kontrakt wielostronicowy: rozpoznawanie, walidacja, rozliczanie budżetu i budowanie treści biegną dla każdej żądanej strony, zanim transakcja grafu się otworzy, więc anulowanie na stronie 40 z 50 raportuje otlsCancelled i zostawia dokument, łącznie z pierwszymi 39 stronami, nietknięty. Nie ma częściowo przeszukiwalnego pliku do tłumaczenia później, a reszta obsługi porażek idzie tym samym ograniczonym stylem:

  • Timeout jest per wywołanie Recognize, mierzony od jego startu, więc domyślne 60 000 ms dotyczy każdej strony, a nie całego dokumentu
  • Potomek wciąż biegnący przy timeout albo anulowaniu jest kończony, oczekiwany do 5 sekund, a jego prywatny katalog jest usuwany w bloku finally
  • output.tsv ma limit 64 MiB, a stderr.txt 1 MiB, sprawdzane w trakcie biegu potomka, jak i po jego wyjściu
  • Liczba słów i jednostek kodowych UTF-16 jest ograniczana per strona przez pozostałe budżety MaxWordsPerPage, MaxTotalWords i MaxTextCodeUnits, a ich przekroczenie wywala bieg, zamiast ucinać listę słów
  • Standardowe wyjście idzie do NUL, bo Tesseract pisze output.tsv, a standardowy błąd idzie do pliku, więc niezerowy kod wyjścia jest raportowany z do 4 096 znaków własnego narzekania silnika, zwykle najszybszy sposób, by dowiedzieć się, że brakuje pliku .traineddata

Jak rozpoznane słowa stają się niewidoczną warstwą tekstu

HotPDF zapisuje słowa Tesseract jako niewidoczny tekst w trybie renderowania tekstu 3, trybie ani wypełnienia, ani obrysu z ISO 32000-1 §9.3.6, więc strona nadal pokazuje zeskanowany obraz, podczas gdy wyszukiwanie i kopiowanie działają na rozpoznanych słowach. Strumień treści otwiera BT z 3 Tr, a każde słowo dostaje macierz Tm na swojej linii bazowej, rozmiar fontu wyprowadzony z wysokości prostokąta w pikselach przy DPI renderowania i poziomą skalę Tz rozciągającą bieg glifów na zmierzoną szerokość prostokąta, dlatego podświetlenie wyszukiwania ląduje na słowie w obrazie, zamiast dryfować przez niego

TSV Tesseract ma prostokąty, ale nie ma linii bazowych, więc adapter raportuje każde słowo bez niej, a pipeline szacuje linię bazową na jedną piątą wysokości prostokąta nad dolną krawędzią. Sam tekst idzie przez współdzielony nieosadzony font Type0 z kodowaniem Identity-H i wygenerowaną CMap ToUnicode, po jednym CID na każdy odrębny skalar Unicode w całym biegu, i dlatego chiński, łacina z diakrytykami i znaki z płaszczyzny uzupełnień przeżywają i kopiowanie, i wyszukiwanie. Ten projekt ma dwa limity warte podania z góry: jeden bieg może nieść najwyżej 65 535 odrębnych skalarów, a nieosadzony font nie spełnia wymogu osadzania fontów z ISO 19005, więc wyjście PDF/A potrzebuje osobno osadzonego zgodnego fontu. Sprawdzenie wyniku jest proste i warte zautomatyzowania: zapisz, wczytaj ponownie i puść zwykłą ścieżkę tekstu dokumentu wczytanego z artykułu o ekstrakcji tekstu z wczytanego PDF w Delphi; jeśli słowa wrócą na oczekiwanych stronach, warstwa jest prawdziwa

RapidOCR i inne silniki na tym samym protokole TSV

HotPDF reużywa tego samego biegacza procesów i parsera TSV dla RapidOCR przez HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), co jest użyteczniejszym wyborem dla skanów w chińskim uproszczonym. Wiersz poleceń jest identyczny, z tym że ścieżka skryptu mostu jest wstawiana za plikiem wykonywalnym Pythona, a język jest przypięty do chi_sim. HotPDF dowozi most jako tools/OCR/rapidocr_tsv.py; oczekuje pakietów rapidocr i onnxruntime plus trzech lokalnych modeli ONNX, wyłącza automatyczne pobieranie modeli i pisze TSV w kształcie Tesseract, więc strona Delphi nie potrzebuje drugiego parsera. Nazwa silnika raportowana w Info.EngineName to RapidOCR (local ONNX). Ten kształt podpowiada ogólny przepis: każdy rozpoznawacz, którego owiniesz małym skryptem przyjmującym listę argumentów w stylu Tesseract i emitującym dwunastokolumnowy TSV, dziedziczy za darmo izolację uchwytów, timeout, anulowanie, budżety wyjścia i komit wszystko-albo-nic. Adaptery są tylko na Windows, biegną po jednej stronie naraz synchronicznie i nie prostują ani nie wstępnie przetwarzają obrazu ponad to, co produkuje renderer, więc jakość obrazu na wejściu nadal wyznacza sufit temu, co wychodzi

Adaptery Tesseract i RapidOCR, zapisywacz niewidocznej warstwy tekstu, renderer stron, który je karmi, i ekstrakcja tekstu weryfikująca wynik trafiają razem do tego samego natywnego komponentu VCL dla Delphi i C++Buildera. Jeśli dodajesz OCR do aplikacji przechwytującej albo archiwizującej dokumenty, komponent PDF HotPDF dla Delphi daje ci pipeline, poza samym silnikiem OCR, który zostaje do zainstalowania