跳转到内容

PDF 究竟是什么

Evidence: Standard-backed

PDF 并不只是碰巧放在文件里的页面描述。它更像一个自带打印机的小型图数据库。本页说明每个 PDF 都具备的四个部分——header、body、交叉引用表、trailer——以及 NextPDF 如何写入它们,让读取器无需猜测就能找到每个对象。

大多数 PDF 错误并不是渲染错误,而是结构错误:某个字节偏移量相对于应指向的对象偏了一个字符、某个 trailer 指向了错误的 root,或者某条交叉引用条目与对象的实际位置不一致。在读取器沿着文件中的另一条路径前进并越过文件结尾之前,这些问题都不会改变页面外观。

如果你把 PDF 当成不透明的黑箱,这类失败看起来就像随机发生。如果你理解对象模型,它们就会显出本质:一个与位置不匹配的数字。读懂这个格式,正是「这个 PDF 损坏了」与「对象 14 的偏移量已过时,因为写入器在流长度确定之前就测量了它」之间的差别。

按文件顺序,一个 PDF 有四个部分:

  1. 一个 header——标明版本的一行(%PDF-2.0)。
  2. 一个 body——一系列带编号的间接对象:字典、流、数组、数字、字符串、名称。
  3. 一个 交叉引用表(或者,在 PDF 2.0 中是交叉引用)——一个从对象编号到字节偏移量的映射,让读取器无需扫描整个文件就能取得任意对象。
  4. 一个 trailer——一个小型字典,标明文件的 root 对象,并指向交叉引用区段的起始位置。

读取器不会从头到尾读取 PDF。它会先读取最后一行,找到 startxref,跳到交叉引用区段,并把它作为进入 body 的索引。这个格式天生就是按反向读取设计的。仅这一点,就能解释它的大部分设计。

NextPDF 按照这种格式的读取方式来构建 PDF:先写入对象,再记录偏移量,最后写入表格。

每个间接对象都由单一注册表(src/Core/ObjectRegistry.php)分配编号。该注册表通过 allocate() 发出连续编号,并在对象的字节被写入输出缓冲区之后,通过 register() 记录字节偏移量。偏移量绝不会被预先猜测。它们是在对象 header 被发出的那一刻,从 BinaryBuffer::getOffset() 观测到的。这就是 NextPDF 的交叉引用条目不会偏离其所描述对象的原因:偏移量就是缓冲区当时实际所在的位置。

body 完成后,一个特定于版本的序列化策略src/Writer/PdfSerializationStrategy.php)会写入交叉引用区段与 trailer:

  • Pdf20StreamStrategy 会发出一个压缩的交叉引用/Type /XRef)——这是 PDF 2.0 的默认。
  • Pdf17TableStrategyPdf14TableStrategy 会发出一个传统的 20 字节交叉引用表格,再加上一个独立的 trailer 字典——这是需要采用较旧文件结构的 PDF/A 规范所必需的。

该策略由输出规范选定,而不是推断出来的。无论采用哪一种,最终字节的形状都相同:交叉引用区段,接着是 startxref,接着是字节偏移量,最后是 %%EOF。这段结尾正是读取器最先找到的部分。

  1. Step 1 of 4: ISO 32000-2 §7.5.5 — %%EOF and startxref at the file end
  2. Step 2 of 4: ISO 32000-2 §7.5.4 / §7.5.8 — the cross-reference section maps object number to offset
  3. Step 3 of 4: ISO 32000-2 §7.5.5 — the trailer names /Root, the document catalog
  4. Step 4 of 4: ISO 32000-2 §7.3.10 — each indirect object is reached at its recorded offset
读取器如何在 NextPDF 文件中解析一个对象,以及定义每个步骤的 ISO 32000-2 条款:它从文件结尾开始,向内推进。

这个四部分结构并不是 NextPDF 的惯例;它正是 Spec: ISO 32000-2, §7.5 规定的文件结构。该标准将 PDF 定义为一个 header、一个对象 body、一个交叉引用表和一个 trailer,并指出读取器应从文件结尾开始解析。最后一行是 %%EOF,它前面的两行则是 startxref 关键字,以及指向交叉引用区段的字节偏移量。

Evidence: Standard-backed

