Technical Article

验证压缩的 PDF:对象和交叉引用 (XRef) 流

您编写了一个微型的验证器。它打开 PDF、寻找到末尾、找到 startxref、读取偏移量,并期望落在关键字 xref 上,且在其下方有固定宽度的交叉引用表。它从该表中收集对象偏移量,然后向后扫描以查找 trailer 关键字以了解 /Root/Size。它在您生成用来测试它的每个文件上都工作得完美无瑕。然后,一个由当前版本的 Word 或针对 PDF 1.5 的库生成的文件到达了,验证器宣布它已损坏。在偏移量指向的地方没有 xref 关键字,在任何地方都没有 trailer 字典,并且验证器构建的对象表几乎是空的。文件是有效的。验证器在通过十五年前的透镜读取它。

这是针对经典布局编写的字节级 PDF 检查在现代文档上失败的单一最常见原因。它所依赖的结构(明文交叉引用表和 trailer 关键字)在 PDF 1.5 中被设为可选,并且经常不存在。有两个功能取代了它:交叉引用流和压缩的对象流。两者都在 ISO 32000-1 中有描述,不知道它们的验证器会将健康的文件视为一堆缺失的对象。

PDF 1.5 对文件尾部做出的更改

ISO 32000-1 第 7.5.8 节定义了交叉引用流,第 7.5.7 节定义了 /ObjStm 类型的对象流。它们一起允许写入器丢弃经典解析器赖以定位的两个结构。PDF 1.5 文件根本可能没有以 xref 表结束。取而代之的是,startxref 指向的对象是一个普通的流对象,其字典携带 /Type /XRef,该流以紧凑的二进制形式保存交叉引用数据。也没有 trailer 关键字,因为尾部现在是流自身的字典。经典解析器寻找的键 /Root/Size/ID 就存放在该字典内部。

第二个更改移动了对象本身。写入器不再以其自身的字节偏移量写入每个间接对象,而是可以将许多小对象(页面字典、批注字典、结构树)打包到单个对象流中,并使用 Flate 压缩整个容器。单个对象在文件中不再具有字节偏移量。它们具有在压缩二进制大对象内部的位置。扫描原始字节以查找 1 0 obj 的验证器永远找不到它们,因为该文本只有在解压后才存在。对于经典解析器,半个文档简直就是凭空消失了。

即使在压缩文件中,尾部键也是明文

令人放心的是,读取交叉引用流的尾部不需要解压任何内容。流对象在写入时作为字典,后跟 stream 关键字以及随后的压缩字节。字典是明文。因此,当 startxref 指向交叉引用流时,紧跟在对象数字后面的字节看起来像普通的字典,并且 /Root/Size/ID 清晰地呈现在那里,在 stream 关键字和 Flate 数据开始之前。

这意味着验证器通过仅解析流字典,就可以了解它最需要的三个事实:目录在哪里、文件声称有多少个对象,以及文件标识符。它不需要解压缩交叉引用数据,也不必解释其中的二进制条目。击败幼稚解析器的工作不是读取尾部,而是查找对象。这是两个可分离的问题,解决第一个问题开销低。

对象流:头部,然后是 Flate 二进制大对象

对象流是一个容器。它的字典携带 /Type /ObjStm,给出了打包在内部的对象数量的 /N 条目,以及给出了在解压数据中第一个对象体开始的字节偏移量的 /First 条目。压缩的有效载荷一旦解压,就以一个由 /N 个整数对组成的小头部开始。每对是一个对象数字和该对象体相对于 /First 的偏移量。头部之后是连接在一起的对象体本身。

一旦字节被解压,展开一个就是机械式的。您读取字典以获取 /N/First,使用 Flate 解码器解压流,遍历前导 /N 对以了解哪个对象数字存放在哪个偏移量,然后像它是普通间接对象一样将每个主体抬起出来。唯一真正的依赖项是 Flate 解码器,而您已经有了一个:Delphi 附带了 System.ZLib,而 Free Pascal 附带了 zstream 单元,它们都包装了 zlib,无需任何第三方代码即可解压原始的 Flate 流。将每个提取的对象追加到验证器对象表中的例程使验证器的其余部分(遍历 /Root 并检查页面树的部分)表现得完全与在经典文件上相同。

您不必实现的内容

