Техническая статья

OCR через Tesseract DLL в HotPDF: вызов C API из Delphi

HotPDF запускает Tesseract внутри вашего Delphi-процесса через HPDFCreateTesseractDLLOCREngine — фабрику, добавленную в v2.772.0: она динамически грузит совместимую с Tesseract 5 DLL, рулит её C API (TessBaseAPIInit2, TessBaseAPIRecognize, итератор результатов) и возвращает IHPDFOCREngine. THotPDF.ApplyLoadedOCRTextLayer пользуется этим движком, чтобы добавить невидимый searchable Unicode-текстовый слой на отсканированные страницы PDF

Тот же распознаватель и раньше был достижим через внешний адаптер tesseract.exe, который пишет BMP и парсит TSV. Тот путь работает, но каждая страница платит за запуск процесса, временный битмап-файл и текстовый формат без базовых линий и без контроля сегментации страницы. Вызов DLL снимает все три. Заодно он снимает процессную стену, а это значит, что Pascal-биндинг лежит прямо поверх C-структур, C-булев и C-выделенных строк. Большая часть того, что стоит знать об этом адаптере, — где такой биндинг может тихо пойти не так

Как запустить Tesseract in-process из Delphi с HotPDF?

Запуск Tesseract in-process с HotPDF занимает один вызов фабрики в юните HPDFTesseractRecognition и тот же вызов ApplyLoadedOCRTextLayer, которым пользуется каждый OCR-движок HotPDF. Фабрика валидирует заранее. Файл DLL и каталог tessdata обязаны существовать, идентификатор языка может содержать только ASCII-буквы, цифры, _ и +, каждая модель в комбинации вроде chi_sim+eng обязана иметь подходящий файл .traineddata, и все 21 нужный экспорт обязаны разрешиться до возврата движка. Ошибки конфигурации поднимают EArgumentException; DLL, не сумевшая загрузиться, поднимает EOSError с кодом ошибки Windows и подсказкой проверить архитектуру и зависимости

uses
  SysUtils, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // Win64-приложению нужна 64-битная DLL; DLL-зависимости кладутся рядом
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'chi_sim+eng');   // THPDFTesseractOptions.Default
  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
    // Пустой список страниц значит все страницы; страницы с текстом пропускаются
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' words accepted, ', Info.DroppedWordCount, ' dropped');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

THPDFTesseractOptions.Default ставит PageSegMode в tpsAuto, EngineMode в temDefault, TimeoutMilliseconds в 60 000 и MaxPixels в 16 777 216. Пиксельный бюджет важнее, чем кажется. Страница US Letter при дефолтных 300 DPI рендерится в 2 550 × 3 300 пикселей, около 8,4 миллиона, — влезает. Та же страница при 600 DPI — 5 100 × 6 600, около 33,7 миллиона, и адаптер отвергнет её раньше, чем Tesseract увидит хоть пиксель. Поднимайте MaxPixels (потолок — 67 108 864) или держите DPI на месте; каждая сторона к тому же зажата в 32 767 пикселей

DLL грузится через LoadLibraryEx с поисковыми флагами на собственную папку DLL плюс дефолтные безопасные каталоги, так что библиотеки образов, от которых зависит Tesseract, могут лежать рядом без трогания PATH и текущего каталога. HotPDF не бандлит и не скачивает никакого OCR-рантайма или моделей; то и другое проставляете вы

Что меняется по сравнению с адаптером tesseract.exe?

DLL-адаптер меняет изоляцию процессов на более богатый вывод и меньший оверхед на страницу. Оба адаптера встают в один конвейер текстового слоя, так что координатный маппинг, фильтрация по уверенности и фиксация всё-или-ничего идентичны; различается то, как пиксели въезжают и слова выезжают

АспектАдаптер tesseract.exeАдаптер Tesseract DLL
ФабрикаHPDFCreateTesseractOCREngineHPDFCreateTesseractDLLOCREngine
Пиксели на входBMP-файл в приватном временном каталоге8-битный grayscale-буфер в памяти
Слова на выходWord-уровневый TSV, потолок 64 МиБИтератор результатов, UTF-8 на слово
Базовые линииНедоступныПрокидываются из TessPageIteratorBaseline
Сегментация страницы и режим движкаТолько автоматическая сегментацияTHPDFTesseractPageSegMode, THPDFTesseractEngineMode
ТаймаутЖёсткий: дочерний процесс терминируетсяКооперативный: Tesseract обязан заметить
Изоляция падений и памятиОтдельный процессНет, делит ваше адресное пространство

