PDF Library for Delphi отрисовывает HTML в страницу PDF с настоящей двумерной раскладкой: display: flex и display: grid измеряются и размещаются, а не деградируют до сложенных блоков, а сноски резервируются внизу той рамки, что несёт их ссылку, с нумерацией, остающейся непрерывной через колонки и страницы. Точки входа привычные — DrawHTMLTextBox для одной рамки и DrawHTMLStory для многоколоночного потока
Это важно, поскольку HTML — способ, которым сейчас приходит большая часть содержимого отчётов. Шаблоны создают люди, пишущие CSS, дашборды проектируются как карточки, и рендерер, молча схлопывающий гибкий ряд в четыре сложенных блока, выдаёт документ, вообще не похожий на дизайн. До появления этой возможности единственным двумерным контейнером, который измерял движок, была таблица, поэтому каждую карточную раскладку приходилось переавторить как таблицу вручную
Что изменилось в модели раскладки?
Прежний основной цикл поддерживал единственную строчную рамку и продвигался вниз по странице. Эта модель отлично обрабатывает встроенное содержимое и сложенные блоки и не может выразить контейнер, чьи дочерние элементы соразмеряются друг относительно друга. Таблицы были единственным исключением, с собственным двухпроходным измерением
Flex и grid добавляют каждый свой ограниченный проход измерения по дочерним элементам контейнера, и важное слово здесь — ограниченный. Гибкий контейнер измеряет до 256 прямых дочерних элементов в фиксированный массив. Сетка использует матрицу занятости не более 64 на 64 ячейки для детерминированного автоматического размещения. Эти потолки существуют, чтобы враждебная или просто сгенерированная таблица стилей не могла вызвать неограниченную рекурсию или квадратичную память размещения, что реально важно, когда HTML приходит из шаблона, который редактирует клиент
Как гибкие элементы получают свои размеры
В направлении строки контейнер суммирует базис каждого элемента вместе с его весами роста и сжатия, затем распределяет оставшееся пространство, положительное или отрицательное, согласно этим весам. С flex-wrap каждая линия решается независимо, поэтому ряд, разбивающийся на две линии, назначает свободное пространство для каждой линии отдельно, а не по всему контейнеру. В направлении столбца то же распределение по главной оси выполняется относительно либо явной высоты, либо высоты содержимого
justify-content, align-items, gap и обратные направления оперируют геометрией, которая уже измерена. Они перемещают рамки; они никогда не запускают повторное измерение содержимого элемента. Именно это разделение не даёт сложному дашборду измерять свои дочерние элементы по несколько раз
uses
PDFlibrary;
var
Lib: TPDFlib;
Html, Remainder: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.NewDocument;
Lib.SetPageSize('A4');
Lib.NewPage;
Html :=
'<div style="display:flex; gap:12px;">' +
' <div style="flex:2 1 0; background:#f4f6f8; padding:8px;">' +
' <b>Revenue</b><br/>EUR 4,182,300</div>' +
' <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
' <b>Margin</b><br/>18.4%</div>' +
' <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
' <b>Backlog</b><br/>92 days</div>' +
'</div>';
Remainder := Lib.DrawHTMLTextBox(40, 40, 515, 120, Html);
if Remainder <> '' then
Log('content did not fit - carry the remainder to the next box');
Lib.SaveToFile('dashboard.pdf');
finally
Lib.Free;
end;
end;
Возвращаемое значение — строка продолжения, именно так каждая точка входа рисования HTML сообщает, что не поместилось. Передайте её в следующую рамку или следующую страницу, и поток возобновится с места остановки
Размещение в сетке и чем может быть трек
Треки сетки принимают фиксированные длины, проценты, единицу fr, простые выражения repeat() и minmax(). Автоматическое размещение заполняет матрицу занятости детерминированно, поэтому один и тот же HTML всегда даёт одинаковое расположение. Явные координаты допускают перекрытие, и это намеренно: дизайн, накладывающий бейдж на карточку, выражает замысел, а не ошибку. Когда явно задана только одна ось, размещение ищет только по другой оси
Элементы, охватывающие несколько строк, вносят свою измеренную высоту обратно в строки, которые они покрывают, усредняя по ним, что не даёт высокому охватывающему элементу сжимать одну строку, оставляя соседние короткими:
Html :=
'<div style="display:grid; grid-template-columns:repeat(3, 1fr); ' +
' gap:10px;">' +
' <div style="grid-row:span 2; background:#eef;">Site plan</div>' +
' <div>Inspector</div>' +
' <div>Date</div>' +
' <div style="grid-column:2 / span 2;">Findings summary</div>' +
'</div>';
Remainder := Lib.DrawHTMLTextBox(40, 180, 515, 260, Html);
Дочерние элементы flex и grid рендерятся через тот же HTML-рендерер, что и всё остальное, и именно это свойство делает функцию пригодной к использованию, а не отдельным миром. Шрифты, каскад CSS, ссылки, изображения, таблицы и дальнейшие вложенные контейнеры flex или grid ведут себя внутри гибкого элемента точно так же, как и на верхнем уровне, а внешний план раскладки записывает финальные команды текста и прямоугольников, поэтому повторное рисование повторно использует существующий кеш измерений
Почему сноски — задача разбиения на страницы?
Сноска — не содержимое, следующее после абзаца, содержащего её ссылку; это содержимое, которое должно появиться внизу той же рамки, что и её ссылка. Это переворачивает обычный порядок измерения, поскольку пространство, доступное для основного текста, теперь зависит от содержимого, которое ещё не разложено
Поэтому рендерер измеряет сноску, когда встречает ссылку, и вычитает область сноски из бюджета высоты тела текущей ограниченной рамки. Если ссылка, основной текст на данный момент и сноска не помещаются все вместе, маркер сноски и всё, что после него, переходят в строку продолжения вместе. Именно это правило предотвращает два классических сбоя: наложение сноски поверх основного текста и застревание сноски на странице, чья ссылка находится на предыдущей
В ограниченной рамке область сноски прикреплена к низу с разделительной линией над ней. При неограниченном измерении, где нет высоты рамки для привязки, область сноски следует сразу после основного текста. Нумерация переносится в поле расширения на стеке продолжения, поэтому DrawHTMLTextBox и DrawHTMLStory поддерживают непрерывность последовательности через колонки и страницы, а строка продолжения, созданная до появления этого поля, всё равно возобновляется корректно
// Сноски внутри многоколоночной истории удерживают одну непрерывную последовательность
Html := LoadTemplate('chapter.html'); // использует маркеры float:footnote
Remainder := Lib.DrawHTMLStory(40, 40, 515, 700,
2, // колонки
16, // желоб в пунктах
20, // максимум страниц для этой истории
Html);
if Remainder <> '' then
Log('story exceeded its page budget');
Практические рекомендации для авторов шаблонов
Проектируйте в пределах документированных потолков. Гибкий контейнер с более чем 256 прямыми дочерними элементами почти всегда — таблица данных, переодетая во flex, и путь таблицы всё равно измеряет её лучше. Сетка больше 64 на 64 — это электронная таблица, и тот же совет применим. Для многоколоночного основного текста поведение колонок и переноса по слогам, описанное в статье перенос по слогам и сбалансированные текстовые колонки, управляет тем, как поток выглядит внутри каждой колонки
Измеряйте перед рисованием, когда раскладка должна поместиться. GetHTMLTextHeight сообщает высоту, которая потребуется для заданной ширины, — это дешёвый способ выбрать между одной раскладкой и другой до фактического нанесения чернил. И относитесь к непустой строке продолжения как к норме, а не как к исключению: это механизм, которым длинное содержимое разбивается на страницы, а не сигнал ошибки
Когда HTML приходит из движка отчётов, а не из шаблонов, написанных вручную, управляемый набором данных путь в статье движок отчётов на основе набора данных хорошо сочетается с этим, генерируя разметку, которую затем раскладывают flex и grid. А когда то же содержимое должно снова покинуть PDF, путь семантического экспорта в статье экспорт PDF в Markdown и DOCX замыкает цикл
Раскладка HTML, генерация отчётов и семантический экспорт — часть одной библиотеки для Delphi, C++Builder и Free Pascal; полный список возможностей — на странице PDF Library for Delphi