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

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

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

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

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

Всё начинается с 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, где за каждой таблицей снова следует её же содержимое в виде отдельных абзацев

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

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

Экспорт 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