跳转到内容
getnextpdf.com

PDF 文件的解剖结构

用纯文本编辑器打开任意一个 PDF,你看到的第一样东西会让人安心:一个 %PDF-1.x%PDF-2.0 header。你看到的最后一样东西是 %%EOF。这两行之间的一切,都是一台用于按编号查找对象的整洁小机器。本页是一次剖析。我们打开文件,为每个器官命名,并展示它们如何彼此相连。

它是两个邻页的结构篇姊妹。PDF 究竟是什么把文件视为一张对象图;增量更新讲解它如何随时间增长。本页则紧贴字节——一个解析器会逐一走过的物理区域,按它们在磁盘上排列的顺序呈现。

使用一个 PDF,你几乎从不需要这些知识。你需要它的那一天,是某个 PDF 出问题的那一天。一个文件在某个查看器里能打开、在另一个里却不能;一个验证器报告“交叉引用表损坏”;一份已签署的文件突然无法通过验证。一旦你能读懂它的解剖结构,这些都不再是谜团。它们不过是一个不再与某个位置匹配的数字、一个放错地方的区域,或一段指向虚无的结尾。

了解布局,能把“这个 PDF 损坏了”变成一个你能据以行动的诊断结论。这正是对着一个黑箱耸耸肩,与精确指出那个撒谎的字节之间的差别。

一个合规的 PDF 有四个物理部分,按以下文件顺序排列(Spec: ISO 32000-2, §7.5.1):

  1. 一个 header——一行,%PDF-2.0,标明版本。
  2. 一个 body——文件的主体:一连串带编号的间接对象
  3. 一个 交叉引用区段——一个从对象编号到该对象所在字节偏移量的索引。经典 PDF 使用一个文本表格;PDF 2.0 使用一个压缩的 xref 流
  4. 一个 trailer——一个标明进入点的小型字典,后面跟着 startxref、一个偏移量,以及 %%EOF

转折点在于:读取器并不从顶部开始。它从底部开始,读取 startxref 以找到索引,再用那个索引直接取得任意对象。文件是从前往后写入的,却是从后往前读取的。

让我们按顺序走过这四个区域,把字节摆在眼前。

header 是一行。NextPDF 写入 %PDF-2.0,并按惯例再写一行由高位字节组成的注释,好让简陋的传输工具把文件当成二进制而非文本对待。正是这第二行,导致一个以纯文本方式打开的 PDF 会在版本号紧后面显示一小段乱码。

body 是文件内容所在之处。每个间接对象都由一个编号、一个生成号、关键字 obj、一个值,以及 endobj 组成(Spec: ISO 32000-2, §7.3.10)。这个值是少数几种形态之一——但其中两种几乎承载了全部重量:

  • 一个字典<< /Key value … >>,是一个从名称到值的映射。页面树、catalog、字体描述符:全都是字典。
  • 一个是一个字典,后面跟着 stream、一段任意字节的数据块,以及 endstream。页面内容、内嵌字体和图像都是流,且几乎总是被压缩的。(它们的过滤器自成一个故事,讲述于流与过滤器。)

对象通过间接引用彼此指向——2 0 R 的意思是“对象 2,生成号 0”。正是这套接线,把一份扁平的对象列表变成了一张图。

交叉引用区段是大多数人从未正确想象过的部分。在经典形式中,它是纯文本:关键字 xref,接着是由固定宽度的 20 字节行组成的若干子区段(Spec: ISO 32000-2, §7.5.4)。每一行都是一个十位数的字节偏移量、一个五位数的生成号,以及一个单字符标志——n 表示使用中,f 表示空闲——被填充到恰好二十字节,好让读取器仅凭算术就能跳转到任意条目。PDF 2.0 用一个交叉引用流取代了它:同样的索引,但以二进制形式压缩在一个标有 /Type /XRef 的流对象内部(Spec: ISO 32000-2, §7.5.8)。更小,且能够描述被打包在对象流内部的对象。

