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

HTML в PDF в Delphi: путь HTML5 с постраничными носителями

HotPDF конвертирует HTML в страницы PDF через THPDFHTMLImporter.RenderHTML5 — версионированный профиль постраничных носителей с настоящим DOM, настоящим каскадом CSS, таблицами, разрывами страниц и опциональной генерацией AcroForm из элементов управления HTML-форм. Он работает полностью в рамках одного процесса: без движка браузера, без Chromium, без внешнего конвертера и по умолчанию вообще без доступа к файлам или сети

Причина, по которой существует отдельная точка входа, а не улучшенный RenderHTML, — совместимость. Старый метод годами рендерит счета и письма в промышленных приложениях, а нестрогий DOM с полным каскадом меняет вывод документов, настроенных под старое поведение. Поэтому старый метод сохраняет свою точную семантику, а всё новое находится за RenderHTML5, чью версию профиля сообщает HTML5ProfileVersion, а поддерживаемые вехи перечисляет HTML5ProfileMilestones

Что на самом деле добавляет профиль HTML5?

На практике важны четыре возможности. Структурный DOM, в котором html и body — контейнеры, а не текстовые узлы. Каскад CSS с корректным приоритетом. Вёрстка таблиц. И явное управление разрывами страниц — именно это и означает «постраничные носители»: HTML был задуман для прокручиваемой области просмотра, и превращение его в страницы требует решить, где содержимому разрешено разрываться

Пункт про контейнеры звучит как деталь реализации, а на деле это разница между «работает» и «не работает». Если корневые элементы трактуются как текстовые контейнеры, весь DOM схлопывается в один текстовый блок ещё на этапе разбора, и каждое правило таблицы стилей, разрыв страницы, таблица и поддерево формы впоследствии применяются ни к чему. Любой HTML-рендерер, выдающий из структурированного документа одну недифференцированную стену текста, совершил именно эту ошибку

Порядок каскада по шагам

Приоритет CSS — это не одна простая сортировка. HotPDF применяет обычные объявления из подходящих правил в порядке специфичности, а затем в порядке следования в источнике, затем поверх них — встроенные обычные объявления, затем объявления !important из подходящих правил и, наконец, встроенные !important. Четыре прохода именно в таком порядке

HotPDF в Delphi разрешает каждое CSS-свойство через четыре упорядоченных прохода каскада — от сопоставления обычных правил до встроенных объявлений !important
Более поздние проходы намеренно побеждают в спорах: встроенные объявления превосходят таблицы стилей, а !important превосходит всё, объявленное в обычном порядке

Реализации, сводящие это к правилу «побеждает последний», верно обрабатывают типичные случаи и неверно — интересные, а это хуже, чем ошибаться равномерно, потому что сбой проявляется только в шаблонах, использующих !important для переопределения базовой таблицы стилей — именно в тех шаблонах, что писали люди, ожидающие, что каскад будет работать правильно:

uses
  HPDFDoc, HPDFHTMLImport;

var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.BeginDoc;

    Importer.Margin := 56;              // пункты
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;
    Importer.CreateAcroForms := False;  // отрисовывать элементы управления, не создавать поля формы

    Css := '@page { margin: 56pt } ' +
           'table { border-collapse: collapse } ' +
           'tr.total { page-break-inside: avoid; font-weight: bold }';
    Html := LoadTemplate('statement.html');

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('nothing was emitted - check the markup');

    Pdf.EndDoc;

    Writeln(Format('profile=%d nodes=%d rules=%d layout ops=%d',
      [Importer.Statistics.ProfileVersion,
       Importer.Statistics.ParsedNodeCount,
       Importer.Statistics.CSSRuleCount,
       Importer.Statistics.LayoutOperationCount]));
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

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

Почему импортёр никогда ничего не загружает по сети?

Конвертер HTML в PDF, разрешающий внешние ссылки, — это движок подделки серверных запросов (SSRF), переодетый в генератор документов. Направьте его на разметку, предоставленную пользователем, и он с готовностью обратится к http://169.254.169.254/ или к file-URL на вашем сервере приложений и встроит результат в PDF, который кто-то потом скачает

Поэтому по умолчанию HotPDF не читает ни файлы, ни сеть. Изображения и другие ресурсы поступают ровно двумя способами: как data: URI внутри разметки или через обратный вызов OnResource, где ваш код решает, что означает URI, и предоставляет байты:

HTML-импортёр HotPDF для Delphi разрешает каждый URI ресурса локально, принимая data URI и обратные вызовы OnResource и отклоняя обращения к файлам и сети сверх лимитов количества и байтов
До страницы доходят только байты, полученные локально, отклонённые ссылки увеличивают счётчик, и ни один запрос никогда не покидает процесс
procedure TReportForm.SupplyResource(Sender: TObject;
  const URI: WideString; ResourceKind: THPDFHTMLResourceKind;
  var Data: TBytes; var ContentType: string; var Handled: boolean);
begin
  Handled := False;
  // Отдавать ресурсы только из локального каталога, включённого в белый список. Никогда
  // не передавайте URI HTTP-клиенту и никогда не соединяйте его с путём
  // без предварительной проверки
  if StartsText('asset:', URI) then
  begin
    Data := LoadAssetBytes(Copy(URI, 7, MaxInt));
    ContentType := 'image/png';
    Handled := Length(Data) > 0;
  end;
end;

Каждый поступивший ресурс всё равно засчитывается против трёх отдельных потолков: сколько ресурсов, сколько байт у каждого и сколько байт в сумме. Обратный вызов, который добросовестно возвращает всё, о чём его просят, невозможно обмануть и заставить исчерпать память документом, который двести раз ссылается на одно и то же изображение весом 50 МБ

Четыре бюджета, один режим сбоя

Узлы парсера, правила CSS, операции вёрстки и ресурсы учитываются независимо, потому что и отказывают они независимо. Документ может быть маленьким и патологически дорогим в вёрстке; другой может быть неглубоким, но с таблицей стилей, специально устроенной так, чтобы взорвать сопоставление селекторов. Исчерпание любого из них вызывает исключение EHPDFHTMLImportBudget — выделенный класс исключений, чтобы исчерпание бюджета можно было перехватывать отдельно от некорректного входа

Статистика сбрасывается в начале каждого вызова RenderHTML5, поэтому цифры описывают один импорт, а не время жизни объекта импортёра. В сервисе, рендерящем недоверенные шаблоны, логируйте ParsedNodeCount, LayoutOperationCount и ResourceRejectCount по каждому документу: растущее число отказов обычно означает, что автор шаблона ссылается на ресурсы, которые ваш обратный вызов не распознаёт, а число операций вёрстки около потолка означает, что следующий чуть больший документ завершится ошибкой

Выбор между импортом HTML и API вёрстки

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

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

Задавайте CreateAcroForms осознанно. При значении «выключено» элементы управления HTML-форм отрисовываются как статичная графика — именно это нужно для печатной выписки. При «включено» они становятся настоящими интерактивными полями AcroForm — именно это нужно для заполняемой формы, а связанное с этим поведение полей описано в статье добавление полей формы в загруженный PDF

Импорт HTML, декларативная вёрстка и API прямой отрисовки работают с одним и тем же объектом документа в Delphi и C++Builder; полный список возможностей — на странице компонента HotPDF Delphi PDF Component