Одна цена не исчезает. Каждый вызов Recognize создаёт собственный инстанс API и зовёт TessBaseAPIInit2, так что языковые модели инициализируются на каждую страницу, а не один раз на движок. Файловый кэш операционной системы смягчает перегрузку, но на крупных многоязычных наборах моделей это всё равно доминирующая фиксированная цена за страницу, и она съедает дедлайн распознавания. In-process движок RapidOCR DLL берёт противоположный дизайн и держит ONNX-модели резидентно всю жизнь движка; граничные проблемы (C ABI, заёмные буферы, непрерываемая нативная работа) — из одной семьи

Почему Delphi не может скопировать структуру monitor Tesseract?

Delphi не может безопасно отзеркалить progress monitor Tesseract, потому что ETEXT_DESC содержит зависящие от версии внутренние поля, и рукокопированная запись кладёт cancel-callback и дедлайн на неверные смещения на некоторых сборках. Ничто не падает громко, когда это случается. Tesseract просто читает ваш указатель на callback из поля, которое теперь держит нечто другое, — или вообще не видит дедлайна

Поэтому HotPDF трактует monitor как непрозрачный указатель и трогает его только через экспортируемые функции: TessMonitorCreate, TessMonitorSetCancelThis, TessMonitorSetCancelFunc, TessMonitorSetDeadlineMSecs и TessMonitorDelete. Если вы сами биндите C API для другой цели, применим тот же паттерн. Набросок ниже — ваш собственный биндинг-код, не API HotPDF, и зеркалит декларации, которые HotPDF использует внутри

