技术文章

HotPDF 中的 rowspan 网格与跨页重复表头

HotPDF 通过它的 HTML5 分页媒体档渲染 HTML 表格:为 rowspan 和 colspan 用真实的占用网格,行高靠实测而不是数字符数,表头在每一张续页上重复。有两种情况它会拒绝重复表头——事先弄清这两条,比事后调试一个重复出现的单元格便宜得多

逼出这套能力的文档类型,每个报表团队迟早都会遇到:以 HTML 为事实来源的发票或合规报告,一张表横跨四页,而表头在每一页上都必须可读。任何达不到真实表格布局的做法,都会产出的正是读者一眼就能看到的两种失败:表头只在第一页出现一次,行高靠字符数瞎猜

表格能力为什么搬进了 HTML 渲染器?

因为另一条路会丢富文本,而富文本正是内容一开始就用 HTML 的原因。显而易见的方案听起来像复用:HotPDF 已经有一个带正经网格的布局 DOM 表格对象,把 HTML 解析器桥接过去,跨行跨列就白拿了。问题在于那个表格对象是用什么画的。它的单元格只有文本加一份样式,绘制路径输出的是纯文本——HTML 里真正含着的东西,链接、上标、行内字号变化、按 run 的颜色,在到达页面之前就没了

在真实文档面前站得住的是反方向:把表格引擎的能力——占用网格、真实测量、表头重复、列宽加权——搬进 HTML 渲染器,富文本渲染留在它已经工作的位置。这比桥接的改动大,但它是唯一能让「表格单元格里的超链接还是超链接」的改动

不需要 union-find 的 rowspan

跨行单元格制造原子行组,但对这些组求闭包不需要通用的不相交集合结构,因为占用永远是一段连续区间。一个 rowspan="3"、从第 K 行开始的单元格占用 K 到 K+2 行,仅此而已,于是组信息可以退化成每行一个结束标记

算法用两句话就能说完。放置一个从 K 开始、到 E 结束的跨行单元格时,记录 GroupEnd[K] := Max(GroupEnd[K], E)。然后反向把所有行走一遍,应用 G[R] := G[G[R]],让每行的结束点穿过重叠的 span 向后传播,一次遍历就得到传递闭包。你得到的是:对每一行,必须与它同页的最后一行是哪行——这正是分页步骤决定断点落点所需要的全部信息

高度分配是另一半。当一个跨行单元格需要的垂直空间超过它覆盖的行目前提供的高度时,盈余归 span 的最后一行,而不是均摊。先确定普通行高,再处理跨行单元格,给每个 span 的末行补足。把盈余摊平看起来更公平,产出却明显是错的:只含短单行单元格的行被撑高了,只因为三行之上某个不相干的单元格碰巧很高

HotPDF 的 HTML 表格网格:一个 rowspan 为 3、从第 2 行开始的单元格把第 2 到第 4 行占成一个原子矩形;旁边是一次反向遍历得到的每行组结束值 G(R),显示第 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 会报告当前构建实现了哪些能力组——himParserCascadehimPagedLayouthimTablesFormshimBoundedResources——应用程序因此可以刻意降级,而不是在生产环境里撞见缺口

测量必须与绘制严格一致

行高只有在「测折行的代码」和「画折行的代码」按同一条规则折行时才正确。这听起来像废话,却是表格边框对不上内容的最常见根源。HotPDF 用贪心行计数器测量,这个计数器必须在三个具体方面与富文本输出路径的折行语义一致:只在空格处断行、绝不拆词、比列宽还宽的词独占一行

第二个要求是字体。测量必须用单元格自己的字体跑——先通过 SetFont 设置真实的字体名、样式集和字号,再调用宽度函数——而不是用碰巧处于激活态的任何字体。同样字号下粗体通常比常规体宽百分之十以上,足够把三行的单元格变成四行。一张表头加粗、正文不加粗的表格,如果用单一字体测量,错的恰好是读者最先看的那几行

把这一点弄对,测试里能断言的东西就变了。精确测量产生的可观察效应是行间距,不是字符数:单行行高约 20 磅,而字符数估算对同样内容会给出两行、约 35 磅。断言就打在行与行的垂直距离上。另外记住 PDF 用户空间里 Y 轴向上增长,所以表头在正文行上方意味着表头的 Y 值更大——和屏幕坐标的直觉正好相反

HotPDF 什么时候拒绝重复表头?

两种情况,且都属于「硬要重复就会产出明显错误结果」的情况。第一种:表头块里有一个跨行单元格,越过了表头伸进了正文行。重复表头意味着把那个单元格的内容在不属于它的位置再画一遍,所以表头只画一次,表格不带它继续。第二种:表头高度超过可用页高的 90%,重复它之后几乎没有给数据留任何空间,表格将无法前进

HotPDF 跨分页重复 HTML 表头的决策流程:rowspan 越过 thead 边界伸入正文行的表头只画一次,高度超过可用页高 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%,但分配过程尊重每列实际需要的最小宽度,所以一个装着超长不可断词的窄列不会悄悄溢出表格盒子

它在文档流水线中的位置

表格工作属于更大的分页媒体档,HTML5 分页媒体导入路径里描述的分页规则、资源预算和 CSS 处理,原封不动地适用于含表格的文档。如果你的数据起点不是 HTML,直接把表格构建进 PDF的直构路线完全绕过解析层,通过 API 给你同样的网格行为。而因为行高最终取决于折行位置,文本对齐与断行里的测量讨论,是所有调优高密度表格输出的人的配套阅读

这里可复用的教训其实与表格无关。当一个新子系统需要一个旧子系统已有的能力时,先问:两边谁拥有最难重新实现的那件事。网格算术是几十行代码,搬起来很轻松;带行内链接、上标和按 run 样式的富文本渲染不是。所以网格搬了,文本留下了。HotPDF 把两条路径都作为 HotPDF Delphi PDF component 的一部分发布,HTML 输入还是直构,是项目决策而不是库强加的