Artykuł techniczny

Tesseract OCR do przeszukiwalnego PDF w Delphi z HotPDF

Potok, który nigdy nie zgłasza końca pliku, to osobliwy bug do wyśledzenia w funkcji OCR. Jeden wątek Twojej aplikacji czyta standardowe wyjście programu pomocniczego i czeka, aż ReadFile polegnie z ERROR_BROKEN_PIPE. Drugi wątek startuje tesseract.exe przez CreateProcess z bInheritHandles ustawionym na True, bo tak wręcza się procesowi potomnemu plik dla jego standardowego błędu. Zapisowy koniec potoku helpera był dziedziczalny, więc Tesseract ma teraz jego kopię, a wątek czytający wisi, aż Tesseract skończy swoją stronę — co, przy skanie 300 DPI z dwoma załadowanymi modelami językowymi, nie jest szybkie

Zewnętrzny rozpoznawacz przynosi więcej niż zawieszenie: co widzi potomek, co zrobić z połowicznie poprawnym wyjściem, jak zamienić pikselowe boxy w przeszukiwalny tekst PDF. Wbudowany silnik OCR z dopasowaniem szablonów celowo pokrywa maszynowo drukowane litery i cyfry ASCII. Nazwy z diakrytykami, chińskie umowy i archiwa mieszane językowo potrzebują wytrenowanych modeli językowych, a Tesseract to zwyczajowy kandydat, bo jest programem linii poleceń, który można zainstalować obok Twojego pliku wykonywalnego

Wpięcie Tesseracta w warstwę tekstu OCR HotPDF

HotPDF owija lokalnie zainstalowany plik wykonywalny Tesseract jako IHPDFOCREngine przez HPDFCreateTesseractOCREngine w jednostce HPDFTesseractRecognition. Podajesz ten silnik do THotPDF.ApplyLoadedOCRTextLayer, które renderuje każdą żądaną stronę, puszcza Tesseract raz na stronę, filtruje słowa i zapisuje niewidoczną warstwę tekstu Unicode dla każdej żądanej strony w jednej transakcji — albo dla żadnej. Mapowanie współrzędnych, rotacja, filtrowanie pewności i komit to potok, którego wbudowany silnik już używa; zmienia się tylko rozpoznawanie

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

Fabryka podnosi EArgumentException, zanim jakakolwiek strona zostanie dotknięta. Plik wykonywalny i katalog tessdata muszą istnieć, timeout musi leżeć między 1 a 3 600 000 milisekund, a język może zawierać wyłącznie litery ASCII, cyfry, _ i +, bo kończy na linii poleceń: eng+chi_sim jest legitne, wartość z cudzysłowami albo spacjami to iniekcja czekająca na okazję. Drugi overload bierze rekord THPDFTesseractOptions. Jego Default daje --psm 3, --oem 3, 60 sekund na stronę i sufit 16 777 216 pikseli, i odrzuca tpsOSDOnly oraz tpsAutoOnly, dwa tryby, które nie zwracają słów

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;    // dotyczy każdej strony
  // podnosi EArgumentException przy brakującym wykonywalnym albo folderze tessdata,
  // złym identyfikatorze języka albo opcjach poza ich zakresami
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',                              // modele sklejone '+'
    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;
    // pusta lista stron znaczy wszystkie; strony, które już mają tekst, są pomijane
    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;

Zrób arytmetykę pikseli raz. Strona A4 przy 300 DPI to około 2481 na 3508 pikseli, z grubsza 8,7 miliona, i mieści się. Strona A3 przy tej samej rozdzielczości to około 3508 na 4961, blisko 17,4 miliona, więc pada z diagnostyką budżetu pikseli i przebieg kończy się jako otlsEngineError. Overload biorący sam timeout w ogóle nie nakłada sufitu pikseli. Dla serwisu przyjmującego pliki z zewnątrz trzymaj overload z opcjami i podnoś MaxPixels (do 67 108 864) albo świadomie obniżaj THPDFOCRTextLayerOptions.DPI, gdy spodziewane są duże formaty

