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

Рефлоу содержимого PDF в адаптивный HTML в Delphi

PDFium Component превращает PDF с фиксированной раскладкой в семантическую модель, которую можно переверстать, используя BuildReflowDocument, и экспортирует эту модель как автономный HTML через ToHtml. Заголовки остаются заголовками, элементы списка остаются элементами списка, а таблицы, обнаруженные на странице, выходят в виде настоящей табличной разметки с сохранёнными ячейками заголовков и объединениями. Ничто в выводе не ссылается на внешний скрипт или таблицу стилей

Причина, по которой это нужно, в том, что страница PDF — это набор позиционированных глифов, что абсолютно не подходит для экрана телефона, программы чтения с экрана или поискового индекса. Каждая попытка решить это извлечением обычного текста теряет структуру, делавшую документ читаемым, а каждая попытка решить это конвертацией страниц в изображения теряет текст целиком. Модель рефлоу сохраняет и то и другое: слова и связи между ними

Диаграмма конвейера reflow в PDFium Component в Delphi: GetStructuredText питает либо тегированное дерево структуры, либо порядок чтения компоновки в BuildReflowDocument и самодостаточный экспорт ToHtml
Каждый узел модели reflow прослеживается к тому же структурированному текстовому слою — объявлен ли его заголовок в дереве структуры или выведен из раскладки

Откуда берётся семантическая информация?

Всё начинается с GetStructuredText, единственного источника текста и семантики в компоненте. Когда PDF несёт структурное дерево — тегированный PDF, как определено в разделе 14.7 ISO 32000-1, — модель следует логической иерархии, записанной производителем. Когда его нет, а большинство встречающихся в реальности PDF его не имеют, модель откатывается к порядку физической раскладки, уже вычисленному для целей порядка чтения

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

Каждый узел записывает, откуда пришла его информация, поэтому потребитель может отличить заголовок, объявленный документом, от заголовка, выведенного эвристикой раскладки. Конвейеры, чувствительные к достоверности, должны читать это поле, а не считать все узлы одинаково авторитетными

Плоское дерево, и почему это не дерево объектов

Модель — это дерево, развёрнутое в прямом порядке обхода: массив узлов, где каждый узел несёт ParentIndex и Depth, а не рекурсивная запись или граф объектов с владением. Страницы, заголовки, абзацы, списки, элементы списков, иллюстрации, подписи, таблицы, строки и ячейки — всё живёт в этом одном линейном массиве

Отсюда следуют два преимущества. Потребители могут обрабатывать массив по порядку в потоке без рекурсии, что превращает вывод HTML, Markdown или древовидного представления в простой цикл. И раскладка остаётся переносимой между Delphi, C++Builder и Free Pascal, которые по-разному обрабатывают рекурсивные управляемые типы через границу ABI. Рекурсивная запись из динамических массивов — как раз та конструкция, что компилируется везде и ведёт себя в каждом случае чуть иначе

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfReflowOptions;
  Doc: TPdfReflowDocument;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.LoadDocument;

    Options := TPdfReflowOptions.Default;
    Options.FullDocument := True;
    Options.DetectTables := True;
    Options.IncludeCss := True;          // встроенный блок стилей, без внешнего файла
    Options.MaxNodes := 200000;          // бюджет с отказом при превышении
    Options.MaxCharacters := 4000000;

    Doc := Pdf.BuildReflowDocument(Options);

    for I := 0 to High(Doc.Nodes) do
      case Doc.Nodes[I].Kind of
        prnkHeading:
          Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
        prnkParagraph:
          Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Copy(Doc.Nodes[I].Text, 1, 60)]));
        prnkTable:
          Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
      end;

    Writeln(Format('%d node(s), %d table(s), %d character(s)',
      [Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
  finally
    Pdf.Free;
  end;
end;

Как таблицы не появляются дважды?

Обнаружение таблиц выполняется после того, как для страницы собран структурированный текст, что создаёт очевидную опасность: содержимое одной и той же ячейки существует и в текстовых блоках, и в обнаруженной таблице. Вывод обоих даёт HTML, где за каждой таблицей снова следует её же содержимое в виде отдельных абзацев

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

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

Диаграмма геометрического правила обнаружения таблиц в PDFium Component для reflow в Delphi: таблица, покрывающая более половины текстового блока, замещает его, так что текст ячеек никогда не появляется дважды
Когда обнаруженная таблица накрывает больше половины текстового блока, узел таблицы заменяет блок, чтобы текст ячеек появился ровно один раз

Экспорт HTML, остающегося автономным

ToHtml обходит уже построенную модель и ни разу не обращается повторно к PDFium, поэтому повторный экспорт ничего не стоит дополнительно и не может дать иной результат из той же модели. Текст и значения атрибутов экранируются единообразно, уровни заголовков ограничиваются диапазоном от h1 до h6, действительно определённым в HTML, а ячейки заголовков, RowSpan и ColumnSpan проходят как записаны

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

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // сохранить видимость границ страниц
  Options.PreserveLineBreaks := False;   // пусть браузер сам переносит абзацы

  Html := Pdf.BuildReflowDocument(Options).ToHtml;

  Bytes := TEncoding.UTF8.GetBytes(string(Html));
  Stream := TFileStream.Create('report.html', fmCreate);
  try
    if Length(Bytes) > 0 then
      Stream.WriteBuffer(Bytes[0], Length(Bytes));
  finally
    Stream.Free;
  end;
end;

PreserveLineBreaks — параметр, о котором стоит подумать больше всего. Разрыв строки в PDF — это решение вёрстки, принятое для фиксированной ширины страницы, поэтому сохранение его на узком экране воспроизводит ту самую проблему, ради решения которой существует рефлоу. Сохраняйте разрывы для поэзии, листингов кода и адресов; отбрасывайте для прозы

Бюджеты, отмена и состояние страницы

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

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

Для чего рефлоу хорош и для чего нет

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

Именно для вспомогательных технологий модель рефлоу сочетается с функциями чтения, описанными в статье построение доступной программы чтения, а документы, несущие настоящее структурное дерево, дают заметно лучшие модели, что является хорошим аргументом в пользу проверки тегирования заранее, как описано в статье проверка структурного дерева PDF/UA

Рефлоу, структурированный текст, проверка тегирования и рендеринг используют один объект документа в Delphi, C++Builder и Lazarus; полный API описан на странице компонента PDFium для Delphi