Technical Article

在 Delphi 中加载来自 Word 和 Excel 的混合引用 PDF

打开由 Microsoft Word 或 Excel 生成的 PDF,逐页翻阅,没有任何异常。将其加载到 Delphi 程序中,读取返回的页数,数字是正确的。然后开启加密并重新保存,任务却失败并抛出 EListError,或者输出的文件打开时显示交叉引用损坏的警告。文件其实从未损坏。它是一个混合引用(hybrid-reference)文件,而正是这种让十五年前的查看器也能打开文件的结构,打败了过早停止读取的加载器。

这是通过了所有内部测试的 PDF 流水线遇到无法正常往返(round-trip)处理的文件的最常见方式之一。内部生成的输入文件全都不是混合的。第一个混合文件往往是在客户转发了一份从电子表格导出的发票的那天到来的。

Word 和 Excel 到底写入了什么

ISO 32000-1 §7.5.8.4 描述了混合引用布局。如果应用程序既想要 PDF 1.5 的功能(例如对象流),同时又想让 PDF 1.4 的读取器能够打开文件,它就会将交叉引用信息写入两次。一次是传统的交叉引用表(即 1.4 及更早版本 PDF 结尾处的固定宽度 ASCII 行),另一次是索引其余部分的内容的交叉引用流。经典部分的 trailer 携带一个 /XRefStm 项,其值是该流的字节偏移量。

这种分工是刻意为之的。旧版本读取器必须访问的对象(如编目 Catalog 和页面树 page tree)可以从传统表中访问。折叠进压缩对象流中的对象在传统表中被标记为空闲(带有类型为 f 的条目),因此 1.4 读取器会直接跳过它们,永远不会被无法解析的结构所卡住。它们的真实位置只存在于交叉引用流中。此类文件的特征是它的尾部:一个简短的传统部分,通常仅是 xref 后跟一个 0 0 子部分头部,其 trailer 指向实际恢复数据所在的 /XRefStm

为什么正确的页数什么也证明不了

因为编目和页面树是故意设计为可从传统表中访问的,所以只读取该表的加载器能够找到 /Root,遍历页面树并报告正确的页数。旧读取器所需的一切都存在,因此文件看起来很健康。丢失的对象是那些打包在对象流中的对象:AcroForm 表单字段字典、标记 PDF(tagged-PDF)结构元素,以及不需要对遗留查看器可见的一长串小字典。

在某些操作触及这些对象之前,你不会注意到这个静态的缺失,而完全重新保存(resave)会触及所有这些对象。遍历文档以进行重新加密或重写,恰恰需要依次请求每个对象编号,这就是为什么症状会在保存时而不是加载时显现,距离原因发生的地方已经很远了。

陷阱在于探测器一看到 xref 就停止

判断文件索引方式的简便方法是遵循 startxref 并检查它指向的头几个字节。关键字 xref 意味着经典表;流对象意味着交叉引用流。对于只采用一种方案的文件,这种测试是正确的。但对于混合文件,它是错误的,因为混合文件的 startxref 指向经典部分纯粹是为了满足旧读取器,而该部分 trailer 中的 /XRefStm 才是文档大部分内容被实际索引的地方。一遇到 xref 就返回“经典(classic)”的探测器永远不会读取 /XRefStm,所有仅存在于流中的对象都变得不可见了。

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

如果存在这种提前退出的探测器,加载看起来很正常,而重新保存则是缺失对象显现的地方。修复方法不是在开始时读取更多字节,而是在判定文件读取完成之前识别混合 trailer 并遵循 /XRefStm

合并顺序不可妥协

一旦读取了两个索引,它们只能朝一个方向进行合并。必须首先合并交叉引用流,然后在其周围填充经典条目。原因在于该格式核心的精妙欺骗。混合文件在经典表中将其压缩对象标记为空闲,以便旧读取器忽略它们。如果加载器遵循“先入为主”的策略并先读取经典表,它会将这些对象编号记录为空闲,然后丢弃实际定位它们的流条目,因为槽位已被占用。反转顺序后,来自流的类型 2(type 2)条目(每个条目是一个对象流编号加一个索引)将赢得它们本应拥有的槽位,而经典条目则落在它们周围。

相同的规则也防止了旧版本复活已删除的对象。增量更新通过 /Prev 向后链接,类型 0(type 0)空闲条目是表示较新部分已停用某个对象编号的哨兵。链中稍后读取的更旧部分绝不能被允许用陈旧的位置覆盖该哨兵。如果将“首次看到”视为针对空闲标记的权威,已删除的对象就会保持删除状态;如果处理不当,文件自身的历史记录就会重新激活最新版本已删除的内容。

这在 HotPDF 中意味着什么

引擎会为你解析混合引用文件,并且在必须解析交叉引用数据的每条路径上都会这样做。使用 LoadFromFileLoadFromStream 加载文档,进行更改,然后调用 SaveLoadedDocument;或者运行诸如 EncryptFile 之类的一键式操作(读取输入并写入输出)。无论哪种方式,恢复过程都会读取 /XRefStm,在经典条目之前合并流部分,并在写入操作进行枚举之前解析存在于流中的对象。AES-256 加密路径是该问题首次显现的地方,因为加密文档会重写每个对象,从而要求每个对象都必须已经被定位。

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

值得关注的细节位于 API 的上游。来自 Word、Excel、PowerPoint 以及一长串“另存为 PDF”流水线的文件通常都是混合的,因此你仅针对自己生成器输出运行的加载器可能在测试中永远遇不到混合文件。在你的测试固件中放入从真实 Office 应用程序导出的文档,而不仅仅是你自己代码生成的文件。

检查你怀疑的文件

通过两项检查可以快速解决这个问题。在十六进制视图中打开文件并读取最后一个 startxref 之后的字节;混合文件会显示一个简短的经典部分,其 trailer 字典中包含 /XRefStm。或者将完全解析报告的对象计数与 trailer 中 /Size 声明的最大对象编号进行比较。如果存在巨大差距,意味着对象隐藏在加载器未打开的流中,这正是后来导致保存失败的缺陷所在。

故事的写入器端,即最初是如何生成对象流和压缩交叉引用的,在我们关于对象流和增量更新的文章中进行了介绍。当遇到非常大的混合文件时,针对大型 PDF 工作流的 Direct File API 指南中的加载技术使你无需将其全部读取到内存中即可进行检查。这两者都与此处描述的恢复自然配合使用,该恢复作为 Delphi 和 C++Builder 的 HotPDF Component 的一部分提供,同时还包括本博客其他地方介绍的加载、编辑、加密和签名 API。