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

PDFlibPas: PDF в Markdown и DOCX в Delphi

PDFlibPas преобразует содержимое PDF в два редактируемых формата без автоматизации Office. ExportPageMarkdown и ExportDocumentMarkdown возвращают семантический Markdown с распознанными заголовками, нумерованными и маркированными списками и таблицами в стиле pipe, а SaveDOCXToFile и SaveDOCXToStream записывают пакет WordprocessingML с абзацами, заголовками, нативной нумерацией списков, распознанными таблицами, форматированием шрифта, разрывами страниц и позиционированными изображениями PNG

Оба пути работают полностью на Pascal, на сервере, без установленного Word и без COM. Именно это ограничение — причина того, что функция существует в PDF-библиотеке, а не в настольном инструменте

Почему «PDF в Word» — действительно сложная задача?

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

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

Тегированные PDF — исключение, причём весомое. Когда документ несёт дерево структуры, роли абзаца, заголовка, списка и таблицы зафиксированы, а не угаданы, и именно поэтому работа по доступности, описанная в статье о структуре доступности тегированного PDF, окупается ещё и в качестве конвертации. Если вы контролируете генератор, разметка вывода тегами — это единственное действие с максимальной отдачей для всех, кому впоследствии придётся этот документ конвертировать

Экспорт в Markdown постранично

Путь через Markdown стоит выбирать, когда назначение — текстовый конвейер: сайт документации, поисковый индекс, корпус для поиска ассистента. Опции — это битовая маска: PDF_MARKDOWN_INCLUDE_PAGE_MARKERS, PDF_MARKDOWN_DETECT_HEADINGS, PDF_MARKDOWN_PRESERVE_STYLES, а PDF_MARKDOWN_DEFAULT объединяет все три

var
  Pdf: TPDFlib;
  Md: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.LoadFromFile('handbook.pdf', '');

    // Одна страница в виде строки
    Md := Pdf.ExportPageMarkdown(1, PDF_MARKDOWN_DEFAULT);

    // Диапазон страниц, потоково записанный на диск в UTF-8 без BOM
    Pdf.SaveMarkdownToFile('1-40',
      PDF_MARKDOWN_DETECT_HEADINGS or PDF_MARKDOWN_PRESERVE_STYLES,
      'handbook.md');
  finally
    Pdf.Free;
  end;
end;

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

Потоковые точки входа важны для крупных документов. SaveMarkdownToStream и SaveMarkdownToFile записывают UTF-8 постранично и не буферизуют весь вывод целиком, поэтому руководство на 900 страниц не превращается сначала в строку на 900 страниц в памяти. Отсутствие метки порядка байт (BOM) тоже намеренное: BOM в файле Markdown сбивает с толку удивительно много генераторов статических сайтов и инструментов сравнения diff

DOCX без Office на машине

Модуль записи DOCX сам формирует пакет: записи ZIP через сырой Deflate с проверкой CRC, части WordprocessingML и связывающие их отношения. Ничто не обращается к Word, а значит, конвертация работает на сервере без графической подсистемы, под служебной учётной записью, в контейнере — везде, где автоматизация Office либо нелицензирована, либо нестабильна, либо запрещена

var
  Pdf: TPDFlib;
  Target: TFileStream;
begin
  Pdf := TPDFlib.Create;
  Target := TFileStream.Create('handbook.docx', fmCreate);
  try
    Pdf.LoadFromFile('handbook.pdf', '');
    Pdf.SaveDOCXToStream('1-40',
      PDF_DOCX_INCLUDE_IMAGES or PDF_DOCX_DETECT_HEADINGS or
      PDF_DOCX_PRESERVE_STYLES or PDF_DOCX_PRESERVE_PAGE_BREAKS,
      Target);
  finally
    Target.Free;
    Pdf.Free;
  end;
end;

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

Что даёт детерминированная упаковка?

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

Автоматизация Office такого не гарантирует. Она встраивает временные метки, идентификаторы ревизий и зависящие от машины метаданные, поэтому один и тот же документ, конвертированный дважды, отличается способом, который делает хеширование бессмысленным. Та же логика лежит в основе детерминированных идентификаторов файлов, обсуждаемых в статье о детерминированных PDF ID для воспроизводимых сборок: когда результат воспроизводим, проверка превращается в сравнение вместо инспекции

Где результат хорош, а где нет

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

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

Для конвейеров ассистентов и поиска путь через Markdown обычно оказывается лучшей целью: заголовки становятся границами фрагментов, таблицы остаются читаемыми в виде pipe-таблиц, а маркеры страниц дают каждому фрагменту цитируемое местоположение. Для редактирования человеком ответ — DOCX, потому что пользователю нужен не сам текст, а возможность его изменить

PDFlibPas — это библиотека PDF для Delphi, C++Builder и Lazarus с соответствующими интерфейсами DLL и ActiveX, поэтому те же вызовы экспорта доступны из C#, C++ или скриптовых хостов. Полная документация и пробная сборка доступны на странице PDFlibPas Delphi PDF library