打开一个经历几年生产使用的 PDF,你经常会发现簿记内容比正文还多:成千上万个小字典对象,每个对象前都有自己的 obj 头,再加上每个对象消耗 20 个 ASCII 字节的交叉引用表。我们分析过的一个支持案例中,58 MB 的保单归档不到一半字节是真正的页面内容,其余都是结构开销和陈旧 revision。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 库,它公开了两种分别针对这两个问题的文件格式机制:用于紧凑存储的对象流,以及用于只追加修改并不破坏历史内容的 incremental updates。
对象流和 xref 流如何重塑文件
在 PDF 1.4 之前,每个间接对象都以未压缩形式位于主体中,末尾的交叉引用表是固定宽度文本结构:每条记录正好 20 字节,不允许压缩。因此,一个有 200,000 个对象的文档,仅 xref 数据就大约有 4 MB,还没绘制任何一个 glyph。PDF 1.5 引入了两个替代机制,定义于 ISO 32000-1 §7.5.7 和 §7.5.8:对象流(/Type /ObjStm)把非 stream 对象聚合进一个 Flate 压缩容器,交叉引用流则把查找表本身存成带可变宽字段的压缩二进制。
节省最明显的地方,正是朴素生成器最容易浪费的地方:表单密集型文档、成千上万个字段字典、深层 outline 树,以及 tagged-PDF 结构元素。这些对象单个很小且高度重复,打包在一起后非常适合作为 Flate 输入。页面内容流在 PDF 1.5 之前本来就可压缩,所以对象流不会大幅缩小图像主导的文件;它们会显著缩小结构主导的文件。
在 HotPDF 中,这两个功能通过一对属性打开,而且顺序依赖很重要:
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'catalog-2026.pdf';
Pdf.UseXRefStream := True; // binary xref, prerequisite for ObjStm
Pdf.UseObjectStreams := True; // pack objects into /Type /ObjStm
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Compressed structure demo');
Pdf.EndDoc; // emits XRefStm + ObjStm containers
finally
Pdf.Free;
end;
end;
UseObjectStreams 要求 UseXRefStream 为 True,因为压缩对象由 type-2 xref 记录寻址,即对象流编号加索引,而 type-2 记录无法用经典 20 字节文本表表达。只设置 UseObjectStreams 不会带来任何收益;在 BeginDoc 前同时设置两者,才是有效配置。
PDF 1.4 处的兼容边界
这两个属性默认都是 False,原因会咬到有遗留集成的团队。只理解 PDF 1.4 语义的阅读器遇到 xref 流时,通常不会报告“compressed objects unsupported”,而是报告交叉引用表损坏或直接拒绝文件,因为它期待的 trailer keyword 布局并不存在。如果输出会进入旧传真网关、带嵌入式解释器的硬件打印机,或按 PDF 1.4 编写的下游解析器,就应为该通道保持两个标志关闭并接受较大的文件。对归档和 Web 分发通道而言,主流查看器已经支持 PDF 1.5 二十年,启用它们几乎是免费的压缩。
还有一个运维副作用值得提醒支持团队:一旦字典被打包到对象流中,两个生成文件的字节级 diff 就失去意义,因为一个字段变化可能让整个容器重新 Flate。支持调查应按对象内容进行逻辑比较,而不是做二进制 diff。
为什么 incremental updates 存在:偏移、签名、审计轨迹
PDF 中的数字签名覆盖显式的 /ByteRange:物理文件中的两个区间,以绝对字节偏移计量,CMS digest 就是基于它们计算的。即使可见内容完全相同,只要重写文件,每个偏移都会移动;digest 不再匹配,签名就会报告损坏。这就是 ISO 32000-1 §7.5.6 定义 incremental updates 的具体原因:变更和新增对象追加在已有 %%EOF 之后,随后是新的交叉引用区段,其 /Prev 项指回前一个区段。原始字节从不触碰,因此之前已签名的 revision 仍可验证,Acrobat 也可以在签名面板中分别显示每个已签名 revision。
HotPDF 把它封装成专用入口点:
Pdf.BeginIncrementalUpdate('contract-signed.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Addendum recorded 2026-06-11');
Pdf.SaveIncrementalUpdate('contract-updated.pdf'); // appends the delta only
这里有两个细节很容易出错。第一,BeginIncrementalUpdate 必须接收原始文件名;追加的 xref 区段会存储只相对于这些确切原始字节有效的偏移。第二,保存按定义只追加,所以输出总是比输入大。这不是需要优化掉的缺陷,而是保持早期已签名 revision 完整的属性。
编辑已加载文档:LoadFromFile,而不是 BeginDoc
另一个陷阱会抓住通过生成 API 学会 HotPDF 的开发者。BeginDoc 会开始一个新文档;当目标是修改现有文档时,它是错误调用。文档手术要走 loaded-document 路径:
PageCount := Pdf.LoadFromFile('base.pdf');
Pdf.InsertPagesFromDocument(OtherDoc, '1-3', 5); // pages 1-3 after page 5
Pdf.MovePage(2, 5);
Pdf.SaveLoadedDocument('modified.pdf');
混用这两个模型的症状,是文件只包含新内容而没有任何原始内容,因为 BeginDoc 会愉快地在你以为正在编辑的文件旁边创建新文档。审阅代码时,应把 LoadFromFile + SaveLoadedDocument 视为一组配套词汇,把 BeginDoc + EndDoc 视为另一组;同一个过程对同一文档同时使用两组,几乎总是 bug。
控制增长:何时压缩追加过的文件
只追加保存有长期成本。每晚给同一个 PDF 盖一个状态行的作业,一年会产生 365 个 revision,每个 revision 都附带新的 xref 区段。一旦 revision 历史完成使命,并且没有签名必须保留,就可以通过 loaded-document 路径重新序列化来压缩文件:
Pdf.LoadFromFile('stamped.pdf');
Pdf.SaveLoadedDocument('compacted.pdf');
重新保存是完整重写,意味着它会有意丢弃历史 revision,并使文件中的任何签名失效,因此应把它放在与其他破坏性操作相同的策略检查之后。一个在生产中有效的合理规则是:当 revision 数超过阈值,或追加开销超过基础文件某个百分比时进行压缩,并且永远不要压缩签名面板非空的文件。
发布前检查结果
这对功能的验证相当机械。用 Adobe Acrobat 打开输出并检查三点:启用对象流时,文档属性报告 PDF 1.5 或更高;incremental update 之后,签名面板仍验证每个既有签名 revision;load-modify-save 周期后,页数和书签仍然存在。对归档通道,还应把文件跑过 veraPDF,因为压缩 xref 结构正是严格解析器比宽容查看器更仔细检查的内容。如果你还处理超大输入,关于大型 PDF 工作流中 Direct File API 的演练可以很好地与 incremental saving 结合;上文提到的签名机制则在 HotPDF 数字签名和 PAdES 文章中深入讨论。
FAQ
启用对象流会破坏旧 PDF 阅读器吗?
只实现 PDF 1.4 的阅读器无法解析 xref 流,通常会把文件报告为损坏。对于进入遗留解释器的通道,应把 UseXRefStream 和 UseObjectStreams 保持为默认 False;对现代查看器和归档通道再启用它们。
Incremental update 会保持我的数字签名有效吗?
会,这正是它的目的:新对象追加在已签名字节之后,因此已签名的 /ByteRange 仍然能正确计算 digest。完整重写,包括 load-and-resave 压缩,即使可见内容未变,也会破坏每个既有签名。
为什么我的文件反复保存后一直增长?
Incremental saving 每次保存都会追加一个 delta,永不回收空间。当 revision 历史和签名不再需要保留后,应偶尔用 LoadFromFile 加 SaveLoadedDocument 压缩。
下一步看什么
这两个功能都是 HotPDF Component 标准功能集的一部分,与本博客其他文章展示的生成、表单、加密和签名 API 一起提供。如果你希望把上面的调用映射到自己的文档管线,产品页链接了完整 API 文档。