Технічна стаття

Flexbox, CSS Grid і виноски в PDF з Delphi

PDF Library for Delphi рендерить HTML у сторінку PDF зі справжньою двовимірною версткою: display: flex і display: grid вимірюються й розміщуються, а не деградують до складених блоків, а виноски резервуються внизу того блока, що несе посилання на них, з нумерацією, що лишається неперервною через стовпці й сторінки. Точки входу звичні: DrawHTMLTextBox для одного блока й DrawHTMLStory для багатостовпцевого потоку

Це важливо, бо HTML — це той спосіб, у який надходить більшість вмісту звітів зараз. Шаблони створюють люди, що пишуть CSS, дашборди проєктуються як картки, а рендерер, що мовчки згортає рядок flex у чотири складені блоки, дає документ, що взагалі не нагадує дизайн. Поки ця можливість не з'явилася, єдиним двовимірним контейнером, який вимірював рушій, була таблиця, тож кожну верстку картки доводилося вручну переробляти на таблицю

Що змінилося в моделі верстки?

Попередній головний цикл підтримував одну рядкову рамку й просувався вниз сторінкою. Ця модель бездоганно обробляє вбудований вміст і складені блоки та не може виразити контейнер, чиї дочірні елементи розміряються один відносно одного. Таблиці були єдиним винятком, з власним двопрохідним вимірюванням

Flex і grid кожен додає обмежений прохід вимірювання по дочірніх елементах контейнера, і важливе слово тут — обмежений. Контейнер flex вимірює до 256 прямих дочірніх елементів у фіксований масив. Grid використовує матрицю заповнення щонайбільше 64 на 64 комірки для детермінованого автоматичного розміщення. Ці стелі існують, щоб ворожа чи згенерована таблиця стилів не могла спричинити необмежену рекурсію чи квадратичну пам'ять розміщення, що є реальним занепокоєнням, коли HTML надходить із шаблону, який редагує клієнт

Як елементи flex отримують свої розміри

У напрямку рядка контейнер підсумовує базу кожного елемента разом з його ваговими коефіцієнтами росту та стиснення, потім розподіляє простір, що лишився, позитивний чи негативний, відповідно до цих коефіцієнтів. З 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 повідомляє, що не влізло. Передайте його наступному блоку чи наступній сторінці, і потік відновиться там, де зупинився

Розміщення в grid і чим може бути трек

Треки grid приймають фіксовані довжини, відсотки, одиницю 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 — усе поводиться всередині елемента flex точнісінько так само, як на верхньому рівні, а зовнішній план верстки записує кінцеві команди тексту й прямокутників, тож повторне малювання повторно використовує наявний кеш вимірювання

Чому виноски — проблема розбиття на сторінки?

Виноска — це не вміст, що йде потоком після абзацу, який містить посилання на неї; це вміст, який має з'являтися внизу того самого блока, що й посилання на нього. Це інвертує звичайний порядок вимірювання, бо простір, доступний для основного тексту, тепер залежить від вмісту, який ще не зверстано

Тому рендерер вимірює примітку, коли зустрічає посилання, і віднімає площу примітки з бюджету висоти основного тексту поточного обмеженого блока. Якщо посилання, основний текст дотепер і примітка не можуть уміститися всі разом, маркер виноски і все, що йде після нього, переходять у рядок продовження разом. Саме це правило запобігає двом класичним збоям: примітка, що друкується поверх основного тексту, і примітка, застрягла на сторінці, чиє посилання на попередній

В обмеженому блоці область примітки прикріплена до низу з роздільною лінією над нею. У необмеженому вимірюванні, де немає висоти блока, до якої можна прикріпитися, область примітки йде одразу після основного тексту. Нумерація несеться в полі розширення в стеку продовження, тож DrawHTMLTextBox і DrawHTMLStory утримують послідовність неперервною через стовпці й сторінки, а рядок продовження, створений до появи цього поля, все одно відновлюється правильно

// Виноски всередині багатостовпцевої історії утримують одну неперервну послідовність
Html := LoadTemplate('chapter.html');    // uses float:footnote markers
Remainder := Lib.DrawHTMLStory(40, 40, 515, 700,
  2,        // стовпці
  16,       // проміжок у пунктах
  20,       // максимум сторінок для цієї історії
  Html);
if Remainder <> '' then
  Log('story exceeded its page budget');

Практичні поради авторам шаблонів

Проєктуйте в межах задокументованих стель. Контейнер flex з більш ніж 256 прямими дочірніми елементами — майже завжди таблиця даних у костюмі flex, і шлях таблиці все одно вимірює її краще. Grid, більший за 64 на 64, — це електронна таблиця, і застосовується та сама порада. Для багатостовпцевого основного тексту поведінка стовпців і переносів, описана у статті переноси та збалансовані текстові стовпці, керує тим, як виглядає потік усередині кожного стовпця

Вимірюйте перед малюванням, коли верстка має вміститися. GetHTMLTextHeight повідомляє висоту, яку потребувала б задана ширина, і це дешевий спосіб обрати між однією версткою та іншою до фактичного нанесення. І трактуйте непорожній рядок продовження як нормальний, а не винятковий: це механізм, за допомогою якого довгий вміст розбивається на сторінки, а не сигнал помилки

Коли HTML надходить з рушія звітів, а не з написаних вручну шаблонів, шлях, керований набором даних, у статті рушій звітів на основі набору даних добре поєднується з цим, генеруючи розмітку, яку потім упорядковують flex і grid. А коли той самий вміст також має знову покинути PDF, шлях семантичного експорту у статті експорт PDF у Markdown і DOCX замикає цикл

Верстка HTML, генерування звітів і семантичний експорт — частини однієї бібліотеки для Delphi, C++Builder і Free Pascal; повний перелік можливостей на сторінці PDF Library for Delphi