HotPDF перетворює відскановані сторінки PDF на пошуковий PDF із Tesseract через HPDFCreateTesseractOCREngine — фабрику, яка загортає локально встановлений виконуваний файл Tesseract у IHPDFOCREngine. Цей рушій ви передаєте в ApplyLoadedOCRTextLayer, який рендерить кожну сторінку, ганяє Tesseract раз на сторінку, розбирає його word-рівневий TSV-вивід і комітить невидимий Unicode-текстовий шар для всіх запитаних сторінок однією транзакцією — або жодної з них
Причина існування цього адаптера — охоплення. Вбудований OCR-рушій на template matching свідомо вузький: надруковані машиной ASCII-літери і цифри, і нічого більше. Рахунки з іменами з діакритикою, китайські контракти і багатомовні архіви потребують справжнього розпізнавача з натренованими мовними моделями, і Tesseract — очевидний кандидат, бо це консольна програма, яку можна поставити поруч із застосунком. Викликати зовнішню програму з документаційної бібліотеки звучить тривіально. Це не так, і більша частина цікавого коду в адаптері — про те, що буває, коли програма поводиться погано, висне, її скасовують або вона успадковує те, чого ніколи не мала б бачити
Як HotPDF керує Tesseract із Delphi-застосунку?
HotPDF ганяє Tesseract як прихований дочірній процес на сторінку, згодовуючи йому відрендерений бітмап і читаючи назад TSV-файл, а результат віддає через той самий шов IHPDFOCREngine, яким користується вбудований рушій. Нічого нижче по течії не змінюється: мапування координат, обробка ротації, Unicode-валідація, фільтрування за confidence і атомарний коміт — це текстошаровий конвеєр, який у вас уже є. Фабрика живе в юніті HPDFTesseractRecognition і валідує заздалегідь: виконуваний файл мусить існувати, директорія tessdata мусить існувати, таймаут мусить бути між 1 і 3 600 000 мілісекунд, а ідентифікатор мови може містити лише ASCII-літери, цифри, _ і +. Остання перевірка важлива, бо рядок мови опиняється на командному рядку, і eng+chi_sim — легітимне значення Tesseract, а все з лапками чи пробілами — ні
uses
SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;
procedure MakeSearchable(const SourceFile, TargetFile: string;
Token: THPDFCancellationToken);
var
Doc: THotPDF;
Engine: IHPDFOCREngine;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
// піднімає EArgumentException за відсутній виконуваний файл, відсутню tessdata,
// поганий ідентифікатор мови чи таймаут поза 1..3600000 мс
Engine := HPDFCreateTesseractOCREngine(
'C:\OCR\Tesseract\tesseract.exe',
'C:\OCR\Tesseract\tessdata',
'eng+chi_sim', // кілька моделей, з'єднаних '+'
120000); // ліміт на сторінку, усталено 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;
// порожній список сторінок означає всі; сторінки з текстом пропускаються за замовчуванням
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;
На кожній сторінці Recognize створює приватну директорію під temp-шляхом на ім'я HotPDF-OCR-{GUID}, зберігає відрендерений бітмап як input.bmp і запускає tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1, причому кожен аргумент-шлях береться в лапки за правилами екранування Windows-командного рядка для бекслешів і вкладених лапок. Значення --dpi — це рендерний DPI з THPDFOCRTextLayerOptions.DPI, тож Tesseract ніколи не мусить вгадувати роздільність із метаданих зображення, а --psm 3 просить повністю автоматичну сегментацію сторінки. Рушій звітує себе як Tesseract (local CLI) — саме це потрапляє в Info.EngineName. Tesseract і його мовні моделі не йдуть у комплекті з HotPDF; встановити їх — робота застосунку
Чому TSV-парсер такий суворий?
TSV-парсер у HotPDF валить усю сторінку на будь-якому кривому рядку, бо частково розібраний список слів дає текстовий шар, який тихо розходиться з зображенням. TSV-вивід Tesseract має фіксований дванадцятиколонковий заголовок, від level до text, і HotPDF звіряє перший рядок із тим точним заголовком після зняття опційного byte order mark. Кожен наступний рядок мусить розколотися рівно на дванадцять полів, і розкол зупиняється після одинадцятого таба, щоб таб усередині розпізнаного тексту лишився частиною слова, а не створив тринадцяту колонку. Лише рядки рівня 5 — слова; рівні з 1 по 4 описують сторінки, блоки, абзаци і рядки, і вони пропускаються. Рядки рівня 5 з порожнім чи чисто-пробільним текстом теж пропускаються, бо порожнє слово має бокс, але нічого, що можна знайти чи пошукати. Усе інше перевіряється жорстко: цілочислова геометрія, confidence, розпарсений інваріантним форматом en-US, щоб німецька локаль не читала 93.5 як сміття, бокс, що лежить повністю всередині бітмапа, і confidence між 0 і 100. Один збій — і все піднімається, рушій повертає False, а масив слів очищається. Регресні тести включають рівно той випадок: одне валідне слово, за яким іде зламаний рядок, має дати нуль слів, а не одне
// стиснуто з циклу рівня 5 у 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; // рядки сторінка/блок/абзац/рядок
WordText := Fields[11];
if Trim(WordText) = '' then Continue; // пробільні слова не мають позиції
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; // конвеєр очікує 0..1
Останній рядок взаємодіє з усталеним значенням, якого ви могли не чекати. Confidence у Tesseract йде від 0 до 100, конвеєр працює в діапазоні 0–1, а THPDFOCRTextLayerOptions.MinimumConfidence має усталене 0.5, тож будь-яке слово Tesseract нижче 50 рахується в Info.DroppedWordCount і ніколи не дістає сторінки. На чистому скані 300 DPI це розумна підлога. На шумному факсі воно може викинути несподівану частку сторінки, і правильний хід — подивитися на кількість викинутого, перш ніж знижувати поріг, бо слова з низьким confidence — це рівно ті, що найімовірніше неправильні
Що успадковує дочірній процес Tesseract?
Дочірній процес Tesseract успадковує від HotPDF рівно два хендли: хендл NUL для стандартного вводу і виводу та файловий хендл для стандартної помилки. Ця точність і є сенс. CreateProcess з bInheritHandles = True — це спосіб передати стандартні хендли дитині, але сам по собі він передає кожен успадковуваний хендл у процесі-хості, включно з файлами, пайпами і подіями, відкритими нерелевантним кодом у вашому застосунку. Дитина потім тримає ті об'єкти живими до свого виходу, тож файл лишається заблокованим, а пайп ніколи не бачить свого кінця, поки Tesseract жоне крізь сторінку. HotPDF закриває ту прогалину розширеним записом старту: STARTUPINFOEX, список атрибутів із PROC_THREAD_ATTRIBUTE_HANDLE_LIST і прапорець створення EXTENDED_STARTUPINFO_PRESENT. Зі списком хендлів bInheritHandles все ще мусить бути True, але через межу переходять лише перелічені хендли. Та сама думка про стримування веде ізоляцію PDF-кодеків у робочих процесах, де дитина — недовірений код; тут дитина довірена, але хост — не єдиний власник власної таблиці хендлів
// константи показані іменами; сирець передає їхні числові значення
// обидва хендли створені з bInheritHandle = True
InheritedHandles[0] := NullHandle; // stdin і stdout
InheritedHandles[1] := ErrorHandle; // stderr.txt у приватній директорії
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, // потрібне для списку хендлів
CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);
Чому скасований OCR-прогін може виглядати як збій рушія?
Скасований OCR-прогін виглядає як збій рушія, бо IHPDFOCREngine.Recognize повертає один Boolean, і False означає і "Tesseract провалився", і "користувач натиснув Скасувати". Адаптер опитує токен скасування і таймаут кожні 25 мілісекунд, поки дитина працює, і коли токен спрацьовує, він піднімає виняток усередині Recognize, ловить свій виняток, прибирає за собою і повертає False з діагностикою. Якби конвеєр трактував це як збій рушія, викликач побачив би otlsEngineError для роботи, яку користувач свідомо зупинив. Тому ApplyLoadedOCRTextLayer перевіряє токен першим щоразу, коли Recognize повертає False, і перетворює результат на збій рушія, лише якщо токен не було встановлено. Той порядок тримає багатосторінковий контракт: розпізнавання, валідація, облік бюджету і побудова вмісту йдуть для кожної запитаної сторінки до того, як графова транзакція відкриється, тож скасування на сторінці 40 з 50 звітує otlsCancelled і лишає документ, включно з першими 39 сторінками, недоторканим. Немає частково пошукового файлу, який треба пояснювати потім, а решта обробки збоїв іде в тому самому обмеженому стилі:
- Таймаут — на кожен виклик
Recognize, міряється від його старту, тож усталені 60 000 мс застосовуються до кожної сторінки, а не до цілого документа - Дитина, яка досі працює при таймауті чи скасуванні, припиняється, чекається до 5 секунд, а її приватна директорія видаляється в блоці
finally output.tsvобмежений 64 МіБ, аstderr.txt— 1 МіБ, з перевіркою і поки дитина працює, і після її виходу- Кількість слів і UTF-16 code units обмежуються на сторінку рештою бюджетів
MaxWordsPerPage,MaxTotalWordsіMaxTextCodeUnits, і перевищення валить прогін, а не обрізає список слів - Стандартний вивід іде в
NUL, бо Tesseract пишеoutput.tsv, тоді як стандартна помилка йде у файл, щоб ненульовий код виходу звітувався з щонайбільше 4 096 символів власної скарги рушія — зазвичай найшвидший спосіб дізнатися, що бракує файлу.traineddata
Як розпізнані слова стають невидимим текстовим шаром
HotPDF пише слова Tesseract як невидимий текст із режимом рендерингу тексту 3 — режимом ні-заливки-ні-обведення з ISO 32000-1 §9.3.6, тож сторінка досі показує відскановане зображення, поки пошук і копіювання працюють на розпізнаних словах. Потік вмісту відкриває BT з 3 Tr, і кожне слово отримує матрицю Tm на своїй базовій лінії, розмір шрифту, виведений із висоти бокса в пікселях при рендерному DPI, і горизонтальний масштаб Tz, що розтягує гліфовий пробіг до виміряної ширини бокса, — саме тому підсвітка пошуку падає на слово в зображенні, а не дрейфує повз нього
TSV від Tesseract має бокси, але не має базових ліній, тож адаптер звітує кожне слово без неї, а конвеєр оцінює базову лінію на одній п'ятій висоти бокса над нижнім краєм. Сам текст іде крізь спільний невбудований шрифт Type0 з кодуванням Identity-H і згенерованою CMap ToUnicode, один CID на кожен окремий Unicode-скаляр за весь пробіг, — саме так китайські символи, латиниця з діакритикою і символи supplementary plane виживають у копіюванні та пошуку. Той дизайн має дві межі, про які варто сказати заздалегідь: один пробіг несе щонайбільше 65 535 окремих скалярів, а невбудований шрифт не задовольняє вимогу вбудування шрифтів ISO 19005, тож PDF/A-вивід потребує окремо вбудованого відповідного шрифту. Перевірити результат просто, і варто це автоматизувати: збережіть, перезавантажте і ганяйте звичайний текстовий шлях завантаженого документа з добування тексту з завантаженого PDF у Delphi; якщо слова повертаються на очікуваних сторінках, шар справжній
RapidOCR та інші рушії на тому самому протоколі TSV
HotPDF перевикористовує той самий ранер процесів і TSV-парсер для RapidOCR через HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds), що є кориснішим вибором для сканкопій спрощеною китайською. Командний рядок ідентичний, хіба що шлях bridge-скрипта вставляється після виконуваного файлу Python, а мова фіксується на chi_sim. HotPDF постачає bridge як tools/OCR/rapidocr_tsv.py; він очікує пакети rapidocr і onnxruntime плюс три локальні ONNX-моделі, вимикає автоматичне завантаження моделей і пише TSV у формі Tesseract, щоб Delphi-боку не потрібен другий парсер. Назва рушія, звітована в Info.EngineName, — RapidOCR (local ONNX). Та форма підказує загальний рецепт: будь-який розпізнавач, який можна загорнути в маленький скрипт, що приймає список аргументів у стилі Tesseract і видає дванадцятиколонковий TSV, успадковує ізоляцію хендлів, таймаут, скасування, бюджети виводу і коміт все-або-нічого задарма. Адаптери — тільки під Windows, ганяють по одній сторінці синхронно і не роблять deskew чи передобробку зображення понад те, що видає рендерер, тож якість зображення на вході досі ставить стелю тому, що виходить
Адаптери Tesseract і RapidOCR, письменник невидимого текстового шару, рендерер сторінок, який їх годує, і добування тексту, що перевіряє результат, усі виходять у тому самому нативному VCL-компоненті для Delphi і C++Builder. Якщо ви додаєте OCR до застосунку document capture чи архівування, HotPDF Delphi PDF component дає вам конвеєр, у якому лишилося встановити сам OCR-рушій