一个 间接对象 被定义为一个对象编号与一个生成号(generation number),两者以空白分隔,后面跟着由关键字 objendobj 包住的对象值。对象编号与生成号的组合可唯一识别该对象;对它的一个间接引用写法为对象编号、生成号,以及关键字 R。NextPDF 的 ObjectRegistry 精确地对应了这一点:一个连续编号、新写入对象的生成号 0,以及一个记录下来的偏移量。

从 PDF 1.5 起,对象也可以存在于 对象流 之中;在这种情况下,它们的存储形式不带 obj/endobj 关键字,且生成号必须为零。交叉引用/Type /XRef Spec: ISO 32000-2, §7.5.8 )是 PDF 2.0 同时索引普通对象与这些压缩对象的机制。 NextPDF 的 CrossReferenceStream 以一个 /W 字段宽度数组与 FlateDecode 压缩来构建它。

下面是一个最小 PDF body 及其 trailer 的形状。交叉引用区段中的数字就是字节偏移量。它们必须完全正确,因此 NextPDF 会从缓冲区记录它们,而不是计算它们。

%PDF-2.0
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000122 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
196
%%EOF

读取器从底部打开这个文件:先是 %%EOF,接着是 startxref 196,然后定位到 xref 起始处的字节 196,读出对象 1 位于字节 9,沿着 /Root 1 0 R 找到 catalog,并从那里遍历页面树。对象 0 永远是空闲列表(free-list)的开头,其生成号为 65535——这是继承自这个格式最早期设计的一个怪癖;读取器预期它存在,所以它被忠实地重现。

这个陷阱在于,人们会以为 PDF 像源代码一样从上到下读取。事实并非如此。body 中的对象可以任意排序。对象编号不必在文件中连续,读取器也从不依赖它们连续。唯一具有权威性的索引是交叉引用区段,而找到它的唯一方法是查看文件结尾的 trailer。一个 body 完全有效、却在 startxref 中只有一个数字错误的 PDF,是无法读取的。一个对象写入顺序杂乱、但交叉引用表正确的 PDF,则完全没有问题。位置本身毫无意义;被记录下来的位置才是一切。

本页说明的是文件结构,而不是页面内容。标记如何被放到页面上——内容流、绘图运算符、文字显示——属于另一个独立主题。它也不涵盖文件写入后被更改时会发生什么。那是增量更新(incremental updates)的职责:附加第二个交叉引用区段,并让 trailer 向后串接。

NextPDF 是一个写入器。这里描述的是它如何序列化自己构建的文件。它不是通用的 PDF 解析器或修复工具,也不承诺读取、重建或抢救任何交叉引用表受损的第三方文件。这个保证范围很窄,而且是有意如此。NextPDF 写出的文件,其偏移量都能匹配,因为这些偏移量来自测量,而不是预测。

既然新文件永远使用 0,为什么还需要生成号? 生成号用于在多次更新之间复用对象。一个刚写好的文件,其每个对象的生成号都是 0。只有在文件被增量更新且某个对象编号被回收再利用时,才会出现非零生成号。

两个对象可以拥有相同的编号吗? 在单一交叉引用区段内,不行。跨越多次增量更新时,一个文件在物理上可以包含同一对象编号的多份副本。最新的交叉引用条目胜出。这是下一页的主题。

文件中的对象顺序对输出有影响吗? 没有。NextPDF 为了可重现构建,会按确定性顺序写入对象;但读取器通过交叉引用区段解析一切,因此物理顺序在语义上没有意义。

  • 间接对象——body 中带编号的对象,写作 N G obj … endobj,其中 N 是对象编号,G 是生成号。
  • 间接引用——指向一个间接对象的指针,写作 N G R
  • 交叉引用表(xref)——从对象编号到字节偏移量的索引。在 PDF 2.0 中,这通常是一个交叉引用流/Type /XRef),而不是每条条目占 20 字节的经典文本表格。
  • Trailer——交叉引用区段结尾处的字典,标明 /Root(文件 catalog)与 /Size,并通过 startxref 偏移量被找到。
  • 对象流——一个本身包含其他间接对象(一起压缩)的流对象;其成员不带 obj/endobj,且生成号为零。
  • 文件 catalog——由 /Root 指定的对象;通往页面树及文件中其他所有内容的进入点。