PDF 文件的解剖结构
用纯文本编辑器打开任意一个 PDF,你看到的第一样东西会让人安心:一个 %PDF-1.x 或 %PDF-2.0 header。你看到的最后一样东西是 %%EOF。这两行之间的一切,都是一台用于按编号查找对象的整洁小机器。本页是一次剖析。我们打开文件,为每个器官命名,并展示它们如何彼此相连。
它是两个邻页的结构篇姊妹。PDF 究竟是什么把文件视为一张对象图;增量更新讲解它如何随时间增长。本页则紧贴字节——一个解析器会逐一走过的物理区域,按它们在磁盘上排列的顺序呈现。
为什么这很重要
标题为“为什么这很重要”的章节要使用一个 PDF,你几乎从不需要这些知识。你需要它的那一天,是某个 PDF 出问题的那一天。一个文件在某个查看器里能打开、在另一个里却不能;一个验证器报告“交叉引用表损坏”;一份已签署的文件突然无法通过验证。一旦你能读懂它的解剖结构,这些都不再是谜团。它们不过是一个不再与某个位置匹配的数字、一个放错地方的区域,或一段指向虚无的结尾。
了解布局,能把“这个 PDF 损坏了”变成一个你能据以行动的诊断结论。这正是对着一个黑箱耸耸肩,与精确指出那个撒谎的字节之间的差别。
精简版
标题为“精简版”的章节一个合规的 PDF 有四个物理部分,按以下文件顺序排列(Spec: ISO 32000-2, §7.5.1ISO 32000-2 §7.5.1):
- 一个 header——一行,
%PDF-2.0,标明版本。 - 一个 body——文件的主体:一连串带编号的间接对象。
- 一个 交叉引用区段——一个从对象编号到该对象所在字节偏移量的索引。经典 PDF 使用一个文本表格;PDF 2.0 使用一个压缩的 xref 流。
- 一个 trailer——一个标明进入点的小型字典,后面跟着
startxref、一个偏移量,以及%%EOF。
转折点在于:读取器并不从顶部开始。它从底部开始,读取 startxref 以找到索引,再用那个索引直接取得任意对象。文件是从前往后写入的,却是从后往前读取的。
NextPDF 如何处理它
标题为“NextPDF 如何处理它”的章节让我们按顺序走过这四个区域,把字节摆在眼前。
header 是一行。NextPDF 写入 %PDF-2.0,并按惯例再写一行由高位字节组成的注释,好让简陋的传输工具把文件当成二进制而非文本对待。正是这第二行,导致一个以纯文本方式打开的 PDF 会在版本号紧后面显示一小段乱码。
body 是文件内容所在之处。每个间接对象都由一个编号、一个生成号、关键字 obj、一个值,以及 endobj 组成(Spec: ISO 32000-2, §7.3.10ISO 32000-2 §7.3.10)。这个值是少数几种形态之一——但其中两种几乎承载了全部重量:
- 一个字典,
<< /Key value … >>,是一个从名称到值的映射。页面树、catalog、字体描述符:全都是字典。 - 一个流是一个字典,后面跟着
stream、一段任意字节的数据块,以及endstream。页面内容、内嵌字体和图像都是流,且几乎总是被压缩的。(它们的过滤器自成一个故事,讲述于流与过滤器。)
对象通过间接引用彼此指向——2 0 R 的意思是“对象 2,生成号 0”。正是这套接线,把一份扁平的对象列表变成了一张图。
交叉引用区段是大多数人从未正确想象过的部分。在经典形式中,它是纯文本:关键字 xref,接着是由固定宽度的 20 字节行组成的若干子区段(Spec: ISO 32000-2, §7.5.4ISO 32000-2 §7.5.4)。每一行都是一个十位数的字节偏移量、一个五位数的生成号,以及一个单字符标志——n 表示使用中,f 表示空闲——被填充到恰好二十字节,好让读取器仅凭算术就能跳转到任意条目。PDF 2.0 用一个交叉引用流取代了它:同样的索引,但以二进制形式压缩在一个标有 /Type /XRef 的流对象内部(Spec: ISO 32000-2, §7.5.8ISO 32000-2 §7.5.8)。更小,且能够描述被打包在对象流内部的对象。
trailer 是文件的目录(Spec: ISO 32000-2, §7.5.5ISO 32000-2 §7.5.5)。它标明 /Root——文件 catalog,即其他一切都从其悬挂而下的那个唯一对象——以及 /Size,即对象计数。接着是让反向读取成为可能的那次握手:startxref、单独占一行的字节偏移量,以及 %%EOF。读取器跳到末尾,读取那个偏移量,直接跳到交叉引用区段,然后就启程了。
- HeaderOne line, %PDF-2.0, naming the version. A binary-marker comment usually follows.
- BodyNumbered indirect objects — dictionaries and streams — referenced by N G R.
- Cross-reference sectionA text table of 20-byte entries, or a compressed /Type /XRef stream in PDF 2.0.
- TrailerNames /Root and /Size, then startxref + offset + %%EOF.
- Read orderA reader starts at %%EOF, follows startxref to the index, then reaches each object directly.
还有一个第五区域,是长期存续的文件会生长出来的:一次增量更新。变更不会就地写入。被改动的对象、一个全新的交叉引用区段,以及一个新的 trailer 会被附加在第一个 %%EOF 之后,而那个新 trailer 会携带 /Prev——前一个交叉引用区段的偏移量(Spec: ISO 32000-2, §7.5.6ISO 32000-2 §7.5.6)。这些区段构成一条向后的链;对于任意对象编号,最新的条目胜出。由于原始字节从不移动,签名实际覆盖的那个字节范围上的密码学校验,在一次更新之后依然成立。后续的一次增量更新仍可改变验证器对整份文件作出的报告——签署之后的变更是否被允许,以及签名被认定证明了什么——但它无法改动已签署的字节本身。那个特性正是增量更新整篇的主题。
实践示例
标题为“实践示例”的章节下面是整套解剖结构浓缩在一个最小文件里。xref 下方的那些数字是字节偏移量,而且它们必须精确无误——只要指向某个对象起始位置往后偏一个字符,一个严格的读取器就会放弃。
%PDF-2.01 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>endobjxref0 40000000000 65535 f0000000009 00000 n0000000058 00000 n0000000115 00000 ntrailer<< /Size 4 /Root 1 0 R >>startxref186%%EOF按解析器的方式来读它。最后一行:%%EOF。它上面:startxref 186,于是跳到字节 186,那里是 xref 的起始处。表格说对象 1 位于字节 9。trailer 的 /Root 1 0 R 指向那里——即 catalog——再从 catalog 沿着 /Pages 走到页面树,找到那唯一的一页。对象 0 永远是空闲列表的头部,生成号为 65535,这是这个格式最初设计遗留下来的一块化石,每个读取器仍然预期会看到它。
常见误解
标题为“常见误解”的章节这个陷阱在于,人们会像读故事一样去读一个 PDF——从上到下,按顺序。它不是一个故事;它是一个带后向指针的索引。对象编号在文件中不必连续,对象可以以任意物理顺序出现,读取器也从不依赖它们的位置。唯一具有权威性的映射是交叉引用区段,而找到那张映射的唯一方法,是文件最末尾的 startxref 偏移量。
由此带来的后果会让人意外。一个 body 完美无瑕、却在 startxref 中只有一位数字错误的 PDF,是无法读取的——读取器找不到那个索引。一个对象顺序被打乱、但交叉引用区段正确的 PDF,则完全没有问题。物理位置不携带任何意义。被记录下来的位置,才承载着全部意义。
限制与边界
标题为“限制与边界”的章节本页说明的是物理结构,而不是页面内容。标记如何被放到页面上——内容流运算符、文字显示、图形状态——属于另一个独立主题。它说明的也是一个格式良好的文件。现实世界中的 PDF 经常有点破损,它们之所以还能存活,只是因为宽容的查看器会通过扫描 obj 关键字来重建交叉引用表。那种抢救是一种查看器行为,并不是这个格式所保证的东西。
| Edition | Availability |
|---|---|
| Core | NextPDF 是一个写入器。它会在每个对象被发出的那一刻,从输出缓冲区记录下每一个偏移量,因此它生成的文件,其交叉引用区段从构造上就与 body 相匹配。 |
| Pro | 解析、重建或修复一个并非由 NextPDF 写入的文件中受损的交叉引用表,在每一个版本中都属于范围之外。 |
| Enterprise | 要检视一个既有文件的结构,请使用专门的解析器或验证器;NextPDF 只为它所写入的内容保证正确性,不为它所读取的内容保证。 |
简短 FAQ
标题为“简短 FAQ”的章节为什么 PDF 在 header 之后会有一行二进制标记? 一些较旧的传输工具会损坏一个它们误以为是纯文本的文件。那行高位注释让文件看起来无可争议地是二进制,于是它能原封不动地熬过这趟旅程。
xref 流只是一个更小的表格吗? 大体上是,但多出一项能力。除了被压缩之外,一个 xref 流还能描述存储在对象流内部的对象——这是经典的 20 字节文本表格无法表达的条目。
我能在一个文件里同时拥有一个表格和一个流吗? 单个修订版只用其中一种。但一个混合文件可以为旧读取器配一个经典表格、为新读取器配一个交叉引用流,这样每一类读取器都能找到一个它看得懂的索引。
相关文件
标题为“相关文件”的章节- PDF 究竟是什么——把同样这四个部分看作一张对象图,而不是一种物理布局。
- 增量更新及其重要性——被附加的第五区域如何让文件增长,并保护签名。
- 流与过滤器——body 的流对象内部装着什么,以及它们是如何被压缩的。
- PDF 2.0:变了什么——为什么交叉引用流是 NextPDF 默认写入的结构。
词汇表
标题为“词汇表”的章节- Header——第一行,
%PDF-2.0,标明版本;通常后面跟着一行二进制标记注释。 - 间接对象——body 中一个带编号的对象,写作
N G obj … endobj,其中N是对象编号,G是生成号。 - 字典——一个
<< /Key value … >>形式、从名称到值的映射;最常见的对象形态。 - 流——一个字典,加上一段位于
stream与endstream之间的字节数据块,用于内容、字体和图像。 - 交叉引用表(xref)——从对象编号到字节偏移量的索引;经典形式是每条条目占 20 字节的文本表格,在 PDF 2.0 中则是一个
/Type /XRef流。 - Trailer——标明
/Root与/Size的字典,通过文件末尾的startxref偏移量被找到。 - 增量更新——被改动的对象、一个新的交叉引用区段,以及一个新的 trailer,被附加在
%%EOF之后,并以/Prev向后串接到前一个区段。