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

Декларативная разметка PDF в Delphi с тегированным выводом

HotPDF умеет строить постраничный документ из декларативного дерева вместо координат. Вы собираете THPDFDOMDocument из секций, стеков, текста, списков и таблиц, передаёте его в THPDFDOMRenderer, а рендерер измеряет, разбивает на страницы, рисует служебные элементы страницы и, если запрошено, выводит структурное дерево PDF/UA, делающее результат доступным. Код разметки ни разу не вычисляет координату Y

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

Чем владеет дерево и почему владение строгое

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

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

uses
  HPDFDoc, HPDFLayoutDOM;

var
  Doc: THPDFDOMDocument;
  Section: THPDFDOMSection;
  Table: THPDFDOMTable;
  Row: THPDFDOMTableRow;
  I: Integer;
begin
  Doc := THPDFDOMDocument.Create;
  Doc.GenerateStructure := True;        // вывести структурное дерево PDF/UA
  Doc.Language := 'en-US';

  Section := Doc.AddSection;
  Section.PageWidth := 595;           // A4 в пунктах
  Section.PageHeight := 842;
  Section.MarginLeft := 56;
  Section.MarginTop := 56;
  Section.MarginRight := 56;
  Section.MarginBottom := 56;
  Section.Style.FontName := 'Helvetica';
  Section.Style.FontSize := 10;

  Section.Body.AddHeading('Annual maintenance report', 1);
  Section.Body.AddText('Every asset inspected during the reporting ' +
    'period is listed below, grouped by site.');
  Section.Body.AddSpacer(12);

  Table := THPDFDOMTable.Create('assets');
  Table.AddColumn(3);                 // веса, а не абсолютная ширина
  Table.AddColumn(1);
  Table.AddColumn(1);
  Table.RepeatHeaders := True;
  Row := Table.AddRow(18, True);      // строка заголовка
  Row[0].Text := 'Asset';
  Row[1].Text := 'Last service';
  Row[2].Text := 'Status';
  for I := 0 to High(Assets) do
  begin
    Row := Table.AddRow(16);
    Row[0].Text := Assets[I].Name;
    Row[1].Text := Assets[I].ServiceDate;
    Row[2].Text := Assets[I].Status;
  end;
  Section.Body.Add(Table);
end;

Как разбиение на страницы избегает квадратичной стоимости?

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

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

Измерение остаётся честным в отношении побочных эффектов. THPDFLayoutElement.Measure обязан быть свободным от побочных эффектов рисования, а фактическое размещение всегда проходит через THotPDF.PlaceLayoutElement — ту же центральную процедуру, которая повторно измеряет размещённый фрагмент, настраивает владение переполнением и записывает диагностику. Рендерер DOM решает только политику новой страницы, служебные элементы страницы, интервалы и время жизни продолжений

Правила заголовков таблицы, предотвращающие бесконечный документ

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

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

Есть и защитный потолок для глубины продолжений, поскольку пользовательский компонент вправе реализовать Split так, что тот всегда возвращает эквивалентный хвост. Рендерер проверяет этот лимит после отделения хвоста и до начала следующей страницы, а текущая итерация освобождает хвост в собственном блоке finally, поэтому некорректно ведущий себя сторонний компонент падает с диагностируемой ошибкой, а не заполняет диск

Один логический элемент, много фрагментов страниц

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

HotPDF решает это, сохраняя единственный структурный элемент и добавляя ссылку на размеченное содержимое в его массив /K для каждого фрагмента, где пара /Pg и /MCID идентифицирует страницу и идентификатор. Слот ParentTree для этого MCID указывает обратно на тот же элемент. Именно этого и ожидает ISO 14289, и именно поэтому клоны продолжения отличаются от обычных клонов: обычный Clone означает новое логическое содержимое и получает новую семантическую идентичность, тогда как внутренний клон продолжения наследует идентичность компонента, который он продолжает

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

Правила структуры, которые рендерер проверяет заранее

С включённым GenerateStructure ряд правил PDF/UA проверяется во время рендеринга дерева, а не после того, как файл уже создан. Заголовки начинаются с уровня 1 и не могут пропускать уровни. LI может появляться только внутри L, а Lbl и LBody — только внутри LI. TR принадлежит таблице, а TH и TD — строке. Иллюстрация без альтернативного текста отклоняется в режиме PDF/UA

Раннее отклонение здесь — осознанный выбор. Валидатор, сообщающий об отсутствующем альтернативном тексте после того, как документ уже записан, говорит вам, что партию из десяти тысяч выписок нужно перегенерировать; рендерер, отказывающий компоненту, говорит вам, какому именно компоненту, пока данные, его породившие, ещё в области видимости. Проверка соответствия по-прежнему остаётся отдельным шагом конвейера, и механика этого описана в статье проверка соответствия PDF/A, PDF/X и PDF/UA

var
  Pdf: THotPDF;
  Renderer: THPDFDOMRenderer;
  Stats: THPDFDOMRenderStatistics;
begin
  Pdf := THotPDF.Create(nil);
  Renderer := THPDFDOMRenderer.Create;
  try
    Pdf.FileName := 'maintenance-report.pdf';
    Pdf.BeginDoc;
    Stats := Renderer.Render(Doc, Pdf);
    Pdf.EndDoc;

    Writeln(Format('%d page(s), %d placement(s), %d split(s)',
      [Stats.PageCount, Stats.PlacementCount, Stats.SplitCount]));
    Writeln(Format('structure elements=%d marked content=%d artifacts=%d',
      [Stats.StructureElementCount, Stats.MarkedContentCount,
       Stats.ArtifactCount]));
    Writeln(Format('deepest continuation chain: %d',
      [Stats.MaximumContinuationDepth]));
  finally
    Renderer.Free;
    Doc.Free;
    Pdf.Free;
  end;
end;

Запись статистики полезнее, чем кажется на первый взгляд. Резкий рост SplitCount после изменения шаблона обычно означает, что компонент начал измеряться выше, чем его контейнер. Постепенный рост MaximumContinuationDepth — это раннее предупреждение о компоненте, чей Split продвигается слишком медленно на страницу. А сравнение ArtifactCount с числом страниц-продолжений подтверждает, что повторяющиеся заголовки действительно были помечены как артефакты

Где DOM вписывается рядом с прямым API

DOM не заменяет прямое рисование; он размещается поверх тех же объектов страницы. Всё, что размещает рендерер, можно чередовать с прямыми вызовами на THotPDF, что важно, когда отчёту нужен один элемент, размещённый вручную в точной позиции, — например, изображение подписи. Закрытие страниц остаётся под управлением AddPage и EndDoc, поэтому режим немедленного сброса не удерживает в памяти уже завершённые страницы, а резидентная память по-прежнему определяется текущими продолжениями, ресурсами шрифтов и обычным графом объектов документа

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

Декларативная разметка, автоматическое тегирование и API прямого рисования поставляются в одном компоненте для Delphi и C++Builder; полный список возможностей — на странице компонента HotPDF для Delphi PDF