打开一份 Microsoft Word 或 Excel 产出的 PDF,翻上几页,看不出任何异样。装进 Delphi 程序读回页数,数字也对。然后开着加密重新保存它,保存这一步抛异常,或者输出打开时弹交叉引用损坏警告。文件从来就没坏过。它是一份混合引用(hybrid-reference)文件,让十五年前的阅读器也能打开它的那个结构,正是让一个过早停止读取的加载器栽跟头的那个结构
这是通过所有内部测试的 PDF 管道遇到无法往返的文件的最常见方式之一。输入都是内部生成的,因此它们从来都不是混合的。第一个混合文件的到来通常是客户转发从电子表格导出的发票的那一天
Word 和 Excel 实际上写了什么
ISO 32000-1 在 §7.5.8.4 中描述了混合引用布局。想要诸如对象流之类 PDF 1.5 功能、同时又让 PDF 1.4 读取器能打开文件的应用程序,会写入交叉引用信息两次。这里有一个经典的交叉引用表,即结束高达版本 1.4 的每一个 PDF 的固定宽度 ASCII 行,还有一个索引其余内容的交叉引用流。经典部分的尾部携带一个 /XRefStm 条目,其值为该流的字节偏移量
分工是刻意的。老阅读器必须够到的对象——catalog 和页面树在其中——从经典表就能寻址。被折进压缩对象流的对象在经典表里标成 free(type f 条目),1.4 阅读器因此直接跳过它们,绝不会被解析不了的结构绊倒。它们的真实位置只活在交叉引用流里。这类文件的签名在尾部:一小段经典节,只覆盖老阅读器需要的对象,其 trailer 指向放着真正恢复数据的 /XRefStm
/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'); // 页数是正确的
// 在这里检查或编辑已加载的文档
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // 会遍历每一个对象
finally
Pdf.Free;
end;
end;
由于设置了提早退出的检测器,加载看起来很好,但在重新保存时缺少的对象就会显现出来。修复方法不是在开始时读取更多字节;而是在决定文件结束之前识别混合尾部并跟踪 /XRefStm
合并顺序不容协商
一旦两个索引都被读取,它们就只能沿一个方向合并。必须首先合并交叉引用流,然后在它周围填充经典条目。原因是格式核心中的一个小骗局。混合文件在经典表中将其压缩对象标记为空闲,以便旧的读取器忽略它们。如果加载器遵循“首先看到的优先”策略并首先读取经典表,它将记录这些对象号为空闲,然后丢弃实际定位它们的流条目,因为这些槽已经被占用了。颠倒这个顺序,来自流的类型 2 条目(每个都是一个对象流编号加上一个索引),将赢得它们应该拥有的槽,而经典条目则在它们周围安顿下来
同样的规则可以防止较旧的版本恢复已删除的对象。增量更新通过 /Prev 向后链接,类型 0 空闲条目是一个哨兵,表示最近的部分已停用对象号。绝不能允许链中较晚、较旧的部分用过时的位置覆盖该哨兵。将首先看到的作为空闲标记的权威,已删除的对象就会保持删除状态;如果不小心处理,文件自身的历史将会恢复最新修订版已删除的内容
两个视图怎么走散的
以上一切的前提是两个索引彼此一致。Word 刚导出的文件确实一致:每个视图都在自己声明的范围内描述同一份文档。麻烦从文件经过一个只懂其中一种视图的工具开始。盖章(stamping)类工具是惯犯。有的追加一段经典风格的增量更新——几个新对象、一段新 xref 节、一条指回上一节的 /Prev 链接、一份新 trailer。那份 trailer 若丢了 /XRefStm,流视图就成了孤儿;若把旧值照抄过来,流视图描述的还是盖章之前的那份文档。无论哪种,两个索引从此各说各话
一旦知道要看什么,签名很好认:一个视图里可见的对象,在另一个视图里缺失或过期。经流解析的阅读器找到的是某对象更新前的旧版本,或者对追加对象干脆没有条目;只信表的阅读器看得到编辑,却丢掉所有只有流才能定位的压缩对象。用户的描述是:表单字段在一个查看器里有、在另一个里消失,注释像被盖章那一步删掉了,或者查找整个落错对象
Adobe Acrobat 是这类文件追起来这么费劲的原因。声明索引与字节对不上时,Acrobat 通常靠扫对象头悄悄重建交叉引用数据,于是造出坏文件的人打开它、看不出任何问题、原样转发。失败在更晚的地方、别的环节冒头——preflight 验证器、签名服务、归档入库作业——任何信声明结构、并且会报告对象缺失或交叉引用不匹配的消费方。“在 Acrobat 里开得好好的”几乎是每一张混合引用失步工单的开场白
这在 HotPDF 里意味着什么
引擎会为您解析混合引用文件,它在每个必须解析交叉引用数据的路径上都会执行此操作。使用 LoadFromFile 或 LoadFromStream 加载文档,进行更改,然后调用 SaveLoadedDocument;或运行一次性操作,例如 EncryptFile 读取输入并写入输出。无论哪种方式,恢复都会读取 /XRefStm,在经典条目之前合并流部分,并在写入枚举它们之前解析存在于流中的对象。AES-256 加密路径是问题最先显现的地方,因为加密文档会重写每个对象,从而要求已经定位了每个对象
// 一次性操作:读取混合输入,写出 AES-256 加密副本
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
值得带走的细节位于 API 的上游。从 Word、Excel、PowerPoint 和一长串“另存为 PDF”管道传来的文件通常是混合的,因此在测试中仅针对您自己的生成器输出进行练习的加载器可能永远不会遇到它们。不仅使用自己的代码生成的文件,还要使用从实际的 Office 应用程序导出的文档为测试数据播种
检查您怀疑的文件
进行两项检查可以迅速解决问题。十六进制视图里读取最后一个 startxref 后的数据,混合文件会显示一段简短的经典尾部,其尾部字典包含 /XRefStm。或者将完整解析报告返回的对象数与尾部中 /Size 声明的最大对象编号对照,若差值很大,说明一些对象只存在于流里而未被加载器打开,这会在保存阶段放大成失败
这组检查在典型 Excel 导出的尾部也很容易验证。最后一个 xref 关键字之后的内容都是纯 ASCII,所以这个签名是直接可见的(仅为说明,偏移量示例)
xref
0 16 % 对象 0-15:老阅读器需要的部分
0000000000 65535 f
0000000017 00000 n
... % 还有 14 行;其余住在对象流里
trailer
<< /Size 216 % 已用最大对象号加一
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % 交叉引用流的字节偏移
>>
startxref
88710 % 指向上面的经典节
%%EOF
trailer 里的 /XRefStm 键就是那个破绽。它上面的经典节只列了文件所用 216 个对象号中的 16 个;凡被折进对象流的,要么不在表里,要么标成 free。停在 xref 关键字上的探测器的全部见识,到此为止。与其用眼睛瞄,不如写脚本查——标记就在文件最后几 KB 里,一次有界回读足矣。如果文件后来又增量更新过,尾部会不止一个 trailer,说了算的是最后一个
// 从文件尾部最新的 trailer 里取 /XRefStm 偏移,
// 标记不存在时返回 -1(文件不是混合引用,或根本不是 PDF)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P, Q: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // trailer 就住在尾部
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // 有界回读:最多 2 KB
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := 0; // 保留最后一个标记:增量更新
Q := Pos(AnsiString('/XRefStm'), Tail); // 增量更新会追加更新的 trailer
while Q > 0 do
begin
P := Q;
Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
end;
if P = 0 then
Exit; // 尾部没有混合引用标记
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');
把探测当分诊,别当解析器:它在重存作业开跑前告诉你批次里哪些文件值得看一眼,仅此而已
把它指向一个装满真实来稿的文件夹,文件会分成三组。纯经典文件尾部有 trailer 关键字但没有 /XRefStm,探测返回 -1。完全投靠交叉引用流的文件同样正确地返回 -1——它压根没有 trailer 关键字,trailer 键都住在流字典里。只有双索引布局才会给出偏移。如果你的分诊报告需要区分前两组,丢弃尾部前在同一片里搜一下 trailer;多调一次 Pos 就够
让探测无人值守地跑之前,在两处加护。偏移只是一句声称。Seek 过去,确认那里真坐着一个字典写着 /Type /XRef 的对象——损坏或截断的文件可以留着这个键,而它指名的流早已不在。那种文件该进另一个桶,与健康混合引用分开。这道检查在同样的文件处理上再加几行
// Offset 是 FindXRefStm 返回的值;FS 是以同样方式打开的
// TFileStream,Head 为 AnsiString(Min 来自 System.Math)
if Offset >= FS.Size then
Exit(False); // 键还在,流没了
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
(Pos(AnsiString('/Type/XRef'), Head) > 0); // PDF 语法允许两种写法
然后给 2 KB 窗口留成参数。两 KB 覆盖普通 Office 产出,但一条异常肥大的 trailer 字典能把标记推出窗外;放宽窗口,总好过把文件误判成经典
让混合引用文件在管道里走对路
探测要有回报,得能改变文件的下一站。下一站在哪,取决于管道接下来要对它做什么
只读、只渲染、只验证的文件,该交给能把两个视图都解析掉的加载器。PDFium Component 在加载时就解析 /XRefStm 链,你的代码看到的对象表已经是合并后的那份,验证对象流与交叉引用流一文里的检查照常适用。失步的混合引用若损坏到拒绝加载,引擎经加载错误码上报,结构性损坏对应的是 FPDF_ERR_FORMAT。加载成功是更弱的证据:PDFium 设计上宽容,多数不一致的文件它一声不吭就重建了,所以成功只证明文件可救,对两个视图是否一致只字未提。要回答那个问题,还是回到“完整对象遍历所得与 trailer 的 /Size 对不上”这条老路上来
管道要修改的文件是另一回事。我们在这件事上的偏好很直白:让它们彻底不再是混合引用。用 HotPDF 加载文档并完整保存,输出就只带一种形式、自洽的单一交叉引用:没有 /XRefStm,没有第二个视图等着走散,每个对象恰好归一个索引条目所有。归档入库之前、严格的下游 RIP 或签名服务之前、以及对混合引用输入动过任何编辑之后,你要的都是这个归一化。它完全依赖前文那套流优先的合并——完整保存只能写出加载时成功定位到的对象
已签名的文档是例外。完整重写会挪动每一个字节,让任何按原始字节范围算出的签名失效。对已签名的混合引用做改动,必须以一次合格的增量更新进入,同时保住两个视图同步;只需读取的已签名文件则应原样穿过管道
文件作者侧的对象流与增量更新机制见 对象流与增量更新,对于超大混合文件,大型 PDF 工作流的 Direct File API 文档演示了不全量读入内存的检查方式。这些能力都随 HotPDF Delphi Component for Delphi 与 C++Builder 一起交付,覆盖加载、编辑、加密和签名 API