Технічна стаття

Tesseract OCR у пошуковий PDF у Delphi з HotPDF

HotPDF перетворює відскановані сторінки PDF на пошуковий PDF із Tesseract через HPDFCreateTesseractOCREngine — фабрику, яка загортає локально встановлений виконуваний файл Tesseract у IHPDFOCREngine. Цей рушій ви передаєте в ApplyLoadedOCRTextLayer, який рендерить кожну сторінку, ганяє Tesseract раз на сторінку, розбирає його word-рівневий TSV-вивід і комітить невидимий Unicode-текстовий шар для всіх запитаних сторінок однією транзакцією — або жодної з них

OCR-конвеєр HotPDF на сторінку: відрендерити сторінку в заданому DPI, зберегти input.bmp у приватній директорії HotPDF-OCR, запустити дочірній процес Tesseract з tessedit_create_tsv, розібрати дванадцятиколонковий TSV, відфільтрувати слова за confidence і закомітити невидимий текстовий шар для всіх запитаних сторінок чи жодної
Адаптер замінює лише розпізнавання: рендеринг, розбір, валідація і коміт все-або-нічого лишаються в наявному текстошаровому конвеєрі, тож код нижче по течії не змінюється

Причина існування цього адаптера — охоплення. Вбудований 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, а масив слів очищається. Регресні тести включають рівно той випадок: одне валідне слово, за яким іде зламаний рядок, має дати нуль слів, а не одне

Шість гейтів, які проходить кожен рядок TSV від Tesseract у HotPDF: точний дванадцятиколонковий заголовок, рівно дванадцять полів, лише рівень 5, непорожній текст, бокс всередині бітмапа і confidence від 0 до 100, розпарсений інваріантно, — де один зламаний рядок валить усю сторінку до нуля слів
Частково розібраний список слів тихо розходився б із зображенням, тож парсер відмовляє цілу сторінку на першому кривому рядку замість тримати слова, які вже прочитав
// стиснуто з циклу рівня 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-кодеків у робочих процесах, де дитина — недовірений код; тут дитина довірена, але хост — не єдиний власник власної таблиці хендлів

Успадкування хендлів дочірнім процесом Tesseract у HotPDF: звичайний CreateProcess з bInheritHandles передає дитині кожен успадковуваний хендл файлу, пайпа і події, тоді як STARTUPINFOEX з PROC_THREAD_ATTRIBUTE_HANDLE_LIST обмежує набір хендлом NUL для stdin і stdout плюс файловим хендлом stderr
Без списку атрибутів дитина тримає нерелевантні об'єкти живими до свого виходу, блокуючи файли і голодуючи пайпи; із ним через межу переходять лише два перелічені хендли
// константи показані іменами; сирець передає їхні числові значення
// обидва хендли створені з 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-рушій