工作量很容易被高估。从压缩文件中读取尾部键不需要解码交叉引用流的二进制条目。第 7.5.8 节交叉引用流使用三种条目类型,而类型 2 条目(即说明“此对象存放在索引为 i 的对象流 N 内部”的条目)正是您为构建完整偏移图而要解码的。您需要该图来按数字解析任意对象。您不需要它来读取在明文字典中的 /Root/Size/ID,并且您不需要它来展开对象流,因为每个 /ObjStm 都通过 /N/First 宣告它自身的内容。

您也不需要处理仅仅为了获取尾部键,交叉引用流可能通过其 /DecodeParms 应用的 PNG 和 TIFF 预测器函数。预测器对二进制交叉引用行进行过滤以使其能更好地压缩;它们与流前面的字典没有任何关系。因此,使经典验证器具有感知现代 PDF 能力的最小升级非常小:当 startxref 落在流上而不是 xref 关键字上时,解析流字典以获取尾部键,并展开您遇到的任何 /ObjStm 对象,以便它们的内容进入对象表。解码类型 2 条目和预测器是一项独立、更大型的任务,您可以推迟到真正需要随机对象解析时再做。

为什么合规性检查必须首先展开流

在您运行规范检查的瞬间,这就变得不再是纯理论的了。PDF/A 或 PDF/X 验证器检查特定对象:文档目录以寻找 /OutputIntents 数组,以寻找具有正确标识符 of XMP 数据包的 /Metadata 流,每个字体描述符以寻找嵌入的字体文件,以及尾部以寻找 /ID。在压缩文件中,大多数这些对象都在对象流内部。未展开对象流 of 验证器看不到目录的键、找不到元数据,也无法枚举字体。它将报告完全符合规范的文档缺少其输出意图、缺少其 XMP 且缺少其一半结构,因为由于它从未对其进行解压,它需要的证据仍保留在 Flate 二进制大对象内部。

顺序非常重要。展开必须在检查运行之前发生,而不是与它们并行,因为每次检查都假设它可以按数字到达对象。如果您将规范检查直接连接到原始字节扫描上,它就会继承经典解析器的盲目性,并在极有可能是良构的现代文件上产生错误的冲突,因为它们来自足够新以至于从一开始就能写入交叉引用流的工具链。

让 PDFium 为您完成解析

PDFium 组件在加载文档的过程中会解析交叉引用流和对象流,这是避免手动编写解压和展开步骤的实用方法。当您使用 TPdf 组件加载文件时,打包在 /ObjStm 容器中的对象就已经被解析,并且验证入口点看到的是完全展开的文档。ValidatePdfA 返回一个 TPdfAValidationResult 记录,其 Conformance 字段是 TPdfAConformance 值(例如 pac1bpacNone),其 Issues 字段是发现的特定问题集合,且其 IsCompliant 方法仅在检测到一致性级别且问题集合为空时才为 true。因为对象在加载过程中被展开,所以能找到存放在对象流内部的 /OutputIntents 数组或嵌入的字体,而不是被报告丢失。

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

这也适用于返回具有相同形状的 TPdfXValidationResultValidatePdfX。通过 PDFium 进行路由的关键在于,上面描述的结构解压缩在加载器内部正确地发生了一次,因此您的验证代码永远看不到经典文件与完全压缩文件之间的区别。两者都作为解析后的对象集合到达验证器。

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

如果字节已经在内存中而不是在磁盘上,那么相同的加载后验证序列可以通过 LoadDocument(const Data: TBytes) 重载起作用,它接受原始文件内容并以与文件路径相同的方式解析其交叉引用和对象流。手写验证器的重点是结构性规则,而不是 API:从明文中的流字典中读取尾部键,在遍历文档之前使用 Flate 解码器展开每个 /ObjStm,并将解码二进制交叉引用条目视为更大的、可选的任务。

一旦结构展开,验证器就可以在其之上驱动其余的工作流。对于报告跨文件夹输入的合规性的命令行预检工具,请参见我们关于构建批量预检报告 CLI 的演练。当验证是拆分大型文档之前的关卡时,我们拆分 PDF 文档为多个文件的指南中的技术自然与此处显示的加载 and 检查模式相配套。两者都基于适用于 Delphi 和 C++Builder 的 PDFium 组件的加载和验证界面构建。