HotPDF превращает отсканированные страницы PDF в searchable PDF с Tesseract через HPDFCreateTesseractOCREngine — фабрику, оборачивающую локально установленный исполняемый файл Tesseract в IHPDFOCREngine. Этот движок вы передаёте в ApplyLoadedOCRTextLayer, который рендерит каждую страницу, запускает Tesseract по разу на страницу, разбирает его TSV-вывод уровня слов и коммитит невидимый Unicode-текстовый слой для всех запрошенных страниц одной транзакцией — или ни для одной
Причина существования этого адаптера — scope. Встроенный OCR-движок на сопоставлении с шаблоном намеренно узок: печатные ASCII-буквы и цифры, и ничего больше. Счета с именами с диакритикой, китайские контракты и многоязычные архивы требуют настоящего распознавателя с обученными языковыми моделями, и Tesseract — очевидный кандидат, потому что это консольная программа, которую можно положить рядом с приложением. Дёрнуть внешнюю программу из документной библиотеки звучит тривиально. Это не так, и большая часть интересного кода в адаптере — о том, что бывает, когда программа ведёт себя плохо, виснет, отменяется или наследует то, что видеть ей не положено
Как HotPDF рулит Tesseract из Delphi-приложения?
HotPDF запускает Tesseract как скрытый дочерний процесс на страницу, скармливая ему отрендеренный битмап и читая обратно TSV-файл, а результат выставляет через тот же шов IHPDFOCREngine, каким пользуется встроенный движок. Ничто ниже по цепочке не меняется: отображение координат, обработка поворотов, Unicode-валидация, фильтрация по уверенности и атомарный коммит — это уже имеющийся у вас конвейер текстового слоя. Фабрика живёт в юните 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 сверяет первую строку с этим точным заголовком, срезав опциональную метку порядка байтов. Каждая следующая строка обязана разрезаться ровно на двенадцать полей, причём разрез останавливается после одиннадцатого таба, чтобы таб внутри распознанного текста остался частью слова, а не создал тринадцатую колонку. Словами являются только строки уровня 5; уровни с 1 по 4 описывают страницы, блоки, абзацы и линии — их пропускают. Строки уровня 5 с пустым или чисто пробельным текстом тоже пропускаются: у пустого слова есть бокс, но нечего находить и искать. Всё остальное проверяется жёстко: целочисленная геометрия, уверенность, разобранная инвариантным форматом en-US, чтобы немецкая локаль не приняла 93.5 за мусор, бокс, полностью лежащий внутри битмапа, и уверенность между 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
Последняя строка взаимодействует с дефолтом, которого вы могли не ожидать. Уверенность Tesseract идёт от 0 до 100, конвейер работает в диапазоне 0–1, а THPDFOCRTextLayerOptions.MinimumConfidence по умолчанию 0.5, так что любое слово Tesseract ниже 50 попадает в Info.DroppedWordCount и до страницы не доходит. На чистом скане 300 DPI это разумный пол. На шумном факсе он может уронить удивительную долю страницы, и правильный ход — посмотреть на число отброшенных прежде чем снижать порог, потому что слова с низкой уверенностью — ровно те, что вероятнее всего неверны
Что наследует дочерний процесс 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 упал», и «пользователь нажал Cancel». Адаптер опрашивает cancellation token и таймаут каждые 25 миллисекунд, пока дочерний процесс работает, и когда токен срабатывает, поднимает исключение внутри Recognize, ловит собственное исключение, убирает за собой и возвращает False с диагностикой. Если бы конвейер счёл это отказом движка, вызывающий увидел бы otlsEngineError для задания, которое пользователь остановил намеренно. Поэтому ApplyLoadedOCRTextLayer всякий раз при False от Recognize сперва проверяет токен и превращает результат в отказ движка, только если токен не был выставлен. Этот порядок бережёт многостраничный контракт: распознавание, валидация, учёт бюджета и построение содержимого гоняются по каждой запрошенной странице до открытия транзакции графа, так что отмена на странице 40 из 50 сообщает otlsCancelled и оставляет документ, включая первые 39 страниц, нетронутым. Не остаётся частично searchable файла, который пришлось бы потом объяснять, а остальная обработка сбоев следует тому же ограниченному стилю:
- Таймаут — на вызов
Recognize, считается от его старта, так что дефолтные 60 000 мс действуют на каждую страницу, а не на весь документ - Дочерний процесс, всё ещё работающий на таймауте или отмене, терминируется, ждётся до 5 секунд, а его приватный каталог удаляется в блоке
finally output.tsvограничен 64 MiB,stderr.txt— 1 MiB, с проверкой и пока дочерний процесс работает, и после его выхода- Счёт слов и 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-скаляр по всему прогону, — вот почему китайский, латиница с диакритикой и символы дополнительной плоскости все переживают копирование и поиск. У этого дизайна два предела, о которых стоит сказать сразу: один прогон несёт максимум 65 535 различных скаляров, а невстроенный шрифт не удовлетворяет требованию встраивания шрифтов ISO 19005, так что PDF/A-выводу нужен отдельно встроенный соответствующий шрифт. Проверить результат просто, и стоит это автоматизировать: сохраните, перезагрузите и прогоните обычный текстовый путь загруженного документа из статьи о извлечении текста из загруженного PDF в Delphi; если слова вернулись на ожидаемых страницах, слой настоящий
RapidOCR и другие движки на том же TSV-протоколе
HotPDF переиспользует тот же раннер процессов и TSV-парсер для RapidOCR через HPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds) — для сканов упрощённого китайского это более полезный выбор. Командная строка идентична, если не считать пути bridge-скрипта, вставляемого после исполняемого файла Python, а язык фиксирован в chi_sim. HotPDF поставляет бридж как tools/OCR/rapidocr_tsv.py; он ждёт пакеты rapidocr и onnxruntime плюс три локальные ONNX-модели, запрещает автоматическую загрузку моделей и пишет TSV формы Tesseract, чтобы Delphi-стороне не понадобился второй парсер. Имя движка в Info.EngineName — RapidOCR (local ONNX). Эта форма подсказывает общий рецепт: любой распознаватель, который можно завернуть в маленький скрипт, принимающий список аргументов в стиле Tesseract и выпускающий двенадцатиколоночный TSV, бесплатно наследует изоляцию хендлов, таймаут, отмену, бюджеты вывода и коммит «всё или ничего». Адаптеры только под Windows, гоняют по одной странице синхронно и не выравнивают наклон и не препроцессируют изображение сверх того, что выдаёт рендерер, так что качество изображения на входе по-прежнему задаёт потолок выходу
Адаптеры Tesseract и RapidOCR, писатель невидимого текстового слоя, питающий их рендерер страниц и извлечение текста, верифицирующее результат, выходят в том же нативном VCL-компоненте для Delphi и C++Builder. Добавляете OCR в приложение захвата или архивации документов — HotPDF Delphi PDF component даёт вам конвейер, остаётся поставить только сам OCR-движок