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

Китайский и мультиязычный OCR с RapidOCR в HotPDF

HotPDF выполняет китайский и мультиязычный OCR в Delphi через свой нативный адаптер RapidOCR DLL: THPDFRapidOCRDLLOptions.ForLanguage мапит языковой тег вроде 'zh-CN', 'zh-TW', 'ru' или 'ar' на подобранную пару модели распознавания и словаря символов, а THotPDF.ApplyLoadedOCRTextLayer превращает распознанные строки в невидимый searchable Unicode-текстовый слой на отсканированных страницах PDF

Завести демку на латинице — самая лёгкая часть. Интересные отказы начинаются, когда вы переключаетесь на традиционный китайский или русский и вывод превращается в уверенный, хорошо оформленный бред, или когда каждая строка молча теряет последний символ, или когда арабская страница возвращается с текст-боксами в неправильном порядке. Ни одно из этого само по себе исключение не поднимает. Языковые пресеты, добавленные в HotPDF v2.775.0, существуют по большей части, чтобы закрыть эти дыры, и четыре ловушки ниже стоят понимания, даже если вы никогда не трогаете нативный код: каждая объясняет симптом, за которым иначе можно провести день погони

Как ForLanguage выбирает модель и словарь?

THPDFRapidOCRDLLOptions.ForLanguage разрешает тег в один из девяти профилей и возвращает опции, указывающие на <profile>/recognition.onnx и <profile>/dictionary.txt под вашим каталогом моделей, сохраняя общий детектор, опциональный классификатор угла и дефолты потока, пикселей и таймаута из THPDFRapidOCRDLLOptions.Default. Метод приводит тег к нижнему регистру, превращает подчёркивания в дефисы и обрезает окружающие пробелы, так что 'zh_TW', 'ZH-tw' и ' zh-tw ' приземляются на один профиль. Псевдонимы — явный список, а не префиксный матч: 'zh-Hant-TW' принимается, потому что перечислен, тогда как произвольный региональный вариант вне списка поднимает EArgumentException до загрузки любой модели

Разрешение профиля ForLanguage в HotPDF для THPDFRapidOCRDLLOptions: теги вроде zh_TW, ZH-tw и zh-TW нормализуются и сверяются с девятью перечисленными профилями, каждый прикалывает модель распознавания и словарь, выставляемые всегда вместе, а неперечисленный тег поднимает EArgumentException до загрузки какой-либо модели
один тег выбирает одну приколотую пару модель-словарь; детектор, классификатор и бюджеты остаются общими, а неизвестный тег падает рано, вместо того чтобы что-либо грузить
ПрофильЯзыкиПримеры теговПриколотая модель
chУпрощённый китайский и английскийzh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_chtТрадиционный китайскийzh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
enАнглийскийen, en-US, en-GB, engPP-OCRv4
latinФранцузский, немецкий, испанский, португальский, итальянский, нидерландский, турецкийfr, de, es-419, pt-BR, trPP-OCRv3
japanЯпонскийja, ja-JP, jpnPP-OCRv4
koreanКорейскийko, ko-KR, korPP-OCRv4
cyrillicРусский, украинский, болгарский, белорусскийru, ru-RU, uk, bgPP-OCRv3
arabicАрабский, персидский, урдуar, ar-SA, fa, urPP-OCRv4
devanagariХинди, маратхи, непальскийhi, mr, nePP-OCRv4

Сам адаптер ничего не скачивает. Файлы вы проставляете однажды вкомпилированным помощником, например tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic (или -Language All для всех девяти профилей), и помощник кладёт общий детектор и классификатор под корневые имена файлов, которых ждёт Default. После этого отсканированный упрощённый китайский становится searchable за несколько строк. Движковая обвязка — тот же шов IHPDFOCREngine, описанный в статье об in-process RapidOCR DLL и её границе ABI, так что эта остаётся сфокусированной на языках

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt, общий детектор и классификатор
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI, MinimumConfidence 0.5
    // пустой список страниц значит все страницы; страницы с текстом пропускаются
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

