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

CSS multi-column layout в HTML-to-PDF с HotPDF Delphi

HotPDF Component верстает многоколоночный текст CSS, когда THPDFHTMLImporter.RenderHTML5 конвертирует HTML в PDF в Delphi. Постраничный HTML-движок разрешает column-count и column-width против content box, балансирует последний фрагмент каждого multicol-контейнера бинарным поиском, заполняет более ранние страницы колонка за колонкой до низа страницы и поддерживает column-fill, column-span: all и column-rule

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

Как HotPDF решает, сколько брать колонок?

HotPDF разрешает количество колонок по тем же правилам, что и псевдоалгоритм из CSS Multi-column Layout Module Level 1 §3.4, применённый к ширине content box W после снятия padding и рамок. Заявленный column-count берётся как есть. Когда дан только column-width, движок вычисляет N = Trunc((W + gap) / (column-width + gap)) с нижним пределом в одну колонку. Когда даны оба, результат — меньшее из двух, так что заявленный счётчик может ограничить выведенный, но не раздуть его

Конкретный случай: content box в 483pt (A4 с боковыми полями 56pt, округлено) и column-width: 140pt; column-gap: 12pt дают Trunc(495 / 152) = 3 колонки, каждая шириной (483 - 2 * 12) / 3 = 153pt. Добавление column-count: 2 в то же правило сбрасывает до двух колонок, потому что минимум побеждает. Ключевое слово normal для gap разрешается в 1em от размера шрифта контейнера, и это разрешение случается во время вёрстки, а не разбора, так что font-size, заявленный после column-gap в том же правиле, всё равно даёт правильный gap

Разрешение количества колонок CSS multi-column в HotPDF для HTML to PDF в Delphi: column-count берётся как заявлен, column-width выводит N = Trunc((W + gap) / (column-width + gap)), меньшее побеждает при обоих заданных, а любая колонка ниже 12pt схлопывает контейнер в одну колонку
Движок применяет CSS-псевдоалгоритм к content box, ограничивает выведенное количество заявленным и добавляет защиту с полом в 12pt, чтобы недоверенные шаблоны не могли породить тысячи микроскопических колонок

Одна защита не из спецификации, и она там нарочно. После вычисления ширины колонки любое значение ниже 12pt схлопывает контейнер обратно в одну колонку. Stylesheet, говорящий column-count: 9999 — случайно или потому, что он пришёл из недоверенного шаблона, — иначе породил бы тысячи колонок с отрицательными или микроскопическими ширинами и разрывщиком строк, пытающимся уложить один глиф на строку. Откат к одной колонке держит вывод читаемым, а стоимость вёрстки — ограниченной. Нулевые и отрицательные счётчики до туда не доходят: валидатор принимает для column-count только auto или положительное целое, а column-width и column-gap берут auto или normal соответственно, либо длину в пунктах или em

Рендер многоколоночной HTML-секции в PDF из Delphi

Многоколоночному выводу не нужен никакой переключатель на импортёре. THPDFHTMLImporter создаётся с документом THotPDF, в который рисует, BeginDoc обязан уже выполниться, а колоночные свойства приходят через обычный каскад — из разметки или из author stylesheet, переданного вторым аргументом RenderHTML5

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

Обратите внимание на двойное экранирование в последнем абзаце: &amp; внутри Pascal-строки — это HTML-сущность амперсанда, которую импортёр декодирует обратно в одиночный символ. Остальная часть каскада, бюджеты и ресурсные колбэки ведут себя ровно как описано в обзоре импорта HTML5 paged-media в HotPDF; колонки — просто ещё один режим вёрстки внутри того же профиля

Балансировка колонок бинарным поиском

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

Балансировка multi-column в HotPDF для Delphi: бинарный поиск между самой высокой одиночной строкой и суммой всех высот строк находит наименьшую высоту колонки, чьё жадное заполнение укладывает каждую строку, с границами, сходящимися в пределах 0,05pt
Предикат «каждая строка помещается» монотонен по высоте колонки, поэтому деление интервала пополам сходится к кратчайшей сбалансированной полосе, а финальное жадное заполнение съедает каждую строку без сирот

