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

PDFium: структурное извлечение текста PDF в Delphi

PDFiumPas возвращает текст страницы в виде структуры, а не строки. GetStructuredText формирует TPdfStructuredTextPage, содержащий блоки, каждый из которых содержит строки, каждая из которых содержит оформленные фрагменты (spans), с границами в пространстве страницы на каждом уровне и сохранёнными индексами исходных символов, так что любой фрагмент можно сопоставить обратно с лежащей в основе текстовой страницей

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

Почему плоская строка — неверный формат вывода для большинства задач?

Потому что вопросы, которые люди задают к извлечённому тексту, почти никогда не звучат как «какие символы есть на этой странице». Они звучат как «что здесь заголовок», «это таблица», «принадлежит ли этот абзац разделу 4», «где рисовать подсветку». Одна строка не отвечает ни на один из них, а каждый ответ, который вы из неё восстанавливаете, — это эвристика, ответственность за которую теперь на вас

Двухколоночная вёрстка делает это конкретным. Извлеките двухколоночную статью в виде строки, и, в зависимости от того, как генератор записал поток содержимого, вы можете получить сначала колонку один, затем колонку два, а можете получить строку один колонки один, строку один колонки два, строку два колонки один и так далее вниз по странице. Оба варианта возможны из конформного PDF. Ни один не является ошибочным на уровне формата, потому что PDF описывает метки на странице, а не структуру документа. Модель на основе блоков позволяет экстрактору явно принять решение о порядке и сообщить вам, какое решение было принято

Порядок содержимого или физическая раскладка?

TPdfStructuredTextOptions.ReadingOrder выбирает между roContentOrder и roPhysicalLayout, и правильный ответ зависит от того, чему вы доверяете больше — генератору или геометрии

Порядок содержимого возвращает текст в той последовательности, в которой его рисует поток содержимого. Это быстро и для документов, сгенерированных добросовестным генератором, обычно совпадает с задуманным порядком чтения. Физическая раскладка игнорирует последовательность потока и восстанавливает порядок по тому, где символы реально расположены, группируя их сначала в строки, а затем в колонки. Это то, что нужно для отсканированных и распознанных OCR страниц, для вывода инструментов, выдающих текст в порядке шрифта, а не чтения, и для всего, где визуальный результат — единственное, на что можно опереться

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // нумерация с 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // отказоустойчивый бюджет

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

Что добавляет тегирование, чего не может дать геометрия?

Намерение. При включённом IncludeSemantics блоки из тегированного PDF несут Kind, взятый из дерева структуры, поэтому заголовок является заголовком потому, что так сказал генератор, а не потому, что его шрифт был крупнее среднего. Виды охватывают формы, важные для повторного использования: cfParagraph, cfHeading с HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure и нетегированный запасной вариант cfPlain

Поле Source фиксирует, откуда взялась каждая классификация, — rosStructure для дерева структуры и rosHeuristic для вывода по эвристике, и именно это поле стоит логировать, когда вы решаете, насколько доверять конвейеру извлечения на наборе документов. Рисунки — особый случай, о котором стоит знать: для блока cfFigure текст берётся из альтернативного описания, а не из каких-либо глифов, поскольку у рисунка нет собственных символов. Непривязанный альтернативный текст всё равно представляется, а не отбрасывается, — именно это позволяет аудиту доступности увидеть, что описание существует, даже если ничто на странице его не рисует. Сама модель тегирования рассмотрена в статье о валидации дерева структуры PDF/UA

Фрагменты несут оформление и происхождение

Каждый TPdfStructuredTextSpan несёт свой текст, границы в пространстве страницы, FontName, FontSize, FontWeight и Angle, а также SourceStartIndex и SourceCharacterCount. Фрагменты разрываются там, где меняется оформление, поэтому предложение с тремя жирными словами становится тремя фрагментами, а восстановление акцента в HTML или Markdown сводится к чтению свойств, а не к угадыванию по именам шрифтов

Именно два поля с индексами источника превращают извлечение в функцию, а не в отчёт. Они указывают обратно в последовательность символов страницы, а значит, блок, найденный при поиске, можно преобразовать в геометрию посимвольного выделения или прямоугольник подсветки без второго, по-другому упорядоченного прохода по тексту; механика описана в статье о визуальном выделении строк текста через координатные блоки символов. Поле Angle важнее, чем может показаться: повёрнутый текст в штампе или водяном знаке попадает в то же координатное пространство, что и основной текст, и конвейер, игнорирующий угол, с готовностью вклеит диагональную надпись «DRAFT» в середину абзаца

Бюджет и два счётчика качества

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

Два счётчика на возвращённой странице напрямую описывают качество извлечения. UnmappedCharacterCount считает символы без пригодного к использованию сопоставления Unicode — классический симптом подмножественного шрифта, встроенного без CMap /ToUnicode; такой текст отлично рендерится, но извлекается ни во что полезное. GeometryFailureCount считает символы, для которых не удалось определить ограничивающий прямоугольник, что ухудшает упорядочивание при физической раскладке. Логируйте оба. Набор документов, где эти числа стабильно близки к нулю, можно индексировать с уверенностью, а тот, где это не так, сообщает, что некоторым генераторам в вашем конвейере нужно внимание, прежде чем любому нижестоящему результату можно доверять

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Производительность на реальных страницах

Извлечение с физической раскладкой — это дорогой режим, и реализация рассчитана на страницы, которые действительно велики: упорядочивание символов выполняется за O(n log n) вместо повторного сканирования, буферы строк и фрагментов растут геометрически вместо переаллокации на каждый символ, юникодный текст строится в буферах, а не конкатенацией строк, а поиски шрифтов для соседних текстовых объектов кэшируются. Именно эта комбинация удерживает плотную страницу на 5000 символов предсказуемой, а не квадратичной по сложности

Для задач с большим числом страниц всё же стоит выбирать более дешёвый режим там, где это возможно. Используйте roContentOrder с включённой семантикой для тегированных документов, которым вы доверяете, а roPhysicalLayout оставьте для отсканированных и устаревших материалов, где геометрия — единственный доступный сигнал. Если нужна просто строка, более простой API, описанный в статье об извлечении текста из документов PDF, остаётся более быстрым путём, а когда нужно проследить текст обратно до идентификаторов маркированного содержимого, этот слой рассмотрен в статье о чтении и записи маркированного содержимого BDC и MCID

Модель блоков также аккуратно ложится на то, чего хотят конвейеры поиска и извлечения: заголовок с его абзацами — это фрагмент с названием, а границы позволяют цитате указывать на конкретное место на странице, а не просто на документ. PDFiumPas — это компонент для Delphi и Lazarus на основе движка PDFium, задокументированный с примерами на странице PDFium Delphi component