Две детали в том выводе заслуживают примечания. Нативный конвейер возвращает один результат на детектированную строку текста, а не на слово, так что AcceptedWordCount здесь считает строки, а MinimumConfidence сравнивается со средней уверенностью строки по символам: строка со средним 0.45 отбрасывается как единое целое. UniqueScalarCount отчитывает, сколько различных Unicode-скаляров текстовому слою пришлось вмапить в свой шрифт и таблицу ToUnicode — полезная санитарная проверка, что CJK-текст действительно доехал, а не горстка латинских фоллбэков. Держите интерфейс движка живым между документами: инициализация моделей происходит в фабрике и является дорогим шагом

Почему смена только модели распознавания выдаёт мусор?

CTC-модель распознавания никогда не выводит символы — только индексы классов, и словарь — единственное, что превращает индекс 1,204 в глиф. Подмените ch/recognition.onnx на cyrillic/recognition.onnx, оставив китайский словарь, и модель с удовольствием выдаст валидные кириллические индексы, которые старый словарь переведёт в случайные ханьские иероглифы. Результат выглядит как текст, проходит валидацию UTF-8 и searchable ровно для ничего. Поэтому ForLanguage всегда выставляет RecognitionModel и CharacterDictionary вместе, а рукописные опции не должны менять одно без другого

Очевидная проверка безопасности — сверка размера словаря с шириной выхода модели — необходима, но недостаточна. Два словаря могут иметь поровну записей в разном порядке, и ошибка на единицу в порядке сдвигает каждый символ на одну кодовую точку. Поэтому HotPDF проверяет в два этапа, когда фабрика инициализирует модель. Во-первых, число классов выхода обязано равняться записям словаря плюс два. Во-вторых, когда ONNX-файл вшивает метаданные-список character, каждая запись словаря сверяется с ним по порядку, и расхождение топит инициализацию с EInvalidOperation и нативной диагностикой вместо правдоподобного мусора позже

«Плюс два» растут из раскладки классов. Класс 0 — CTC-пустой, классы 1..N — строки словаря в файловом порядке, а финальный класс — пробел. Некоторые словари несут и собственную запись-пробел, и эту строку надо оставить ровно как есть. Именно здесь добрый Trim наносит реальный ущерб: он превращает запись из одиночного пробела в пустую строку и сдвигает или ломает таблицу. Единственная безопасная нормализация — снять завершающий возврат каретки, так что словарь, сохранённый с окончаниями CRLF, грузится корректно, тогда как BOM в UTF-8, пустая строка или запись с табом отвергаются. Набросок ниже показывает раскладку на Pascal; это пояснительный код, а не API HotPDF

Раскладка таблицы классов CTC в HotPDF для словарей RapidOCR: класс 0 — пустой, классы 1..N — строки словаря в файловом порядке с сохранённой одинокой записью-пробелом, а финальный класс — пробел, что даёт N плюс 2 класса выхода, которые фабрика сверяет с моделью, включая метаданные
словарь — единственное, что превращает индексы классов в символы, поэтому его размер, порядок и запись-пробел проверяются до распознавания хоть одной страницы
// Только иллюстрация: таблица классов, которую ждёт CTC-распознаватель
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // перевод строки в конце файла
  SetLength(Result, Last + 3);
  Result[0] := '';                               // класс 0: CTC-пустой
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF: снимаем только CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // никогда Trim: ' ' — это класс
  end;
  Result[Last + 2] := ' ';                       // финальный класс: пробел
  // Length(Result) обязана равняться числу классов выхода модели
end;

Что на самом деле делает жадное CTC-декодирование?

Жадное CTC-декодирование берёт класс с высшей оценкой на каждом временном шаге, схлопывает подряд идущие повторы в один символ и выбрасывает пустой класс; пустой — то, что позволяет по-настоящему удвоенным буквам выживать. Модель распознавания смотрит на строку текста как на последовательность узких вертикальных срезов и для каждого среза, или временного шага, выдаёт вероятность по каждому классу. Строка с AA中 может дать argmax-последовательность A A blank A 中 space. Схлопывание первых двух шагов A даёт одну A, пустой отделяет её от следующей A, и результат — AA中 с нетронутым хвостовым пробелом. Без правила пустого book и bok были бы неразличимы