Обращение с monitor в Tesseract DLL HotPDF: копирование зависящей от версии записи ETEXT_DESC кладёт cancel-callback и дедлайн на неверные смещения и отказывает молча, тогда как HotPDF трактует monitor как непрозрачный, рулит TessMonitorCreate, TessMonitorSetCancelThis, TessMonitorSetCancelFunc и TessMonitorSetDeadlineMSecs и держит cdecl-callback свободным от исключений
непрозрачный указатель плюс пять экспортов — весь контракт; callback остаётся однобайтовым Boolean, который читает лишь флаг и часы
type
  // C: typedef bool (*TessCancelFunc)(void *cancel_this, int words);
  TTessCancelFunc = function(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
  TTessMonitorCreate = function: Pointer; cdecl;   // ETEXT_DESC*, никогда не разыменовывается
  TTessMonitorDelete = procedure(Monitor: Pointer); cdecl;
  TTessMonitorSetCancelFunc = procedure(Monitor: Pointer; Func: TTessCancelFunc); cdecl;
  TTessMonitorSetCancelThis = procedure(Monitor, CancelThis: Pointer); cdecl;
  TTessMonitorSetDeadlineMSecs = procedure(Monitor: Pointer; MSecs: Integer); cdecl;
  TTessBaseAPIRecognize = function(Handle, Monitor: Pointer): Integer; cdecl;

  TOCRJob = record
    CancelRequested: Boolean;
    DeadlineTick: UInt64;
  end;
  POCRJob = ^TOCRJob;

function ShouldCancel(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
begin
  // Бежит на стеке Tesseract: читаем флаги и часы, никогда не поднимаем
  Result := (CancelThis = nil) or POCRJob(CancelThis)^.CancelRequested or
    (GetTickCount64 >= POCRJob(CancelThis)^.DeadlineTick);
end;

// Использование: указатели функций разрешены через GetProcAddress:
//   Monitor := MonitorCreate();
//   try
//     MonitorSetCancelThis(Monitor, @Job);
//     MonitorSetCancelFunc(Monitor, ShouldCancel);
//     MonitorSetDeadlineMSecs(Monitor, RemainingMs);
//     RC := BaseAPIRecognize(API, Monitor);
//   finally
//     MonitorDelete(Monitor);
//   end;

Две детали в том наброске нарочиты. Callback возвращает Boolean — один байт и в Delphi, и в Free Pascal, что совпадает с C bool в TessCancelFunc. Четырёхбайтовый Windows BOOL или Delphi LongBool выглядит взаимозаменяемым и не является: когда одна сторона пишет один байт, а другая читает четыре, старшие байты регистра возврата — что там осталось, и false может приехать как true. Тот же заголовок усложняет дальше: функции вроде TessPageIteratorBoundingBox возвращают int, который HotPDF декларирует как Integer. Читайте C-тип каждого возвращаемого значения вместо допущения одной конвенции на весь API

Вторая деталь — callback никогда не поднимает. Исключение Delphi, разматывающееся сквозь C++-фреймы Tesseract, — неопределённое поведение, поэтому callback HotPDF читает только токен отмены и монотонное значение GetTickCount64. Адаптер превращает результат в диагностику отмены или таймаута после возврата TessBaseAPIRecognize, и выполняет ту проверку независимо от нативного кода возврата

Какими нативными указателями владеет сторона Delphi?

Tesseract DLL-адаптер HotPDF владеет тремя нативными объектами на запрос — инстансом API, monitor и итератором результатов — и всё остальное берёт внаём. Каждый вызов Recognize создаёт собственный набор и освобождает его в блоке finally: TessResultIteratorDelete, затем TessMonitorDelete, затем TessBaseAPIDelete. Освобождение интерфейса движка выгружает библиотеку

Владение объектами в Tesseract DLL HotPDF на вызов Recognize: итератор результатов, monitor и инстанс API находятся во владении и освобождаются в этом порядке внутри finally, страничный итератор от TessResultIteratorGetPageIterator — заёмный вид, который никогда нельзя освобождать, а строки GetUTF8Text копируются и возвращаются через TessDeleteText
три объекта во владении, всё остальное внаёме: освобождайте в фиксированном порядке, никогда не освобождайте страничный итератор дважды и никогда не смешивайте аллокаторы
  • TessResultIteratorGetPageIterator возвращает заёмный вид внутрь итератора результатов, а не новый объект. HotPDF пользуется им для TessPageIteratorBoundingBox и TessPageIteratorBaseline и никогда не освобождает; отдельное удаление освободило бы ту же память дважды
  • TessResultIteratorGetUTF8Text возвращает строку, выделенную собственным рантаймом DLL. HotPDF копирует её и отдаёт обратно через TessDeleteText в блоке finally; Pascal-овский FreeMem освободил бы её на чужой куче
  • Текст слова декодируется строгой валидацией UTF-8 и проверкой длины до конверсии. Слова с управляющими символами, битым UTF-8, боксами вне образа, перевёрнутыми прямоугольниками или уверенностью вне 0–100 топят запрос вместо тихой заплатки
  • Суммарный текст на запрос зажат в 1 048 576 UTF-16-юнитов, а число слов обязано влезть в бюджет запроса, спущенный сверху ApplyLoadedOCRTextLayer

Уверенность приезжает как 0–100 и масштабируется в 0–1, так что THPDFOCRTextLayerOptions.MinimumConfidence значит одно и то же для каждого движка. Когда Tesseract отчитывает базовую линию, прокидываются обе конечные точки; иначе конвейер текстового слоя откатывается к геометрической оценке — ровно как для TSV-ввода

Зачем валидировать enum до того, как он доедет до DLL?

HotPDF копирует сырой ordinal из PageSegMode и EngineMode в Integer до проверки диапазона, потому что компилятор может предположить, что переменная enum всегда держит объявленное значение, и свернуть Ord(X) > Ord(High(T)) в константный false. Ordinal не украшение: THPDFTesseractPageSegMode следует нумерации сегментации страниц Tesseract от 0 до 13, THPDFTesseractEngineMode — нумерации режимов движка от 0 до 3, и оба едут в DLL как простые целые. Запись опций, собранная через FillChar, заполненная из потока или переданная из C++Builder с приведённым целым, может нести байт вроде 200. Валидация скопированного ordinal превращает это в EArgumentException на фабрике вместо неопределённого режима внутри нативного кода. Фабрика к тому же отвергает tpsOSDOnly и tpsAutoOnly, не дающие слов, и требует osd.traineddata для tpsAutoOSD и tpsSparseTextOSD

Что на самом деле гарантирует таймаут распознавания?

Таймаут Tesseract DLL кооперативен: HotPDF может остановить собственную работу и попросить Tesseract остановиться, но не может заставить нативный код вернуться. Часы стартуют с началом Recognize, так что конверсия битмапа и инициализация моделей съедают тот же бюджет, что и распознавание. HotPDF проверяет истёкшее время и токен отмены при конверсии в grayscale и между словами при итерации результатов, и передаёт оставшиеся миллисекунды в TessMonitorSetDeadlineMSecs перед вызовом TessBaseAPIRecognize

Зазор — внутри нативного вызова. Monitor Tesseract опрашивается во время распознавания слов, а не во время TessBaseAPIInit2 или анализа вёрстки страницы, так что медленная загрузка модели или патологическая вёрстка могут пробежать дедлайн раньше, чем таймаут будет отчитан. Пиксельный и выводной бюджеты к тому же не зажимают собственное потребление памяти нативной библиотеки. Если нужен воркер, которого можно убить, — берите процессный адаптер; это честный компромисс, а не отсутствующая фича

Анатомия кооперативного таймаута Tesseract DLL в HotPDF: часы стартуют с началом Recognize и накрывают конверсию в grayscale, TessBaseAPIInit2 и анализ вёрстки, но monitor опрашивается только во время распознавания слов, поэтому загрузки моделей и вёрстка могут вылезти за срок раньше, чем HotPDF отчитает otlsEngineError или otlsCancelled
дедлайн здесь — просьба, а не гарантия: init и анализ вёрстки могут бежать долго, а воркер, который можно по-настоящему убить, требует процессного адаптера

Сегментация страницы — то, на чём DLL-адаптер зарабатывает на трудном вводе. Формы, этикетки и отсканированные таблицы с разбросанными полями часто распознаются лучше с tpsSparseText, чем с автоматической сегментацией, которая пытается собрать колонки и абзацы, которых нет

procedure OCRFormPages(Doc: THotPDF; const Pages: array of Integer);
var
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  LayerOptions: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default;
  TessOptions.PageSegMode := tpsSparseText;  // разбросанные поля, без сборки колонок
  TessOptions.EngineMode := temLSTMOnly;     // нужны LSTM-модели в tessdata
  TessOptions.TimeoutMilliseconds := 20000;  // включает инициализацию моделей
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'eng+deu', TessOptions);

  LayerOptions := THPDFOCRTextLayerOptions.Default;
  LayerOptions.MinimumConfidence := 0.6;
  if not Doc.ApplyLoadedOCRTextLayer(Pages, Engine, LayerOptions, Info) then
    case Info.Status of
      otlsCancelled:
        Writeln('OCR cancelled, document unchanged');
      otlsEngineError:
        Writeln('Tesseract failed or timed out: ', string(Info.Diagnostic));
    else
      Writeln(string(Info.Diagnostic));
    end;
end;

Таймаут всплывает как otlsEngineError с диагностикой Tesseract DLL OCR timed out, а отменённый токен — как otlsCancelled. В обоих случаях ApplyLoadedOCRTextLayer распознал каждую выбранную страницу прежде, чем запустить транзакцию фиксации, так что неудача на странице 40 из 50 оставляет загруженный документ ровно как был. Заметьте: tpsSingleLine, tpsSingleBlock и tpsSparseText меняют только сегментацию; ни один не выпрямляет перекошенный скан

Free Pascal и Lazarus: протухшие пиксели и потерянный китайский

Обе фабрики Tesseract работают в Windows Free Pascal и сборках Lazarus Win32 и Win64 с v2.772.1 — после двух специфичных для FPC правок. Сперва пересоберите пакет Lazarus под целевую архитектуру; общий порт разобран в статье о HotPDF на Free Pascal и Lazarus Win64

Первая правка про пиксели. LCL-овский TBitmap, записанный через scanlines, может обновить сырой образ, не обновив Windows-хэндл битмапа, так что GetDIBits на том хэндле вернёт старые пиксели. Симптом ставил в тупик: текст, нарисованный прямо на битмап, распознавался, а страница, отрендеренная PDF-рендерером HotPDF, давала пустой список слов. На FPC адаптер теперь читает формат-осведомлённый снимок через CreateIntfImage, уважающий пиксельный формат и порядок строк сырого образа. Сборка Delphi держит путь GetDIBits на приватной 24-битной копии. Ни одна сборка не модифицирует битмап вызывающего

Вторая правка принадлежит адаптеру tesseract.exe. TStringList в FPC хранит ANSI-строки, так что присваивание декодированного UTF-8 TSV-текста в Lines.Text молча выбрасывало каждый китайский или supplementary-plane символ, который системная ANSI-кодовая страница не могла представить. FPC-путь теперь держит TSV как байты UTF-8, снимает BOM на байтовом уровне и декодирует каждое слово в UnicodeString по отдельности. DLL-адаптер этой проблемы никогда не имел, потому что декодирует каждое слово прямо из итератора

Шпаргалка

  • Фабрика: HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language[, Options]) в HPDFTesseractRecognition, добавлена в v2.772.0, поддержка FPC в v2.772.1
  • Дефолты: tpsAuto, temDefault, 60 000 мс, 16 777 216 пикселей; диапазон таймаута 1–3 600 000 мс, пиксельный потолок 67 108 864
  • Подгоняйте разрядность DLL под приложение и кладите DLL-зависимости рядом с Tesseract DLL
  • Трактуйте monitor как непрозрачный; никогда не копируйте ETEXT_DESC в Pascal-запись
  • Декларируйте cancel-callback как cdecl с однобайтовым Boolean-результатом и никогда не позволяйте исключению из него вырваться
  • Освобождайте текст итератора через TessDeleteText; никогда не освобождайте страничный итератор, полученный из итератора результатов
  • Ждите от дедлайна кооперативности: инициализация моделей и анализ вёрстки могут его перебежать
  • Берите адаптер tesseract.exe, когда нужна жёсткая остановка или изоляция падений

Tesseract DLL-адаптер, процессные адаптеры и встроенный OCR-движок поставляются с компонентом HotPDF Delphi PDF component для Delphi, C++Builder и Free Pascal; издания и загрузки — на странице продукта HotPDF