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

Сітки rowspan і повторювані заголовки таблиць у HotPDF

HotPDF рендерить HTML-таблиці через свій профіль HTML5 для друкованих сторінок, використовуючи справжню сітку зайнятості для rowspan і colspan, виміряні висоти рядків замість оцінок за кількістю символів і повторення рядків заголовків на кожній сторінці продовження. Дві ситуації змушують його відмовитися повторювати заголовок, і знати їх заздалегідь дешевше, ніж налагоджувати задубльовану комірку

Клас документів, який це вимагає, — той, що кожна звітна команда рано чи пізно випускає: рахунок чи комплаєнс-звіт, де джерело істини — HTML, таблиця тягнеться через чотири сторінки, і заголовок має бути читабельним на кожній із них. Щось менше за справжнє табличне верстання дає дві відмови, які читачі помічають миттєво: заголовок, що з'явився один раз на першій сторінці, і рядки, чия висота була вгадана за кількістю символів

Чому таблична можливість переїхала в HTML-рендерер?

Бо альтернатива втрачає rich text, а rich text — це причина, з якої вміст узагалі є HTML. Очевидний план виглядає як повторне використання: HotPDF уже має об'єкт таблиці layout DOM із повноцінною сіткою, тож містимо HTML-парсер у нього й отримуємо спани безкоштовно. Проблема в тому, чим цей об'єкт таблиці малює. Його комірки несуть текст і стиль, а шлях малювання видає простий текстовий вивід, тож усе, що HTML справді містив за межами шрифту й кольору — посилання, верхні індекси, вбудовані зміни розміру, колір на рівні фрагментів, — зникає до того, як доходить до сторінки

Напрям, який виживає при контакті з реальними документами, — зворотний. Перенесіть можливості табличного рушія — сітку зайнятості, справжнє вимірювання, повторення заголовків і зважування колонок — у HTML-рендерер і лишите rich-text рендеринг там, де він уже працює. Це більша зміна, ніж міст, і це зміна, яка зберігає гіперпосилання всередині комірки таблиці гіперпосиланням

Rowspan без union-find

Розтягнуті комірки створюють атомарні групи рядків, але замикання по цих групах не потребує загальної структури disjoint-set, бо зайнятість завжди є неперервним інтервалом. Комірка з rowspan="3", що починається з рядка K, займає рядки з K по K+2 і нічого іншого, тож інформація про групу зводиться до маркера кінця на рядок

Алгоритм — два рядки наміру. Коли ви розміщуєте розтягнуту комірку, що починається з K і закінчується на E, записуйте GroupEnd[K] := Max(GroupEnd[K], E). Потім один раз пройдіть рядки у зворотному напрямку, застосовуючи G[R] := G[G[R]], що проштовхує кожен кінець рядка назад крізь перекривні спани й дає транзитивне замикання за один прохід. Отримуєте для кожного рядка останній рядок, який мусить лишитися з ним на тій самій сторінці, — а це рівно те, що потрібно кроку пагінації, щоб вирішити, де може впасти розрив

Розподіл висоти — друга половина. Коли розтягнута комірка потребує більше вертикального простору, ніж зараз дають рядки, які вона покриває, надлишок іде в останній рядок спана, а не розмазується рівномірно. Обробляйте розтягнуті комірки після того, як звичайні висоти рядків устоялися, а тоді додавайте до фінального рядка кожного спана. Рівномірне розмазування здається справедливішим і дає наочний неправильно вивід: рядки, що містять лише короткі однострічкові комірки, роздуваються, бо якась нерелевантна комірка трьома рядками вище виявилася високою

Сітка HTML-таблиці HotPDF, де одна комірка з rowspan 3, що починається з рядка 2, займає рядки з 2 по 4 як єдиний атомарний прямокутник, поруч зі значеннями кінців груп на рядок, що їх дає один зворотний прохід і що показують рядки 2, 3 і 4, прив'язані до однієї сторінки
Зайнятість спана завжди неперервний інтервал, тож маркери кінців на рядок і один зворотний прохід замінюють union-find і кажуть пагінації рівно, де може впасти розрив
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5 приймає необов'язкову авторську таблицю стилів як другий аргумент — саме туди йдуть правила друку. Тримайте екранну таблицю стилів осторонь. Профіль версійний, і HTML5ProfileMilestones повідомляє, які групи можливостей реалізує поточна збірка: himParserCascade, himPagedLayout, himTablesForms і himBoundedResources, тож застосунок може деградувати навмисно, замість того щоб виявити діру вже в проді