Na stronę silnik tworzy prywatny katalog HotPDF-OCR-{GUID} pod ścieżką temp, zapisuje bitmapę jako input.bmp i puszcza tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, doklejając --oem, gdy ustawiono tryb silnika. Ścieżki są cytowane według reguł Windows dla ukośników odwrotnych i wbudowanych cudzysłowów, a --dpi niesie rozdzielczość renderu, więc Tesseract nigdy jej nie zgaduje. Tesseract i jego pliki .traineddata nie są w pakiecie; prowizjonowanie ich to robota aplikacji. Na naszych własnych maszynach testowych z Linuksem rozpoznawacz biegnie z pakietu na poziomie użytkownika — binarka, jej domknięcie bibliotek współdzielonych i folder tessdata — bo poleganie na /usr/bin/tesseract działa tylko tam, gdzie ktoś zainstalował pakiet systemowy

Czemu jeden zepsuty wiersz TSV opróżnia całą stronę?

Adapter Tesseracta wywala całą stronę przy pierwszym zniekształconym wierszu, bo częściowo sparsowana lista słów produkuje warstwę tekstu rozmijającą się z obrazem, a nic w zapisanym PDF nie mówi, których słów zabrakło. Wyjście musi dekodować się jako ścisłe UTF-8 bez wbudowanego NUL. Pierwsza linia musi równać się dwunastokolumnowemu nagłówkowi Tesseracta, od level do text, po zrzuceniu opcjonalnego znacznika kolejności bajtów. Każdy wiersz musi rozpaść się na dokładnie dwanaście pól, a podział staje po jedenastym tabulatorze, więc tabulator wewnątrz rozpoznanego tekstu ląduje w polu tekstu zamiast tworzyć trzynastą kolumnę, gdzie kontrola znaków kontrolnych spod niżej i tak go odrzuci

Tylko wiersze poziomu 5 są słowami; wiersze strony, bloku, akapitu i linii są pomijane, podobnie jak słowa puste albo czyste białe znaki. Słowo zawierające znak kontrolny (poniżej U+0020 albo od U+007F do U+009F) wywala stronę. Reszta potrzebuje geometrii integerowej, pewności parsowanej formatowaniem niezmiennym, żeby aplikacja w niemieckim locale nadal czytała 93.5, boxu w całości wewnątrz bitmapy i pewności od 0 do 100. Jedno ważne słowo po jednym zepsutym wierszu daje zero słów

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

Jeden osobliwy wiersz wywala więc cały przebieg i zostawia każdą stronę nietkniętą. To właściwy handel. Ponowny bieg jest tani; przeszukiwalny PDF, w którym szukanie numeru faktury mija tę jedną stronę, która ma znaczenie, wychodzi zwykle miesiące później, w cudzych rękach

Ostatni krok spotyka też domyślny, którego możesz się nie spodziewać. Tesseract donosi pewność od 0 do 100, potok dzieli przez 100, a THPDFOCRTextLayerOptions.MinimumConfidence domyślnie wynosi 0,5, więc każde słowo poniżej 50 ląduje w Info.DroppedWordCount zamiast na stronie. Na czystym skanie ten próg jest sensowny. Na zasumionym faksie potrafi zdjąć dużą część tekstu, więc porównuj Info.ReceivedWordCount z Info.AcceptedWordCount, zanim go obniżysz

Puszczanie OCR ponownie na pliku, który już ma tekst

SkipPagesWithText jest domyślnie True i drugim razem łapie ludzi. Załóżmy, że partia była przetworzona z eng, a teraz potrzebuje eng+deu: każda strona niesie już niewidoczny tekst, więc Info.SkippedPageCount równa się liczbie stron. HotPDF znacznikuje zapisywany content stream jako własną warstwę OCR, a ReplaceExistingOCRTextLayer używa tego znacznika, by rozpoznać te strony ponownie, zrzucając starą warstwę i trzymając oryginalną treść strony

Options := THPDFOCRTextLayerOptions.Default;
Options.ReplaceExistingOCRTextLayer := True;  // podmień naszą wcześniejszą warstwę
Options.UseOptionalContentGroup := True;      // warstwa o nazwie '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));

Plik OCR-owany przez sterownik skanera takiego znacznika nie niesie, więc jego strony nadal liczą się jako strony z tekstem. Wyłączenie SkipPagesWithText ułożyłoby drugą niewidoczną warstwę na pierwszej, a każde trafienie w wyszukiwarce pokazałoby się dwa razy. Zostaw te pliki w spokoju

Które uchwyty dziedziczy proces Tesseracta?

