打开由 Microsoft Word 或 Excel 生成的 PDF,浏览它,看起来一切正常。将其加载到 Delphi 程序中,读取返回的页数也是正确的。但是随后开启加密并重新保存,任务却以 EListError 失败,或者输出打开时出现损坏的交叉引用警告。该文件从来没有损坏。它是一个混合引用文件,正是这种让十五年老的查看器可以打开它的结构,击败了过早停止读取的加载器
这是通过所有内部测试的 PDF 管道遇到无法往返的文件的最常见方式之一。输入都是内部生成的,因此它们从来都不是混合的。第一个混合文件的到来通常是客户转发从电子表格导出的发票的那一天
Word 和 Excel 实际上写了什么
ISO 32000-1 在 §7.5.8.4 中描述了混合引用布局。想要诸如对象流之类 PDF 1.5 功能、同时又让 PDF 1.4 读取器能打开文件的应用程序,会写入交叉引用信息两次。这里有一个经典的交叉引用表,即结束高达版本 1.4 的每一个 PDF 的固定宽度 ASCII 行,还有一个索引其余内容的交叉引用流。经典部分的尾部携带一个 /XRefStm 条目,其值为该流的字节偏移量
这种分工是有意为之的。旧的读取器需要获取的对象(其中包括目录和页面树),可以从经典表中寻址。折叠到压缩对象流中的对象在经典表中被标记为空闲(带有类型 f 条目),因此 1.4 版本的读取器会直接跳过它们,永远不会在它无法解析的结构上被绊倒。它们的真实位置仅存在于交叉引用流中。此类文件的特征就是其尾部:简短的经典部分,通常只不过是 xref 紧接着 0 0 子节头,其尾部指向 /XRefStm,实际的恢复数据就位于那里
为什么正确的页数不能证明什么
因为故意使目录和页面树能够从经典表到达,只读取该表的加载器找到 /Root,遍历页面树,并报告正确的页数。旧读取器需要的一切都存在,因此文件看起来很健康。丢失的对象是那些打包到对象流中的对象:AcroForm 字段字典,带标签的 PDF 结构元素,以及不需要对遗留查看器可见的小字典的长尾
直到某些内容触及那些对象,您才会注意到差距,而全面重新保存将触及所有对象。遍历文档以重新加密或重写正是依次请求每个对象编号的操作,这就是为什么症状出现在保存时而不是加载时,与其原因相去甚远
陷阱是看到 xref 并停止的检测器
确定文件索引方式的廉价方法是追踪 startxref 并检查它所指向的前几个字节。关键字 xref 表示经典表;流对象表示交叉引用流。该测试对于采用单一方案的任何文件都是正确的。对于混合文件来说这是错误的,因为它的 startxref 指向经典部分只是为了满足旧读取器,而该部分尾部的 /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;
由于设置了提早退出的检测器,加载看起来很好,但在重新保存时缺少的对象就会显现出来。修复方法不是在开始时读取更多字节;而是在决定文件结束之前识别混合尾部并跟踪 /XRefStm
合并顺序不容协商
一旦两个索引都被读取,它们就只能沿一个方向合并。必须首先合并交叉引用流,然后在它周围填充经典条目。原因是格式核心中的一个小骗局。混合文件在经典表中将其压缩对象标记为空闲,以便旧的读取器忽略它们。如果加载器遵循“首先看到的优先”策略并首先读取经典表,它将记录这些对象号为空闲,然后丢弃实际定位它们的流条目,因为这些槽已经被占用了。颠倒这个顺序,来自流的类型 2 条目(每个都是一个对象流编号加上一个索引),将赢得它们应该拥有的槽,而经典条目则在它们周围安顿下来
同样的规则可以防止较旧的版本恢复已删除的对象。增量更新通过 /Prev 向后链接,类型 0 空闲条目是一个哨兵,表示最近的部分已停用对象号。绝不能允许链中较晚、较旧的部分用过时的位置覆盖该哨兵。将首先看到的作为空闲标记的权威,已删除的对象就会保持删除状态;如果不小心处理,文件自身的历史将会恢复最新修订版已删除的内容
这在 HotPDF 中意味着什么
引擎会为您解析混合引用文件,它在每个必须解析交叉引用数据的路径上都会执行此操作。使用 LoadFromFile 或 LoadFromStream 加载文档,进行更改,然后调用 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 后的数据,混合文件会显示一段简短的经典尾部,其尾部字典包含 /XRefStm。或者将完整解析报告返回的对象数与尾部中 /Size 声明的最大对象编号对照,若差值很大,说明一些对象只存在于流里而未被加载器打开,这会在保存阶段放大成失败
这组检查在典型 Excel 导出的尾部也很容易验证。读取该尾部后看到的内容通常是纯 ASCII,所以这个签名是直接可见的(仅为说明,偏移量示例)
xref
0 0 % empty classic subsection: no rows at all
trailer
<< /Size 216 % one past the highest object number in use
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % byte offset of the cross-reference stream
>>
startxref
88710 % points at the classic section above
%%EOF
0 0 子节头是关键特征。典型结构里,经典表只保留到长度为零的短段,这段表存在的作用就是承载尾部,而尾部的关键是 /XRefStm 87325。只检测到 xref 并停下的加载器,仅见到这个“空表”就会误以为索引不存在
// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // the trailer lives in the tail
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // bounded backward read: 2 KB max
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := Pos(AnsiString('/XRefStm'), Tail);
if P = 0 then
Exit; // no hybrid marker in the tail
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // skip whitespace after the key
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybrid-reference file: resave will need the /XRefStm section');
更稳妥的做法可以用上尾部扫描函数,先从文件尾往前固定读取一小段并提取 /XRefStm 偏移量。把它当筛选器使用而不是完整解析器:它只告诉你哪些文件在重保存前应被优先复核。真正要做的是按文件偏移顺着 /XRefStm 的链条读取、合并、并在合并时保持空闲项规则,这一链路在 处理 Office 应用导出混合引用 PDF 的配套文章 中有逐步演示
文件作者侧的对象流与增量更新机制见 对象流与增量更新,对于超大混合文件,大型 PDF 工作流的 Direct File API 文档演示了不全量读入内存的检查方式。这些能力都随 HotPDF Component for Delphi 与 C++Builder 一起交付,覆盖加载、编辑、加密和签名 API