Вимірювання мусить точно збігатися з малюванням

Висота рядка коректна лише тоді, коли код, який вимірює перенесені рядки, переносить їх за тим самим правилом, що й код, який їх малює. Це звучить очевидно і є найпоширенішою причиною таблиць, чиї межі не збігаються з вмістом. HotPDF вимірює жадібним лічильником рядків, і цей лічильник мусить збігатися з семантикою переносу rich-text шляху виводу в трьох конкретних відношеннях: він ламає лише на пробілах, ніколи не ріже слово, а слово ширше за колонку отримує власну стрічку

Друга вимога — шрифт. Вимірювання має виконуватися шрифтом самої комірки, встановленим через SetFont із фактичним ім'ям, набором стилів і розміром перед викликом функції ширини, а не тим шрифтом, який випадково був активний. Жирний текст регулярно ширший за звичайний більш ніж на десять відсотків за того ж розміру, а це досить, щоб перетворити тристрічкову комірку на чотиристрічкову. Таблиця, де комірки заголовка жирні, а тіла — ні, виміряна одним шрифтом, буде неправильною рівно в тих рядках, на які читачі дивляться першими

Правильність тут змінює те, що можна стверджувати в тесті. Спостережуваний ефект точного вимірювання — це міжряддя, а не кількість гліфів: однострічковий рядок має близько 20 пунктів висоти, тоді як оцінка за кількістю символів того самого вмісту передбачає два рядки і приблизно 35. Стверджуйте про вертикальну відстань між рядками. І пам'ятайте, що в користувацькому просторі PDF Y зростає вгору, тож заголовок, який сидить над рядком тіла, означає, що значення Y заголовка більше, — протилежність тому, що пише інстинкт екранних координат

Коли HotPDF відмовляється повторювати заголовок?

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

Потік рішень HotPDF для повторення заголовків HTML-таблиць через розриви сторінок: заголовок, чий rowspan переходить у рядки тіла, малюється один раз, заголовок вищий за 90 відсотків придатної висоти сторінки малюється один раз, а кожен інший заголовок повторюється на кожній сторінці продовження
Дві відмови навмисні: повторення заголовка, який володіє розтягнутою коміркою тіла чи заповнює більшу частину сторінки, намалювало б вміст там, де він вже не належить, або лишило б без місця для даних

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

// Ваги колонок приходять із розмітки, тож таблиця стилів друку — те
// місце, де ними керують. Ширини трактуються як ваги, а не як пікселі
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// Рядок заголовка, що несе rowspan, який переходить у тіло таблиці,
// пригнічує повторення заголовка. Тримайте спани в одній секції:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  добре
//   <tr><th rowspan="3">Item</th>...  переходить у tbody, без повторення

Ширини колонок поводяться як ваги, а не як абсолютні виміри, — і це поведінка, яка тримає таблицю придатною, коли вміст не збігається з авторською оцінкою. Колонка, оголошена на 30 відсотків, отримує приблизно 30 відсотків доступної ширини, але розподіл поважає мінімальну ширину, яку кожна колонка справді потребує, тож вузька колонка з довгим нерозривним токеном не переповнює бокс таблиці мовчки

Де це місце в документному конвеєрі

Таблична робота сидить усередині ширшого профілю друкованих сторінок, і правила пагінації, бюджети ресурсів та обробка CSS, описані в шляху імпорту HTML5 paged media, застосовуються без змін до документів із таблицями. Якщо ваші дані не починаються як HTML, шлях прямої побудови з будування таблиць просто в PDF оминує шар розбору цілком і дає ту саму поведінку сітки через API. А оскільки висота рядка зрештою залежить від того, де ламаються рядки, обговорення вимірювання в вирівнюванні тексту й переносах рядків — компаньйон для всіх, хто тюнить щільний табличний вивід

Багаторазовий урок тут — взагалі не про таблиці. Коли нова підсистема потребує можливості, яку стара підсистема вже має, спитайте, яка з двох володіє тим, що найважче реімплементити. Арифметика сітки — це кілька десятків рядків, і вона переїжджає легко. Rich-text рендеринг із вбудованими посиланнями, верхніми індексами та стилізацією на рівні фрагментів — ні, тож сітка переїхала, а текст лишився. HotPDF постачає обидва шляхи в складі HotPDF Delphi PDF component, тож вибір між HTML-входом і прямою побудовою — рішення проєкту, а не бібліотеки