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

Добавление слоя текста для поиска в сканированные PDF в Delphi

PDFium Component добавляет слой текста для поиска на сканированные страницы PDF из Delphi через ApplyOcrSearchLayer. Он рендерит каждую выбранную страницу, передаёт пиксели поставщику OCR, который вы предоставляете, и записывает распознанные слова обратно как невидимые текстовые объекты, размещённые поверх слов на скане. Исходное изображение страницы никогда не декодируется, не перекодируется и не заменяется, поэтому визуальный результат побайтово совпадает со страницей, с которой вы начали

Движок распознавания намеренно не входит в состав библиотеки. PDFium предоставляет рендеринг страниц, отображение координат, загрузку шрифтов, создание текстовых объектов и невидимые режимы рендеринга, но не содержит движка OCR, и притворяться иначе означало бы включить чей-то продукт распознавания в PDF-компонент. Вместо этого распознавание живёт за интерфейсом IPdfOcrProvider: библиотека передаёт пиксели с фиксированной раскладкой, верхним началом координат и форматом BGRA, а поставщик возвращает текст в Юникоде, значения достоверности и четырёхугольники слов

Что именно представляет собой слой текста для поиска?

Сканированный 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 для Delphi