Бинарный поиск работает, только если предикат монотонен, и здесь он монотонен: более высокая колонка никогда не вместит меньше строк, чем более низкая, поэтому «каждая строка помещается» переворачивается из лжи в истину ровно один раз по мере роста высоты. Это свойство гарантирует сходимость и гарантирует, что финальное жадное заполнение на найденной высоте съест каждую строку, а не оставит строку-сироту, которая триггерила бы ложную лишнюю страницу. Оно же объясняет деталь результата: сбалансированная полоса настолько коротка, насколько позволяют строчные боксы, а не обязательно равные колонки, потому что строки неделимы и последняя колонка может закончиться строкой-другой выше остальных

Что происходит, когда колонки пересекают разрыв страницы?

Колонки, не влезающие на текущую страницу, заполняются последовательно до низа страницы, и только финальный фрагмент страницы балансируется. Это поведение, которое CSS Multi-column Layout Level 1 §7.1 предписывает для column-fill: balance во фрагментированных контекстах, и именно так выглядит печатная газета: полные колонки на каждой странице, кроме последней, где оставшийся текст делится поровну, а не оставляет одну высокую колонку рядом с пустой

Каждый фрагмент страницы проходит через один и тот же фрагментатор независимо. Если оставшиеся строки влезают в N колонок доступной высоты, фрагмент балансируется; иначе каждая колонка заполняется до низа страницы, и цикл заводит новую страницу. Естественная высота всего контейнера, которую движок использует для решений break-inside: avoid и измерения протяжённости, — это высота сбалансированной полосы с неограниченной ёмкостью, поэтому контейнер, который влез бы на свежую страницу как сбалансированный блок, переезжает туда целиком, а не режется. Когда одиночная строка выше целой страницы, движок ослабляет ограничение, всё же кладёт строку наверху страницы и сообщает диагностику hdkPaginationConstraint с субъектом multi-column

Пагинация multi-column в HotPDF для HTML to PDF в Delphi: фрагменты до последней страницы заполняют каждую колонку до низа страницы, финальный фрагмент балансируется по колонкам, а column-span all делит контейнер на отдельно балансируемые полосы вокруг полноширинного элемента
Балансируется только финальный фрагмент multicol-контейнера; более ранние страницы заполняются последовательно, и каждый фрагмент независимо повторяет балансировку, линии и пагинацию

column-fill: auto и боксы фиксированной высоты

С column-fill: auto и определённым height HotPDF заполняет колонки по порядку, и высота каждой колонки — меньшее из оставшегося места страницы и высоты контейнера минус его padding и рамки. Минимум важнее, чем кажется. Использование только оставшегося места страницы позволило бы первой колонке проглотить каждую строку всякий раз, когда на странице осталось больше места, чем занимает бокс. Без определённой высоты column-fill: auto откатывается к балансировке, как делают браузеры, поскольку у неограниченного multicol-контейнера нет высоты колонки, до которой заполнять; первая реализация промахнулась мимо этого отката, и одна из 27 регрессионных проверок его поймала

column-span: all и вложенные абзацы

HotPDF поддерживает column-span: all на прямых потомках многоколоночного контейнера, следуя CSS Multi-column Layout Level 1 §6.1. Растягивающийся потомок делит контейнер на сегменты: потомки до него образуют одну колоночную полосу, растянутый элемент рендерится на всю ширину, а потомки после него заводят новую полосу. Каждая полоса балансируется, заполняется, расчерчивается и пагинируется сама по себе, так что заголовок посреди трёхколоночной секции даёт две сбалансированные полосы с полноширинным заголовком между ними — ровно ту раскладку, которую люди реально рисуют в макетах

Дойти до этого потребовало сперва починить менее видимую проблему. Контейнер вроде <div style="column-count:2"> с несколькими потомками <p> раньше рендерил каждый абзац в одной сложенной колонке, потому что каждый абзац верстался как собственный текстовый блок, а колоночное объявление родителя никогда не применялось. HotPDF теперь собирает текст вложенных блочных потомков в один поток для контейнера. Сборщик обязан вставлять жёсткий разрыв на каждой блочной границе; пропустите его — и последнее слово одного абзаца склеится с первым словом следующего в одной строке, баг, который легко пропустить, когда тестовый текст случайно кончается пунктуацией

У того слитого потока нарочно ограниченный охват. Он используется, только когда всё поддерево — простой текстовый поток. Контейнер, держащий элементы форм, изображения, таблицы, списки или <hr> где угодно внутри, держит обычный блочный маршрут и рендерится в одну колонку, потому что слитый текстовый сборщик не может выразить их геометрию. У таблиц в особенности свои правила пагинации, разобранные в статье о обработке rowspan и повторяющихся шапках таблиц в HotPDF, и смешение их в колоночную полосу сломало бы те гарантии