Разбор GreedyCTCDecode в HotPDF: шесть временных шагов голосуют argmax-классами A, A, blank, A, ханьский иероглиф и пробел, подряд идущие повторы схлопываются, blank сбрасывает сторож повторов, так что по-настоящему удвоенная буква выживает, а три граничных бага молча теряют пробелы между словами, последний символ или удвоенные символы
декодер — дюжина строк, и каждая граница важна: включайте последний класс, включайте последний шаг и позволяйте разделять повторы только пустому

Поскольку декодер — всего дюжина строк, границы легко испортить, а отказы безмолвны. Если внутренний argmax-цикл остановится за один класс до конца, класс пробела никогда не выиграет, и каждая строка вернётся без пробелов между словами, что убивает фразовый поиск на английских и латинских страницах. Если внешний цикл остановится за один временный шаг до конца, у каждой строки исчезнет последний символ, что для короткой строки может быть третья текста. А если сторож повторов не сбрасывается пустым, удвоенные символы вроде ll или китайские редупликации вроде 谢谢 схлопнутся в один. Декодер HotPDF включает последний класс и последний временный шаг, держит повторы, разделённые пустым, и вдобавок отвергает оценки, не являющиеся конечными или выходящие за 0..1, и любое число классов, не совпадающее со словарём. Вот та же логика как Pascal-иллюстрация

// Только иллюстрация: жадное CTC-декодирование с корректными границами.
// Scores держит Steps * Classes вероятностей, по строке на временной шаг
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // класс 0 — CTC-пустой
  for Step := 0 to Steps - 1 do             // включаем последний временной шаг
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // включаем последний класс (пробел)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // пустой сбрасывает сторож повторов
  end;
end;

Жадное декодирование — не самая точная из доступных CTC-стратегий; beam search с языковой моделью может вытянуть часть двусмысленных срезов. Для печатных документов при 300 DPI жадный результат — обычно всё, что может предложить модель, и декодер не то место, где стоит компенсировать слабости модели. Латинская модель PP-OCRv3, например, может прочесть ñ как n даже на чистом вводе. HotPDF не замазывает это пост-обработкой с заменами символов: таблица подстановок, чинящая испанский, ломает что-то другое, а неверный символ в searchable-слое хуже честного промаха

Как HotPDF упорядочивает строки текста, включая right-to-left арабский?

HotPDF сортирует детектированные текст-боксы сверху вниз, группирует боксы в строку, когда они перекрываются по вертикали минимум на половину высоты меньшего бокса, и упорядочивает каждую строку слева направо, а при включённом RightToLeft — справа налево; символы внутри каждой распознанной строки никогда не разворачиваются. Группировка важна, потому что детектор часто режет одну визуальную строку на несколько боксов — скажем, метку и значение, разделённые широким зазором, — и чистая сортировка по верхней координате переплетёт их с соседней строкой, стоит верхним краям разойтись на пиксель-другой

Арабский пресет выставляет RightToLeft := True, что велит DLL упорядочивать боксы в каждой строке по правому краю, от правого поля внутрь. Это весь эффект. Текст, который модель возвращает для строки, уже в Unicode-логическом порядке — в том, в котором арабский читатель читает и набирает, — и это же порядок, которого ждут извлечение текста и поиск в PDF. Механический разворот строки, чтобы она «выглядела правильно» в отладчике, сломал бы поиск, копирование-вставку и скринридеры. Двунаправленный показ и глиф-шейпинг — работа вьюера

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

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // поднимает EArgumentException на неизвестном теге, до загрузки любой модели
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // запас под страницы A3 при 300 DPI
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // профиль chinese_cht
  Arabic := CreateRapidEngine('ar-SA');   // профиль arabic, RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

