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

Додавання шару тексту для пошуку до сканованих PDF у Delphi

PDFium Component додає до сканованих сторінок PDF шар тексту, придатний для пошуку, з Delphi через ApplyOcrSearchLayer. Він рендерить кожну обрану сторінку, передає пікселі постачальнику OCR, який ви надаєте, і записує розпізнані слова назад як невидимі текстові об'єкти, розміщені поверх слів у скані. Оригінальне зображення сторінки ніколи не декодується, не перекодовується й не замінюється, тож візуальний результат — це побайтово та сама сторінка, з якої ви почали

Рушій розпізнавання свідомо не є частиною бібліотеки. PDFium надає рендеринг сторінок, відображення координат, завантаження шрифтів, створення текстових об'єктів і невидимі режими рендерингу, але не містить жодного рушія OCR, і вдавання протилежного означало б включення чужого продукту розпізнавання в компонент PDF. Натомість розпізнавання живе за інтерфейсом IPdfOcrProvider: бібліотека передає пікселі BGRA з фіксованим компонуванням і початком координат зверху, а постачальник повертає Unicode-текст, значення впевненості та чотирикутники слів

Що таке шар тексту, придатний для пошуку?

Сканований PDF — це фотографія документа. Вміст сторінки — одне велике зображення, і в ньому нічого не можна виділити, знайти, скопіювати чи проіндексувати. Шар тексту, придатний для пошуку, додає справжні текстові об'єкти поверх цього зображення з режимом рендерингу, встановленим на невидимий, тож переглядачі нічого не малюють, а виділення, пошук і вилучення знаходять слова точно там, де вони з'являються

Позиціювання — це вся суть гри. Якщо невидимий текст сидить на кілька пунктів осторонь, виділення виявляється поруч зі словами, а не на них, а копіювання абзацу дає текст у неправильному порядку. Ось чому геометрія має надходити з тих самих трансформацій, які PDFium використовує для рендерингу сторінки, а не з пропорційного припущення

Реалізація постачальника

Контракт постачальника — один метод. Він отримує запис зображення сторінки, що несе розміри, крок рядка, DPI, формат пікселя та самі байти пікселів, плюс токен скасування, і повертає слова або повідомлення про помилку:

uses
  PDFium;