Куда идёт линия колонок и как её тестировать

HotPDF рисует column-rule на срединной линии каждого gap, на правом крае колонки плюс половина gap, от верха до низа полосы на каждом фрагменте страницы, как описывает CSS Multi-column Layout Level 1 §4. Ширина по умолчанию 1pt. Цвет по умолчанию — цвет текста элемента, когда не заявлен или равен currentColor. Краткая форма column-rule без ключевого слова стиля трактуется как solid — в духе того, как обычно читают краткую форму border, — тогда как none и hidden подавляют линию

Геометрия колонок наблюдаема без разбора PDF. Событие OnLineLayout на THPDFHTMLImporter срабатывает раз на нарисованную строку с записью THPDFHTMLLineLayout, и поскольку движок рисует каждую колонку отдельно, поле X каждой строки — левый край её колонки. Соседние колонки отличаются ровно на ширину колонки плюс gap, что позволяет легко отличить gap в 12pt от gap в 20pt в регрессионном тесте

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex нумеруется с нуля; X — левый край колонки строки
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Подключение, до RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Две практические заметки от написания тестов против этого события. PageIndex нумеруется с нуля, и это первое, на чём спотыкается рукописное утверждение. И X включает сдвиги выравнивания, так что для центрированного или прижатого вправо текста сравнивайте позиции колонок на левовыровненных фикстурах. Сама линия в событии не появляется; чтобы утверждать её позицию, просканируйте несжатый content stream на вертикальные операторы m и l

Пределы и грабли, о которых стоит знать

Пара поведений — аппроксимации, и о них лучше знать до ревизии вёрстки, чем после. Интервал между абзацами внутри слитого колоночного потока аппроксимируется одной строкой высоты строки контейнера на каждой блочной границе, поэтому помаргинальные отступы внутри колоночной полосы не воспроизводятся точно. Когда column-span: all делит контейнер, каждый сегмент повторяет padding контейнера и фоновый бокс, что держит сегменты визуально непрерывными, но не является единым боксом в строгом смысле CSS. Каждое значение column-rule-style, кроме none и hidden, проходит валидацию, но линия рисуется сплошной

Неподдерживаемые значения не проваливаются молча. Установка StrictCSS в True превращает каждую диагностику в исключение EHPDFHTMLUnsupportedCSS, тогда как OnDiagnostic позволяет пакетному заданию логировать их и продолжать. Учтите, что строгий режим распространяется и на пагинационную диагностику, поэтому документ со строкой выше страницы поднимет исключение под StrictCSS, а не тихо ослабит ограничение

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// Авторинг шаблона: падай сразу на всё, что движок бы проигнорировал
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

Строгий режим — правильная настройка, пока шаблон пишется, поскольку опечатка вроде column-span: both отвергается немедленно, вместо того чтобы тихо пустить заголовок течь внутри колонок. В продакшене логирование через OnDiagnostic обычно лучшая сделка, потому что одна странная декларация в клиентском HTML не должна стоить целого документа. Идентичные диагностики (тот же вид, субъект и значение) сообщаются однократно, пока влезают в список MaxDiagnostics, который по умолчанию 256 записей, так что декларация, которую каскад посещает для сотен элементов, не топит лог

FAQ

Поддерживает ли HotPDF краткую форму columns?

Да. Краткая форма columns принимает максимум один положительный целочисленный счётчик и одну длину колонки в любом порядке, а auto сбрасывает оба развёрнутых свойства. Разрешённые значения следуют тем же правилам счётчика и ширины, что и отдельные свойства

Может ли многоколоночный контейнер держать изображения или таблицы?

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

Почему на последней странице колонки сбалансированы, а на ранних — полные?

Таково правило CSS для column-fill: balance в постраничных медиа: балансируется только финальный фрагмент. Более ранние страницы заполняют каждую колонку до низа страницы, чтобы место не пропадало в середине документа

Многоколоночная вёрстка, профиль HTML5 paged-media и остальной конвейер HTML-to-PDF — часть HotPDF Component for Delphi and C++Builder, покрывающего также прямое рисование PDF, формы, шифрование и извлечение текста