Строка с MaxPixels там неспроста. Опции DLL дефолтно дают 16 777 216 пикселей на запрос — A4 и US Letter при 300 DPI это покрывает с запасом, но страница A3 при 300 DPI — примерно 3508 на 4961 пикселей, около 17,4 миллиона, и запрос будет отвергнут как перерасход. Поднимайте MaxPixels (потолок — 67 108 864) или снижайте THPDFOCRTextLayerOptions.DPI для крупных форматов. Right-to-left упорядочение пользуется опциональным экспортом HPDFRapidOCRSetReadingDirection ABI версии 1; адаптер требует его, только когда выставлен RightToLeft, так что DLL постарше продолжает обслуживать языки слева направо и падает при создании движка с EArgumentException, называющей отсутствующий экспорт для арабского

Почему новые OCR-модели не грузятся?

RapidOCR DLL от HotPDF линкует статический ONNX Runtime 1.14, который не читает модели, сохранённые с ONNX IR версии 10, а свежие экспорты вроде моделей PP-OCRv5 могут требовать рантайм поновее; такая модель падает при создании движка с нативной диагностикой. Именно это ограничение — причина, почему языковые пакеты приколоты к конкретным парам распознаватель-словарь PP-OCRv3 и PP-OCRv4, а не к «последним», и почему таблица выше мешает оба поколения: каждая приколотая пара — та, что грузится и верифицируется под этим рантаймом

Инсталлер следит за парностью. Каждый файл манифеста несёт хэш SHA256, существующий файл с другим хэшем останавливает установку вместо перезаписи, и каждая загрузка приземляется под временным именем и переезжает на место лишь после совпадения хэша. Это защита от тихой версии словарной проблемы: кто-то вручную подкладывает свежий recognition.onnx в папку профиля, число классов случайно совпадает, и ничего не падает, пока клиент не отчитает, что поиск не находит слова, которые он воочию видит. В рантайме адаптер остаётся офлайн и никогда не дотягивает отсутствующую модель. Распознаватель заодно валидирует форму модели при загрузке, принимая NCHW-вход с фиксированной высотой 32 или 48 пикселей или динамической, которую гоняет на 48

Если вам нужна письменность, которую не покрывает ни один из девяти профилей, можно по-прежнему навести RecognitionModel и CharacterDictionary на собственные файлы. Те же проверки применяются — в этом и смысл: непарная пара падает при инициализации, а не в архиве вашего клиента. Для страниц, где не подходит ни один профиль RapidOCR, адаптер Tesseract для searchable PDF встаёт в тот же вызов ApplyLoadedOCRTextLayer, а для машинопечатных ASCII-форм встроенному OCR-движку на сопоставлении шаблонов модели не нужны вовсе

Шпаргалка: чек-лист мультиязычного RapidOCR

  • Создавайте опции через THPDFRapidOCRDLLOptions.ForLanguage и трактуйте EArgumentException как неподдерживаемый тег, а не рантайм-аварию
  • Меняйте RecognitionModel и CharacterDictionary вместе, никогда по одному; равные числа классов не доказывают равный порядок символов
  • Держите словари в UTF-8 без BOM, никогда не тримте записи и ждите от модели N + 2 классов: пустой, N записей, пробел
  • Кастомный CTC-декодер обязан накрывать последний класс и последний временной шаг и держать повторы, разделённые пустым
  • Используйте один движок на языковой профиль и передавайте явные списки страниц для документов со смешанными письменностями
  • RightToLeft меняет только порядок боксов; распознанный текст остаётся в Unicode-логическом порядке
  • Ставьте модели через Install-RapidOCRModels.ps1, чтобы SHA256-пины держали парность модели и словаря; выставляйте UseAngleClassifier := False, если ставили с -SkipClassifier
  • Поднимайте MaxPixels выше дефолтных 16 777 216, прежде чем гонять страницы A3 и крупнее при 300 DPI

Языковые пресеты RapidOCR, нативный DLL-адаптер и конвейер OCR-текстового слоя — часть HotPDF Delphi PDF Component для Delphi, C++Builder и Windows FPC/Lazarus, начиная с v2.775.0 для мультиязычных профилей