type
  TMyOcrProvider = class(TInterfacedObject, IPdfOcrProvider)
  public
    function RecognizePage(const Image: TPdfOcrImage;
      const CancellationToken: IPdfCancellationToken;
      out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
  end;

function TMyOcrProvider.RecognizePage(const Image: TPdfOcrImage;
  const CancellationToken: IPdfCancellationToken;
  out Words: TPdfOcrWords; out ErrorMessage: string): Boolean;
var
  I: Integer;
begin
  // Image.Pixels містить рядки BGRA з початком зверху завдовжки
  // Image.Stride байтів. Передайте їх своєму рушію, потім заповніть
  // по одному запису на кожне розпізнане слово
  SetLength(Words, RecognisedCount);
  for I := 0 to RecognisedCount - 1 do
  begin
    Words[I].Text := EngineWordText(I);
    Words[I].Confidence := EngineWordConfidence(I);   // 0..1
    Words[I].Quad := TPdfOcrQuad.FromRectangle(
      EngineLeft(I), EngineTop(I), EngineRight(I), EngineBottom(I));
  end;
  ErrorMessage := '';
  Result := True;
end;

Чотирикутники, а не прямокутники, бо скан рідко буває строго паралельний сторінці. Слово на трохи повернутій сторінці займає паралелограм, і TPdfOcrQuad несе чотири кутові точки, тож перекошені й повернуті слова зберігають точну область виділення. Рушії, що повідомляють лише прямокутники, вирівняні за осями, можуть використовувати FromRectangle, який будує вироджений чотирикутник

Чому позиції слів не можна масштабувати пропорційно?

Виникає спокуса перетворювати координату пікселя на координату сторінки, ділячи на ширину рендерингу й множачи на ширину сторінки. Це працює лише для сторінок без повороту, з CropBox, ідентичним MediaBox, і початком координат у нулі, а чимало сканованих документів не відповідають принаймні одній з цих умов

PDFium Component відображає кожен з чотирьох кутів чотирикутника окремо через FPDF_DeviceToPage — те саме відображення, яке рендерер використав для отримання пікселів, тож записи /Rotate і зміщені рамки обрізки обробляються за конструкцією. Афінна матриця для текстового об'єкта потім будується з трьох відображених точок — нижнього лівого, нижнього правого та верхнього лівого кутів, чого рівно достатньо, щоб виразити позицію, масштаб, поворот і зсув

Сам текстовий об'єкт створюється з одиничним розміром шрифту, щоб можна було виміряти його реальні межі шрифту, а виміряні межі об'єкта потім відображаються на цільовий чотирикутник. Визначення розміру за вгаданим кеглем у сподіванні, що він збігається зі сканованим словом, дрейфувало б з кожною заміною шрифту; попереднє вимірювання робить відповідність незалежною від того, який шрифт використовує шар

Запуск на документі

Запис параметрів керує роздільною здатністю, фільтрацією та кожним бюджетом. Фільтрація за впевненістю важливіша, ніж здається: сміттєві слова з низькою впевненістю назавжди забруднюють результати пошуку, і на відміну від неправильного рендерингу, ніхто цього не помітить, доки пошук не поверне нісенітницю:

var
  Pdf: TPdf;
  Options: TPdfOcrOptions;
  Report: TPdfOcrReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'scanned-contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfOcrOptions.Default;
    Options.Dpi := 300;                  // роздільна здатність розпізнавання
    Options.MinConfidence := 0.60;       // відкидати непевні слова
    Options.SkipPagesWithText := True;   // не чіпати сторінки, спочатку цифрові
    Options.ContinueOnError := True;     // одна погана сторінка не повинна зупиняти завдання
    Options.MaxPixelsPerPage := 40 * 1000 * 1000;

    if Pdf.ApplyOcrSearchLayer(TMyOcrProvider.Create, Options, Report) then
      Pdf.SaveAs('scanned-contract-searchable.pdf');

    for I := 0 to High(Report.Pages) do
      if Report.Pages[I].Status = popsFailed then
        Writeln(Format('page %d failed: %s',
          [Report.Pages[I].PageNumber, Report.Pages[I].ErrorMessage]));
    Writeln(Format('%d word(s) inserted, %d rejected, %d page(s) skipped',
      [Report.InsertedWordCount, Report.RejectedWordCount,
       Report.SkippedPageCount]));
  finally
    Pdf.Free;
  end;
end;

SkipPagesWithText заслуговує на акцент у мішаних архівах. PDF, що вже несе справжній текст, чи то спочатку цифровий, чи оброблений раніше, отримує другий шар тексту, якщо запустити OCR наосліп, а дублікат змушує вилучення повертати кожне слово двічі. Статус для кожної сторінки popsSkippedExistingText точно повідомляє, які сторінки лишили недоторканими

Бюджети, скасування та стримування збоїв

Кожна величина, яку ворожий чи просто величезний документ може роздути, має стелю: пікселі на сторінку й загалом, слова на сторінку й загалом, і символи на слово. Усі вони перевіряються до запису сторінки, а не після, а оцінка пікселів обчислюється з розмірів сторінки та DPI ще до виділення будь-якого растрового зображення. Підвищення DPI з 150 до 300 вчетверо збільшує пам'ять на сторінку, тож стеля на сторінку — перший параметр, який варто налаштувати, коли пакетне завдання починає провалюватися на великих форматах

Токен скасування проходить через увесь шлях: прогресивний рендеринг, виклик постачальника та цикл вставки слово за словом. Це означає, що користувач, який скасовує під час розпізнавання 400-сторінкового файлу, зупиняється в межах однієї сторінки, а не в кінці документа, і той самий шаблон токенів, що використовується деінде в компоненті, описаний у статті скасовуваний прогресивний рендеринг, застосовується тут без змін

Стримування збоїв відбувається на рівні сторінки. Бібліотека збирає дескриптори об'єктів, які вона вставила на сторінку, і викликає FPDFPage_GenerateContent один раз, після розміщення всіх слів. Якщо щось провалюється посередині — чи то помилка постачальника, чи проблема зі шрифтом — об'єкти, вставлені на цю сторінку, видаляються у зворотному порядку, а вміст сторінки перегенеровується, тож сторінка зі збоєм повертається до свого початкового стану замість того, щоб зберігати половину шару тексту. Цикл документа потім продовжується або зупиняється відповідно до ContinueOnError, а активна сторінка завжди відновлюється

Перевірка, що зображення справді лишилося недоторканим

Найпотужніша доступна перевірка водночас і найпростіша: відрендерте сторінку до і після застосування шару в тому самому розмірі та порівняйте растрові зображення. Вони мають бути ідентичними побайтово, оскільки невидимий текст нічого не малює, а потік зображення ніколи не декодувався. Будь-яка різниця означає, що сторінку змінило щось інше, ніж шар тексту

Після цього перевірте текстову сторону, вилучаючи текст з обробленого файлу та підтверджуючи, що позиції слів потрапляють на скан. Шлях вилучення той самий, що описаний у статті вилучення тексту з PDF-документів, а для швидкої візуальної перевірки вирівнювання рендеринг сторінок у зображення, як у статті конвертація сторінок PDF у JPEG, дозволяє накласти рамки слів на скан

Накладання шару OCR, рендеринг, вилучення та редагування — усе це працює з тим самим об'єктом документа в Delphi, C++Builder і Lazarus; повна поверхня API описана на сторінці PDFium Component для Delphi