从 Microsoft Word 或 Excel 用"另存为 PDF"导出一份文档,磁盘上的文件十有八九是一个混合引用文件。它把交叉引用信息携带两遍:一遍是直到 1.4 版本每个 PDF 末尾都有的那种经典定宽表,另一遍是文档大部分真正依赖的压缩交叉引用流。一个单独的 trailer 键 /XRefStm 把两个视图缝合在一起,而一个工具是否看见整个文档,归结为它是否跟随那个键
本文从消费侧看混合文件:文件末尾的字节长什么样、两个视图在编辑下如何漂移分开、以及一条 Delphi 流水线如何检测并路由混合输入。加载器如何合并视图、以及为何顺序不可协商,是我们 HotPDF 关于加载混合引用文件的文章的主题;本文则是关于首先识别出这种布局
为何 Office 导出把索引写两遍
PDF 1.5 引入了两项改变了文件形状的功能:交叉引用流,把对象索引作为压缩二进制数据存储而非明文表;以及对象流,把许多小对象打包进一个 Flate 压缩的容器。使用它们的写入器产出更小的文件,但一个 PDF 1.4 阅读器打不开结果,因为它键控的结构——xref 关键字和 trailer 字典——不见了
ISO 32000-1 §7.5.8.4 定义了折中。一个混合引用文件两者都写:一张经典交叉引用表,寻址一个旧阅读器必须到达的对象——目录和页面树在其中;以及一个交叉引用流,为其余一切建立索引。折叠进对象流的对象在经典表中被标记为空闲,所以一个 1.4 阅读器毫无怨言地跳过它们;它们的真实位置只存在于流中。经典 trailer 随后携带一个 /XRefStm 键,持有该流的字节偏移。旧查看器从不读这个键,从表视图渲染文件。现代查看器跟随它并看见完整文档。Word 和 Excel 多年来一直发出恰好这种布局,这就是为何混合文件不是一个猎奇的边角案例,而是业务流水线接收到的一大份额
一个混合文件的尾部长什么样
这种布局从字节最容易理解。下面是一个小型混合文件的尾部,偏移已缩短;在一个真实的 Office 导出中 /XRefStm 的值通常是靠近文件末尾的一个大偏移。阅读顺序是我们 PDF 文件结构概述中描述的那种从尾部开始的走法:找 %%EOF、读 startxref、跳到表
% ... 正文对象,包括对象流,以及位于字节 116 处的
% 交叉引用流(带有 /Type /XRef 的流对象)...
xref % 经典节:startxref 指向的内容
0 4
0000000000 65535 f % 槽位 0:空闲列表头部,始终存在
0000000017 00000 n % 对象 1:目录,任何阅读器都可见
0000000000 65535 f % 对象 2:标记为空闲——位于对象流中
0000000000 65535 f % 对象 3:同样如此;只有流视图能定位它
trailer
<<
/Size 4
/Root 1 0 R
/XRefStm 116 % 交叉引用流的字节偏移
>>
startxref
7164 % 上方 'xref' 关键字的字节偏移
%%EOF
这个转储中有两处细节承载了整个机制。第一,startxref 故意指向经典小节:那是一个旧阅读器必须落地的地址。交叉引用流只能通过 trailer 字典内部的 /XRefStm 键到达,所以一个从不寻找那个键的解析器永远学不到该流存在。第二,对象 2 和 3 是一种良性谎言。经典表声明它们空闲,但它们是坐在一个压缩容器内部的真实对象;空闲标记是让一个 1.4 阅读器不在它无法使用的条目上绊倒的东西。一个只信任经典视图的消费者得出结论:这份文档的大部分不存在
两个视图如何漂移分开
一个刚从 Word 出来的混合文件是内部一致的:两个视图在各自声明的范围内描述同一份文档。麻烦始于文件被一个只理解其中一个视图的工具编辑。考虑一个追加经典式增量更新的盖戳工具:新对象、一个新的 xref 小节、一条指向前一小节的 /Prev 链,以及一个新的 trailer。如果那个 trailer 丢掉 /XRefStm 键,流视图就被孤立;如果它把旧值向前复制,流视图仍然把文档描述成编辑前的样子。无论哪种,两个索引现在对文件包含什么各执一词
结果文件有一种独特的失败签名:在一个视图中可见的对象在另一个中缺失或过时。一个通过流视图解析的阅读器找到一个已更新对象的编辑前版本,或对一个追加的对象根本没有条目。一个在表视图上的阅读者看到编辑,却丢失了只有流才能定位的压缩对象的踪迹。在实践中这浮现为一个表单字段在一个查看器中存活、在另一个中消失,一次盖戳过程似乎已删除的注解,或完全落到错误对象上的查找
让这些文件调试起来昂贵的是,Adobe Acrobat 通常毫无怨言地打开它们:当索引与字节不一致时,它悄悄地通过扫描对象头来重建交叉引用数据,所以无论谁产出了损坏的文件都看不到任何问题。失败在文件到达一个严格消费者——一个 preflight 验证器、一个签名服务、一个归档摄入任务——之后才浮现,后者信任声明的结构并报告缺失对象或交叉引用不匹配。"它在 Acrobat 里打开没问题"几乎就是每张混合去同步工单的开场白
在纯 Delphi 中检测一个混合文件
对输入分类不需要一个 PDF 库。/XRefStm 键只能出现在一个经典 trailer 字典内部,而活动 trailer 位于文件的最后几千字节之内,因为规范要求 %%EOF 出现在物理末尾附近。读一个有界的尾部窗口并搜索它,对分诊就够了:
uses
System.SysUtils, System.Classes, System.StrUtils, System.Math;
function IsHybridReferencePdf(const FileName: string): Boolean;
const
TailWindow = 2048;
var
Stream: TFileStream;
Buf: TBytes;
Tail: string;
Len, TrailerPos, NextPos, KeyPos, StartXrefPos: Integer;
begin
Result := False;
Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if Stream.Size < 48 then
Exit;
Len := Min(TailWindow, Integer(Stream.Size));
SetLength(Buf, Len);
Stream.Position := Stream.Size - Len;
Stream.ReadBuffer(Buf[0], Len);
finally
Stream.Free;
end;
// 涉及的所有关键字都是 7 位 ASCII,因此按字节解码是安全的
Tail := TEncoding.ANSI.GetString(Buf);
// 找到最后一个 'trailer' 关键字:随着增量更新,
// 最新的 trailer 才是支配该文件的那个
TrailerPos := 0;
NextPos := Pos('trailer', Tail);
while NextPos > 0 do
begin
TrailerPos := NextPos;
NextPos := PosEx('trailer', Tail, NextPos + 1);
end;
if TrailerPos = 0 then
Exit; // 没有经典 trailer:是纯 xref 流文件,不是混合文件
// 混合 trailer 在 'trailer' 和 'startxref' 之间携带 /XRefStm
KeyPos := PosEx('/XRefStm', Tail, TrailerPos);
StartXrefPos := PosEx('startxref', Tail, TrailerPos);
Result := (KeyPos > 0) and
((StartXrefPos = 0) or (KeyPos < StartXrefPos));
end;
三种结果与三种布局对齐。一个纯经典文件有 trailer 但没有 /XRefStm:False。一个完全投向交叉引用流的文件根本没有 trailer 关键字,它的 trailer 键住在流字典中:也是 False,正确地,因为这样的文件是压缩的而非混合的。只有双索引布局返回 True
对于生产用途,两处加固值得多写几行。解析 /XRefStm 之后的整数,寻址到那个偏移,并确认那里确实坐着一个带 /Type /XRef 的流对象;一个被截断的文件可以携带该键而流已不在,这属于一个与健康的混合不同的桶。而把窗口大小当作一个参数:2 KB 覆盖普通的 Office 输出,但一个异常大的 trailer 字典可以把关键字推出范围,拓宽窗口胜过意外地宣布文件是经典的
通过一条 Delphi 流水线路由混合文件
检测买给你一个路由决策。对于只读、只渲染或只校验的文件,用一个解析两个视图的加载器,然后校验行为而非字节。PDFium Component 在加载期间解析 /XRefStm 链,所以你的代码看到的对象表是合并后的那张,而我们关于校验对象与交叉引用流的文章中描述的检查原样适用。如果一个去同步的混合损坏到拒绝加载,引擎通过其错误集报告它——FPDF_ERR_SUCCESS、FPDF_ERR_UNKNOWN、FPDF_ERR_FILE、FPDF_ERR_FORMAT、FPDF_ERR_PASSWORD、FPDF_ERR_SECURITY 和 FPDF_ERR_PAGE——其中 FPDF_ERR_FORMAT 是结构损坏产出的那个。不过别依赖那个信号:PDFium 按设计宽容并悄悄重建大多数不一致的文件,所以一次成功的加载证明的是文件可恢复,而非它的两个视图一致。有意义的完整性检查是把一次完整对象遍历找到的东西与 trailer 的 /Size 声明相比较
对于你的流水线要修改的文件,最安全的策略是让它们根本不是混合的。一次加载后经由 HotPDF 的完整保存,用单一、自洽的交叉引用以一种形式重写文档:没有 /XRefStm,没有会失去同步的第二视图,每个对象都确切归一个索引条目所有。那种归一化正是你在归档摄入之前、在一个严格的下游 RIP 或签名服务之前,以及在把任何编辑应用到混合输入之后想要的。它有效是因为加载器在进入时正确合并了视图——那正是 HotPDF 混合引用文章详细走通的机制
唯一该保持不动的一类文件是数字签名文档。一次完整重写移动每个字节,这会使任何在原始范围上计算的签名失效。对签名混合的更改必须作为一次维持两个视图的正当增量更新进入;一个只需要读取的文件应原封不动地通过。归一化是给你拥有的文件的;签名文件你只追加
混合引用 PDF 并非格式错误;它们是格式自身的兼容桥梁,而且只要 PDF 1.4 阅读器还在装机量中存活,Office 应用程序就会继续生产它们。一条能发现 /XRefStm 键、用 PDFium Component 校验合并后的文档、并用 HotPDF Delphi Component 重生成干净的单索引输出的流水线,把它们当作它们本来所是的东西对待:带一个额外路标在 trailer 中的普通输入