技术文章

校验压缩 PDF:对象流与 XRef 流

你编写了一个小型校验器。它打开 PDF 后定位文件结尾,找到 startxref,读取偏移量,并期望在该位置发现 xref 关键字,紧跟一张固定宽度交叉引用表。它从这张表构建对象偏移,再向前扫描 trailer 以读取 /Root/Size。在你用于测试的传统文件上它工作正常,但当一个由新版 Word 或面向 PDF 1.5 的库生成的文件出现时,校验器会报错为无效文件。偏移量指向处既没有 xref,也找不到 trailer 字典,构建出的对象表几乎为空。文件本身是合法的,问题在于校验器使用了 15 年前的解析路径

这是现代文档在“按字节级别”经典布局检查下失败的主要原因。它依赖的结构——明文交叉引用表和 trailer 关键字——在 PDF 1.5 中可选化并经常缺失。随后出现的两项替代机制是交叉引用流与压缩对象流,两个机制都在 ISO 32000-1 中定义,若校验器不支持它们,合法文档会被误判为对象缺失

PDF 1.5 对文件尾部的变化

ISO 32000-1 §7.5.8 定义了交叉引用流,§7.5.7 定义了类型为 /ObjStm 的对象流。两者一起允许 PDF 写入器不再必须提供经典解析器依赖的两个结构。PDF 1.5 文件可能不再以 xref 表结尾。此时 startxref 指向的对象通常是普通流对象,其字典含有 /Type /XRef,流体内以紧凑二进制形式保存交叉引用数据。由于尾部不再使用 trailer 关键字,尾部字典本身即为该流对象的一部分,而 /Root/Size/ID 等键都位于该字典中

另一项变化是对象存储方式。写入器不再为每个间接对象保留独立偏移,而是可以把多个小对象打包进同一个对象流,例如页字典、批注字典和结构树,并通过 Flate 压缩。对象在文件中的定位不再是原始偏移,取而代之是压缩内容中的位置。若校验器直接在原始字节中搜索 1 0 obj,就会找不到这些对象,因为它们只有在解压后才可见。对经典解析器来说,这意味着文档一大半“消失”

压缩文件中的 trailer 键仍是明文

好消息是,读取交叉引用流的 trailer 无需解压。流对象写入时总是先有字典,再有 stream 关键字和压缩字节。字典部分是明文。故当 startxref 指向 xref 流时,它后续字节看起来仍是普通字典文本,在该字典中可直接读取 /Root/Size/ID,在 stream 关键字之前就能拿到完整信息

也就是说,校验器在读取 trailer 时无需解码交叉引用二进制行即可拿到文件目录与对象总数等关键信息。真正困难的是定位并展开对象本体,这两部分可以独立处理;先处理 trailer 可明显降低复杂度

对象流的结构:头部加 Flate 数据

对象流本身是一个容器,其字典包含 /Type /ObjStm/N 表示打包对象数量,/First 表示展开后对象体起始相对偏移。Flate 解压后的流以 /N 个整数对做头部:每一对记录对象号及该对象在头部之后的起始偏移。随后是对象体按顺序拼接

展开对象流并不复杂:读取字典里的 /N/First 后用 Flate 解码器解压,再遍历头部的 /N 对,重建对象号与正文偏移映射,并按普通间接对象提取正文。这里的核心依赖只有 Flate 解码器。Delphi 可直接使用 System.ZLib,Free Pascal 可使用 zstream,都可处理原始 Flate 数据无需第三方组件。把解压后的每个对象加入校验器对象表后,其余逻辑(如遍历 /Root 与页树)就可沿用经典流程

你无需实现的部分

一个常见误区是夸大工作量。仅为读取 trailer 键并不需要解码交叉引用流的二进制条目。§7.5.8 中的 xref 流有三种条目类型,其中类型 2 是“对象在对象流 N 的第 i 项”,只有在你要完整按对象号随机定位时才需要解析。校验 trailer 的场景只依赖字典里的 /Root/Size/ID,因此不需要解码类型 2 条目,而展开对象流也不依赖它,因为每个 /ObjStm 通过 /N/First 自描述了自己的对象列表

你也不需要为读取 trailer 键去实现 PNG / TIFF predictor 的处理。交叉引用流可能在 /DecodeParms 中声明 predictor,但那是对二进制表格压缩的滤波策略,与流前面的字典无关。使校验器兼容现代 PDF 的最小改动是:当 startxref 命中的是流而非 xref 关键字时,解析该流字典并获取 trailer 键,再展开扫描到的每个 /ObjStm,将其内容写入对象表。类型 2 条目与 predictor 可以后移到确实需要随机对象解析时再处理

为什么合规检查必须先展开对象流

当你做合规性校验时,这不再是抽象问题。PDF/A、PDF/X 校验器会读取特定对象,例如目录下的 /OutputIntents/Metadata 的 XMP 包、字体描述器里的嵌入字体文件、以及 trailer 中的 /ID。在压缩文档中这些内容大多在对象流内部。未展开对象流时,校验器无法读取目录、元数据,也无法枚举字体,因而会把有效文件误报为缺少输出意图、缺少 XMP 或缺少结构信息。实际上相关证据仍保留在未解压的 Flate 流中

顺序很关键。展开必须在合规检查之前进行,而不是与检查并行。每个检查逻辑都默认可按对象号直接访问对象,若直接在原始字节扫描下运行,必然继承经典解析器盲点,并对仍使用交叉引用流的现代文件报出误差

让 PDFium 处理解析细节

PDFium 组件在文档加载阶段会解析 xref 流和对象流,因此可避免你手工重复解压与展开。加载文档时,/ObjStm 中打包对象会被解析为完整对象表,验证入口可直接使用扩展后的文档。ValidatePdfA 返回一个 TPdfAValidationResult,其中 ConformanceTPdfAConformance 值,如 pac1bpacNoneIssues 是问题集合,IsCompliant 仅在识别到合规级别且问题集合为空时为真。对象展开发生在加载阶段,因此嵌在对象流中的 /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;

ValidatePdfX 采用同一模式,返回结构为 TPdfXValidationResult。通过 PDFium 路由的优势是结构解压与对象展开只在加载器中完成一次且正确执行,后续验证逻辑看到的是一致的对象模型,经典文件与完全压缩文件在验证层面不再有差异

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

若字节已在内存中,可使用 LoadDocument(const Data: TBytes) 重载进行同样的加载-校验流程,这一路径接收原始文件内容并以与文件路径相同方式解析 xref 与对象流。对手写校验器来说,关键结构是:先从明文 trailer 字典读取关键键,再在遍历文档前扩展每个 /ObjStm,把解析交叉引用二进制条目作为更大且可选的后续任务

对象结构展开后,校验器可继续执行完整工作流。对于命令行预检场景,可参考 构建批量预检报告 CLI。如果需要在拆分大型文档前进行验证,可结合 PDF 文档拆分指南 使用同一套加载与校验入口。这两者都基于 PDFium Component 提供的 Delphi 与 C++Builder 接口

`r`n`r`n