HotPDF 可以从一棵声明式的树而不是从坐标出发构建一份分页文档。你用小节、堆栈、文本、列表和表格拼出一个 THPDFDOMDocument,把它交给 THPDFDOMRenderer,渲染器就会完成测量、分页、绘制页面装饰元素,并在需要时生成让结果具备无障碍访问能力的 PDF/UA 结构树。排版代码本身从不计算 y 坐标
任何维护过坐标驱动的报表生成器的人都明白这为什么重要。第一版能跑起来。然后客户地址变成了三行,某张表格行数增加了,某个本地化的标题换了行,下游每一个 y 坐标都错了。修补方案会以散落在业务逻辑各处的手工分页检查的形式不断累积,而两年后才提出的带标记 PDF 需求,根本无法补装到一段连"段落"是什么都不知道的代码上
树拥有什么内容,以及为什么所有权要求如此严格?
这棵 DOM 在每一层都强制单一所有权:文档拥有其各个小节,小节拥有其正文、页眉和页脚,堆栈、容器和表格拥有各自的子项。复用要么通过 Clone,要么通过注册的工厂完成,绝不会把同一个对象挂到两个父节点下。这条规则不是形式主义。一个在树中出现两次的组件会被用不同的约束测量两次,并在拆除时被释放两次
这对调用方代码的实际影响是,辅助函数返回的都是全新的实例。用 RegisterComponent 注册一个工厂并调用 CreateComponent,能得到一份具名配方,每次调用都会产出一个全新的组件,像签名栏或法律免责声明这类重复出现的页面装饰元素,正应该以这种方式放进树里
uses
HPDFDoc, HPDFLayoutDOM;
var
Doc: THPDFDOMDocument;
Section: THPDFDOMSection;
Table: THPDFDOMTable;
Row: THPDFDOMTableRow;
I: Integer;
begin
Doc := THPDFDOMDocument.Create;
Doc.GenerateStructure := True; // 生成 PDF/UA 结构树
Doc.Language := 'en-US';
Section := Doc.AddSection;
Section.PageWidth := 595; // A4,单位为点
Section.PageHeight := 842;
Section.MarginLeft := 56;
Section.MarginTop := 56;
Section.MarginRight := 56;
Section.MarginBottom := 56;
Section.Style.FontName := 'Helvetica';
Section.Style.FontSize := 10;
Section.Body.AddHeading('Annual maintenance report', 1);
Section.Body.AddText('Every asset inspected during the reporting ' +
'period is listed below, grouped by site.');
Section.Body.AddSpacer(12);
Table := THPDFDOMTable.Create('assets');
Table.AddColumn(3); // 是权重,不是绝对宽度
Table.AddColumn(1);
Table.AddColumn(1);
Table.RepeatHeaders := True;
Row := Table.AddRow(18, True); // 表头行
Row[0].Text := 'Asset';
Row[1].Text := 'Last service';
Row[2].Text := 'Status';
for I := 0 to High(Assets) do
begin
Row := Table.AddRow(16);
Row[0].Text := Assets[I].Name;
Row[1].Text := Assets[I].ServiceDate;
Row[2].Text := Assets[I].Status;
end;
Section.Body.Add(Table);
end;
分页如何避免平方级开销?
朴素的分页方式是把放不下的内容克隆一份,带到下一页。在一张一万行的表格上,这会导致每翻一页就克隆一次剩余的行,把一份线性开销的文档变成了平方级开销的文档
HotPDF 采用的是窄范围拆分。顶层渲染器按索引遍历正文子项,从不克隆整个小节或整个正文。只有真正跨越页面边界的嵌套堆栈和容器,其受影响的子树才会被克隆,而两种开销较大的叶子类型携带的是一个游标而不是一份拷贝:文本续接段保存的是它还欠着的源字符区间,表格续接段保存的是它还没放置的行片段。长文档保持线性开销,长段落无论被拆分一次还是五次,成本都相同
测量过程对副作用保持诚实。THPDFLayoutElement.Measure 被要求不产生任何绘图副作用,实际的放置操作则始终通过 THotPDF.PlaceLayoutElement 完成,这是同一个负责重新测量已放置片段、建立溢出所有权并记录诊断信息的核心例程。DOM 渲染器只决定何时另起新页的策略、页面装饰元素、间距以及续接段的生命周期
防止文档无限膨胀的表头规则
让表头跨页重复听起来很简单,却隐藏着两种失败模式。HotPDF 要求表头行只能出现在连续行的第一段,并且第一次拆分必须能容纳所有表头行加至少一行正文。若没有第二条规则,一个比剩余空间还高的表头会产生一页只有表头、后面再跟一页同样的表头,如此往复,永无止境
续接页会重绘表头,而这份重绘出来的副本会被标记为工件而非内容,这对无障碍访问和文本提取来说都是正确的做法。原始表头行在逻辑表格结构中依旧只存在唯一一份。省略这一步,屏幕阅读器就会在数据中间再次朗读一遍列标题,文本提取器也会在正文行之间插入一行重复的表头
续接深度还设有一道防御性上限,因为自定义组件完全可以把 Split 实现成始终返回一个等价的尾部。渲染器会在分离出尾部之后、开始下一页之前检查这个上限,当前这轮迭代会在自己的 finally 块中释放这个尾部,因此一个行为异常的第三方组件会以一个可诊断的错误终止,而不是把磁盘写满
一个逻辑元素,多个页面片段
自动打标签正是分页模型和结构模型必须达成一致的地方。一个跨两页拆分的段落在逻辑上仍是一个段落,因此它必须保持为一个结构元素。但标记内容标识符是按页分配的,所以每个页面上出现的可见片段都需要在该页拥有自己的 MCID
HotPDF 通过保持单一结构元素、为每个片段向其 /K 数组追加一条标记内容引用来解决这个问题,其中的 /Pg 和 /MCID 一起标识页面和标识符。该 MCID 对应的 ParentTree 槽位指回同一个元素。这正是 ISO 14289 所期望的做法,也是续接克隆有别于普通克隆的原因:普通的 Clone 意味着全新的逻辑内容,会获得一个新的语义身份,而内部的续接克隆继承的是它所延续的那个组件的身份
元素复用的查找是通过一个按组件指针排序的语义身份索引、以二分查找完成的,这让大型树上的查找保持对数级复杂度。该索引只持有非所有权的引用;结构对象本身的生命周期仍归属于 PDF 对象图
渲染器提前强制执行的结构规则
启用 GenerateStructure 后,若干 PDF/UA 规则会在渲染树的过程中被检查,而不是等文件生成之后才检查。标题从 1 级开始,不允许跳级。LI 只能出现在 L 内部,Lbl 和 LBody 只能出现在 LI 内部。TR 必须属于某张表格,TH 和 TD 必须属于某一行。在 PDF/UA 模式下,没有替代文本的图形会被拒绝
在此处提前拒绝是刻意的选择。一个在文档已经生成之后才报告缺少替代文本的校验器,只能告诉你一批一万份对账单需要重新生成;而一个直接拒绝该组件的渲染器,会在产生这份数据的上下文仍然存在时,告诉你具体是哪个组件出了问题。合规性校验仍然应当作为流水线中独立的一步存在,其具体机制在 PDF/A、PDF/X 和 PDF/UA 校验 中有介绍
var
Pdf: THotPDF;
Renderer: THPDFDOMRenderer;
Stats: THPDFDOMRenderStatistics;
begin
Pdf := THotPDF.Create(nil);
Renderer := THPDFDOMRenderer.Create;
try
Pdf.FileName := 'maintenance-report.pdf';
Pdf.BeginDoc;
Stats := Renderer.Render(Doc, Pdf);
Pdf.EndDoc;
Writeln(Format('%d page(s), %d placement(s), %d split(s)',
[Stats.PageCount, Stats.PlacementCount, Stats.SplitCount]));
Writeln(Format('structure elements=%d marked content=%d artifacts=%d',
[Stats.StructureElementCount, Stats.MarkedContentCount,
Stats.ArtifactCount]));
Writeln(Format('deepest continuation chain: %d',
[Stats.MaximumContinuationDepth]));
finally
Renderer.Free;
Doc.Free;
Pdf.Free;
end;
end;
这份统计记录比初看上去要有用得多。模板改动之后 SplitCount 骤然上升,通常意味着某个组件的测量高度超出了它容器的高度。MaximumContinuationDepth 持续攀升,是某个组件的 Split 每页推进太少的早期预警。而把 ArtifactCount 与续接页数量对比,能确认重复的表头确实被标记成了工件
DOM 与直接 API 如何配合使用
DOM 并不取代直接绘图,它构建在同一套页面对象之上。渲染器放置的任何内容都可以与在 THotPDF 上的直接调用交错使用,当报表需要在精确位置手工放置一个元素,比如一枚签名图片时,这一点就很重要。页面的关闭仍由 AddPage 和 EndDoc 控制,因此立即刷新模式不会在内存中滞留任何已完成的页面,常驻内存仍由当前的续接段、字体资源和普通的文档对象图决定
当内容由数据驱动、布局由规则驱动时选用 DOM,固定的图形内容则继续用直接绘图。如果你眼下的痛点具体就是表格分页,值得先读一读 在 PDF 中生成表格 中更聚焦的方案,而文本对齐这类文本层面的行为在 文本两端对齐 中有描述
声明式排版、自动打标签和直接绘图 API 都封装在适用于 Delphi 和 C++Builder 的同一个组件中;完整功能列表见 HotPDF Delphi PDF 组件页面