Proces potomny Tesseracta dziedziczy od HotPDF dokładnie dwa uchwyty: jeden otwarty na NUL dla standardowego wejścia i wyjścia, i jeden dla stderr.txt w prywatnym katalogu. Wiszący potok spod wyżej pochodzi od samego CreateProcess: bInheritHandles = True jest wymagane, by przekazać standardowe uchwyty, a samo w sobie przekazuje każdy dziedziczalny uchwyt w hoście — potoki, pliki i zdarzenia otwarte przez niepowiązany kod włącznie. HotPDF używa STARTUPINFOEX z listą atrybutów niosącą PROC_THREAD_ATTRIBUTE_HANDLE_LIST i flagą EXTENDED_STARTUPINFO_PRESENT. bInheritHandles zostaje True, a mimo to przechodzą wyłącznie wypisane uchwyty

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

Ten sam wzorzec należy do każdego innego procesu potomnego, jaki startuje Twoja aplikacja, bo jeden launcher dziedziczący wszystko starczy, by zawiesić każdego czytelnika potoku w procesie:

// Wzorzec dla Twoich własnych wywołań CreateProcess (fragment: NullHandle, ErrorHandle,
// Exe, CommandLine, WorkDir i ProcessInfo są zadeklarowane gdzie indziej).
// Starsze RTL Delphi nie deklarują funkcji listy atrybutów; dodaj własne
// zewnętrzne deklaracje z kernel32, jeśli kompilator ich nie znajdzie
// Sprawdzaj każdy wynik BOOL w kodzie produkcyjnym
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;         // utworzony z bInheritHandle = True
  HandleList[1] := ErrorHandle;        // podobnie
  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);
  // ... ustaw dwFlags i hStdInput / hStdOutput / hStdError jak zwykle ...
  CreateProcess(PChar(Exe), PChar(CommandLine), nil, nil,
    True,                              // nadal wymagane przy liście uchwytów
    CREATE_NO_WINDOW or EXTENDED_STARTUP_FLAG,
    nil, PChar(WorkDir), Startup.StartupInfo, ProcessInfo);
  // po wyjściu potomka: DeleteProcThreadAttributeList, FreeMem, CloseHandle
end;

Skrypty mostowe dokładają jeszcze jeden windowsowy szczegół. Launcher Pythona może wystartować drugi proces interpretera, i zakończenie procesu, który stworzyłeś, zostawia ten drugi biegnącym. Gdy silnik dostaje ścieżkę skryptu — jak adapter RapidOCR spod niżej — HotPDF startuje potomka zawieszonego, przypisuje go do job object z JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE i dopiero go wznawia, więc zamknięcie joba kończy całe drzewo. Ta sama idea izolacji napędza izolowanie kodeków obrazów PDF w procesach roboczych, gdzie potomek jest niezaufany; tutaj potomek jest zaufany, ale Twój proces nie jest jedynym kodem trzymającym w nim uchwyty

Anulowanie, timeouty i komit wszystko-albo-nic

IHPDFOCREngine.Recognize zwraca jeden Boolean, i False pokrywa i „Tesseract padł”, i „użytkownik nacisnął Anuluj”. Adapter odpytuje token i swój timeout co 25 milisekund, dopóki Tesseract biegnie, i przy anulowaniu sprząta i zwraca False. Wzięte dosłownie czytałoby się jako otlsEngineError, więc ApplyLoadedOCRTextLayer sprawdza najpierw token zawsze, gdy Recognize zwróci False. Rozpoznawanie i budowa treści kończą się dla każdej żądanej strony, zanim transakcja dokumentu się otworzy, więc anulowanie na stronie 40 z 50 donosi otlsCancelled i zostawia wszystkie 50 stron załadowanymi. Pozostałe limity trzymają ten sam styl:

  • Timeout biegnie od startu każdego wywołania Recognize, więc domyślne 60 000 ms to limit per strona
  • Potomek wciąż biegnący przy timeoutcie albo anulowaniu jest zabijany i czekany do 5 sekund; prywatny katalog jest usuwany w bloku finally, z ponawianiem kasowania do sekundy, bo pliki wyjściowe mogą zostać zablokowane krótko po końcu procesu
  • output.tsv ma sufit 64 MiB, a stderr.txt 1 MiB, sprawdzane w trakcie biegu potomka i po jego wyjściu
  • Słowa i jednostki kodowe UTF-16 na stronę są ograniczone tym, co zostaje z MaxWordsPerPage, MaxTotalWords i MaxTextCodeUnits; przekroczenie wywala przebieg zamiast obcinać listę
  • Niezerowy kod wyjścia wraca z do 4096 znaków stderr.txt — najszybszy sposób, by się dowiedzieć, że brakuje pliku .traineddata