trailer 是文件的目录(Spec: ISO 32000-2, §7.5.5)。它标明 /Root——文件 catalog,即其他一切都从其悬挂而下的那个唯一对象——以及 /Size,即对象计数。接着是让反向读取成为可能的那次握手:startxref、单独占一行的字节偏移量,以及 %%EOF。读取器跳到末尾,读取那个偏移量,直接跳到交叉引用区段,然后就启程了。

  1. HeaderOne line, %PDF-2.0, naming the version. A binary-marker comment usually follows.
  2. BodyNumbered indirect objects — dictionaries and streams — referenced by N G R.
  3. Cross-reference sectionA text table of 20-byte entries, or a compressed /Type /XRef stream in PDF 2.0.
  4. TrailerNames /Root and /Size, then startxref + offset + %%EOF.
  5. Read orderA reader starts at %%EOF, follows startxref to the index, then reaches each object directly.
按文件顺序排列的 PDF 四个物理区域,以及一个读取器实际走过它们的路径——从 trailer 开始,经由交叉引用区段向内推进。

还有一个第五区域,是长期存续的文件会生长出来的:一次增量更新。变更不会就地写入。被改动的对象、一个全新的交叉引用区段,以及一个新的 trailer 会被附加在第一个 %%EOF 之后,而那个新 trailer 会携带 /Prev——前一个交叉引用区段的偏移量(Spec: ISO 32000-2, §7.5.6)。这些区段构成一条向后的链;对于任意对象编号,最新的条目胜出。由于原始字节从不移动,签名实际覆盖的那个字节范围上的密码学校验,在一次更新之后依然成立。后续的一次增量更新仍可改变验证器对整份文件作出的报告——签署之后的变更是否被允许,以及签名被认定证明了什么——但它无法改动已签署的字节本身。那个特性正是增量更新整篇的主题。

下面是整套解剖结构浓缩在一个最小文件里。xref 下方的那些数字是字节偏移量,而且它们必须精确无误——只要指向某个对象起始位置往后偏一个字符,一个严格的读取器就会放弃。

%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
0000000115 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
186
%%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 关键字来重建交叉引用表。那种抢救是一种查看器行为,并不是这个格式所保证的东西。

Reading and repairing arbitrary third-party PDFs — edition availability
EditionAvailability
CoreNextPDF 是一个写入器。它会在每个对象被发出的那一刻,从输出缓冲区记录下每一个偏移量,因此它生成的文件,其交叉引用区段从构造上就与 body 相匹配。
Pro解析、重建或修复一个并非由 NextPDF 写入的文件中受损的交叉引用表,在每一个版本中都属于范围之外。
Enterprise要检视一个既有文件的结构,请使用专门的解析器或验证器;NextPDF 只为它所写入的内容保证正确性,不为它所读取的内容保证。

为什么 PDF 在 header 之后会有一行二进制标记? 一些较旧的传输工具会损坏一个它们误以为是纯文本的文件。那行高位注释让文件看起来无可争议地是二进制,于是它能原封不动地熬过这趟旅程。

xref 流只是一个更小的表格吗? 大体上是,但多出一项能力。除了被压缩之外,一个 xref 流还能描述存储在对象流内部的对象——这是经典的 20 字节文本表格无法表达的条目。

我能在一个文件里同时拥有一个表格和一个流吗? 单个修订版只用其中一种。但一个混合文件可以为旧读取器配一个经典表格、为新读取器配一个交叉引用流,这样每一类读取器都能找到一个它看得懂的索引。

  • Header——第一行,%PDF-2.0,标明版本;通常后面跟着一行二进制标记注释。
  • 间接对象——body 中一个带编号的对象,写作 N G obj … endobj,其中 N 是对象编号,G 是生成号。
  • 字典——一个 << /Key value … >> 形式、从名称到值的映射;最常见的对象形态。
  • ——一个字典,加上一段位于 streamendstream 之间的字节数据块,用于内容、字体和图像。
  • 交叉引用表(xref)——从对象编号到字节偏移量的索引;经典形式是每条条目占 20 字节的文本表格,在 PDF 2.0 中则是一个 /Type /XRef 流。
  • Trailer——标明 /Root/Size 的字典,通过文件末尾的 startxref 偏移量被找到。
  • 增量更新——被改动的对象、一个新的交叉引用区段,以及一个新的 trailer,被附加在 %%EOF 之后,并以 /Prev 向后串接到前一个区段。