技术文章

从 Delphi 在 PDF 中实现 Flexbox、CSS Grid 和脚注

PDF Library for Delphi 会把 HTML 渲染进 PDF 页面,并带有真正的二维布局:display: flexdisplay: grid 会被测量并放置,而不是退化成堆叠的块;脚注会被预留在承载其引用的那个盒子底部,编号在跨列和跨页时都保持连续。入口点还是那两个熟悉的调用,单个盒子用 DrawHTMLTextBox,多列流式排版用 DrawHTMLStory

这一点之所以重要,是因为如今大多数报表内容都是以 HTML 形式到来的。模板由会写 CSS 的人编写,仪表盘按卡片来设计,一个把 flex 行悄悄折叠成四个堆叠块的渲染器,生成的文档会与设计稿完全对不上。在这项能力出现之前,引擎唯一测量的二维容器就是表格,因此每一种卡片式布局都得手工重新写成表格

排版模型发生了什么变化?

此前的主循环维护单一一个行盒,在页面上向下推进。这个模型能完美处理行内内容和堆叠的块,却无法表达一个子项尺寸相互关联的容器。表格是唯一的例外,自带一套两遍测量机制

Flex 和 grid 各自为容器的子项增加了一趟有界的测量过程,关键词是"有界"。一个 flex 容器最多把 256 个直接子项测量进一个固定数组。一个 grid 使用一个至多 64 乘 64 单元格的占用矩阵来做确定性的自动放置。设置这些上限是为了让一份恶意或生成出来的样式表无法引发无界递归或平方级的放置内存开销,当 HTML 来自客户可编辑的模板时,这是一个真实的顾虑

flex 子项如何获得尺寸

在主轴方向上,容器会把每个子项的基础尺寸连同其增长和收缩权重相加,再按这些权重分配剩余空间,不论是正的还是负的。启用 flex-wrap 时,每一行都独立求解,因此一行折成两行后,空闲空间是按行分配的,而不是在整个容器范围内分配。在交叉轴方向上,同样的主轴分配逻辑会针对显式高度或内容高度来运行

justify-contentalign-itemsgap 以及各种反向排列,操作的都是已经测量好的几何信息。它们只移动盒子;绝不会触发对子项内容的重新测量。正是这种分离,让一个复杂的仪表盘不会被测量好几遍

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 子项内部的表现与在顶层完全一致,外层的排版方案会记录最终的文本和矩形绘制命令,因此重复绘制会复用已有的测量缓存

为什么脚注是一个分页问题?

脚注不是排在包含其引用的那段之后再往下流的内容;它必须出现在承载其引用的那个盒子的底部。这就颠倒了通常的测量顺序,因为正文可用的空间此刻取决于一段尚未排版的内容

因此,渲染器在遇到引用时就会测量对应的脚注,并从当前有界盒子的正文高度预算中扣除脚注区域的高度。如果引用、目前为止的正文以及脚注三者无法同时放下,脚注标记及其后的所有内容会一起移入续接字符串。这条规则正是用来防止两种经典失败的:脚注覆盖正文文字,以及脚注被搁浅在一页,而其引用却在前一页

在一个有界盒子内,脚注区域会被固定在底部,上方带一条分隔线。在无界测量的情况下,由于没有盒子高度可以固定,脚注区域会紧跟在正文之后。编号信息保存在续接栈的一个扩展字段中,因此 DrawHTMLTextBoxDrawHTMLStory 能让编号序列跨列、跨页保持连续,即便是在这个字段出现之前生成的续接字符串,恢复时也依然正确

// 多列长文中的脚注保持同一条连续序列
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 容器,几乎总是一张披着 flex 外衣的数据表,而表格路径能更好地测量它。一个超过 64 乘 64 的 grid 是一张电子表格,同样的建议也适用。对于多列正文,列宽和断字行为见 断字与均衡多列文本,它决定了流入每一列的内容看起来是什么样

需要精确适配时,请先测量再绘制。GetHTMLTextHeight 报告给定宽度所需的高度,这是在真正落墨之前,以低成本在几种布局之间做决定的方法。而且请把非空的续接字符串当作常态而不是异常来对待:它正是长内容得以分页的机制,而不是一个错误信号

当 HTML 来自报表引擎而不是手写模板时,数据集报表引擎 中的路径与此配合得很好,由它生成标记,再由 flex 和 grid 负责排布。而当同一份内容还需要再从 PDF 导出时,把 PDF 导出为 Markdown 和 DOCX 中的语义导出路径能补完这个往返闭环

HTML 排版、报表生成和语义导出都是适用于 Delphi、C++Builder 和 Free Pascal 的同一个库的一部分;完整功能列表见 PDF Library for Delphi 页面