Jak niewidoczna warstwa tekstu wygląda wewnątrz strony

HotPDF zapisuje słowa w trybie renderowania tekstu 3 (ani wypełnienie, ani kreska, ISO 32000-1 §9.3.6), więc skan pozostaje widoczny, podczas gdy wyszukiwanie i kopiowanie pracują na słowach. Content stream otwiera BT z 3 Tr; każde słowo dostaje macierz Tm przy swojej linii bazowej, rozmiar czcionki równy wysokości boxa w pikselach razy 72 przez renderowe DPI i skalę Tz rozciągającą bieg do szerokości boxa. To rozciągnięcie trzyma podświetlenie wyszukiwania na słowie w obrazie

TSV ma boxy, ale nie ma linii bazowych, więc każda linia bazowa jest kładziona jedną piątą wysokości boxa nad dolną krawędzią. Między sąsiednimi słowami na linii od lewej do prawej w skryptach rozdzielanych spacjami, jak łacina czy cyrylica, rozciągnięta spacja wypełnia przerwę, więc skopiowany tekst zachowuje granice słów; chiński dostaje żadnej. Glify idą przez współdzieloną nieosadzoną czcionkę Type0 z kodowaniem Identity-H i generowaną mapą ToUnicode — jeden CID na odrębny skalar Unicode — i tak chiński, łacina z diakrytykami i znaki z płaszczyzny dodatkowej przeżywają kopiowanie i wyszukiwanie. Jeden bieg trzyma do 65 535 odrębnych skalarów, a ponieważ ISO 19005 wymaga czcionek osadzonych, wyjście PDF/A potrzebuje osobno osadzonej zgodnej czcionki. Aby sprawdzić wynik, zapisz, przeładuj i puść zwykłą ścieżkę z ekstrakcji tekstu z załadowanego PDF w Delphi; jeśli oczekiwane słowa wracają na oczekiwanych stronach, warstwa jest prawdziwa

RapidOCR i natywna DLL na tej samej umowie

Dla skanów w chińskim uproszczonym HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) reużywa tego samego runnera procesowego i parsera TSV. Ścieżka skryptu mostu idzie za wykonywalnym Pythona, a język jest zpięty na chi_sim. HotPDF dostarcza most jako tools/OCR/rapidocr_tsv.py; potrzebuje pakietów rapidocr i onnxruntime, lokalnych modeli ONNX i pliku czcionki, odmawia automatycznych pobrań i zapisuje TSV w kształcie Tesseracta; overload THPDFRapidOCROptions ustawia pliki modeli, liczniki wątków i sufit pikseli. Uniwersalna fabryka za oboma adapterami, HPDFCreateLocalTSVOCREngine w HPDFLocalTSVRecognition, przyjmuje każdy program biorący argumenty w stylu Tesseracta i zapisujący dwunastokolumnowy TSV, i dziedziczy listę uchwytów, timeout, anulowanie i budżety

HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language) ładuje zamiast tego C API Tesseracta w procesie, zgłasza się jako Tesseract (native DLL), sprawdza z góry, że każdy żądany plik .traineddata istnieje, i mapuje anulowanie oraz timeout na własny monitor postępu Tesseracta. Oszczędza start procesu i powrotną drogę BMP na stronę; plik wykonywalny trzyma crashe i wzrost pamięci rozpoznawacza poza Twoją przestrzenią adresową. Dla desktopowego narzędzia pracującego na skanach użytkownika DLL jest w porządku. Dla serwisu połykającego dokumenty od obcych silnik linii poleceń to bezpieczniejszy wybór. Tak czy inaczej rozpoznawanie biegnie na Windows, jedna strona naraz, na wyrenderowanej bitmapie, więc wyprostuj i wyczyść trudne skany, zanim dotrą do silnika

Adaptery OCR, pisarz warstwy tekstu, renderer je karmiący i ekstrakcja sprawdzająca wyjście mieszkają wszystkie w jednym natywnym komponencie VCL dla Delphi i C++Builder. Jeśli Twoja aplikacja akwizycji albo archiwizacji potrzebuje wyjścia przeszukiwalnego PDF, komponent HotPDF Delphi PDF pokrywa wszystko poza silnikiem OCR, który instalujesz sam