技术文章

PDF 文件结构:头部、主体、交叉引用表和尾部

PDF 阅读器并非从文件开头开始。它是从末尾开始的。最后几个字节保存了其他所有内容的地址,如果解析器不理解该顺序,它将从第一行开始误读格式。因此,学习磁盘上 PDF 最有用的方法是以阅读器的方式学习它:首先看尾部,然后向后跳转到映射表,接着解析映射表指向的对象

当没有任何内容被压缩时,这些字节本身非常简单,可以在文本编辑器中读取。一个最小的、绘制“Hello, World!”的单页文档不到 500 字节,并且格式的每一个结构元素在其中都清晰可见。这是整个文件,并标出了四个部分:

%PDF-1.0                          % Header
%âãÏÓ

1 0 obj                           % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

四个部分,在文件中始终按此顺序排列:一个头部,一个对象主体,一个交叉引用表,以及一个尾部。巧妙之处在于,您几乎是以相反的顺序阅读它们的。ISO 32000-2 §7.5.1 列出了相同的四部分解剖结构,而从后向前访问的原因纯粹是出于实用考虑:直接跳转到其所需对象的阅读器比从头扫描每个字节的阅读器快得多,而这种随机访问正是尾部和交叉引用表存在的意义

头部有两行,而第二行至关重要

第一行是 %PDF-1.0。就语法而言,百分号使其成为注释,但阅读器将其视为文件签名并从中提取版本号。在实践中,版本的处理是宽松的。为 PDF 2.0 构建的阅读器会愉快地打开一个声称是 1.0 的文件,并且大多数阅读器会尝试打开声明的版本有误,或者版本行深埋在文件某处而不是在字节 0 处的文件。该数字是关于期望哪些功能的提示,而不是关卡

第二行是人们不小心删除后,又花一下午调试的那一行。它也是一条注释,但它的有效负载是高于 ASCII 127 的四个字节。它们的存在使得任何在“文本模式”下移动该文件的程序都能将其识别为二进制,并停止重写行尾。PDF 带有压缩流,其字节可能巧合地与回车或换行匹配;如果传输工具重写了这些字节,字典中记录的流长度将不再与磁盘上的字节匹配,文件就会损坏。高字节注释是一种长达 40 年之久的、针对 ASCII 模式下 FTP 的防御手段,它仍然存在于每个严肃工具编写的文件中,因为它所防止的失败是悄无声息且致命的

主体容纳对象,每个对象都有编号

构成文档的所有内容都作为间接对象的扁平序列存在于主体中。每个对象都以两个整数和 obj 关键字开头,保存其内容,并以 endobj 结束。上面示例中的对象 1 是页面树节点:1 0 obj,然后是一个字典,然后是 endobj。第一个整数是对象编号,第二个是生成编号。在新写入的文件中,生成编号几乎总是为零;它仅在对象编号在多次编辑中被重复使用时才会攀升,这种情况非常罕见,您可以将非零生成视为文件经历了增量更新的标志。此处的关键字之间的内容是字典,写在 <<>> 之间,但它也同样可以是一个数字、一个字符串、一个数组或一个流

使得它成为图而不是列表的是引用标记 2 0 R。这意味着“对象 2,生成 0,无论它碰巧位于文件中的何处。”上面的页面树节点并不包含其页面;它指向对象 2,而对象 2 通过相同的机制指向其资源和内容流。主体以写入器认为方便的任何顺序布局,引用将其缝合成一棵以目录为根的树。在文件中的位置没有任何意义。标识来自对象编号,位置来自交叉引用表

交叉引用表是字节偏移量的索引

xref 表是将对象编号转换为文件位置的机制。这就是为什么阅读器可以打开千页文档,并渲染第 850 页而无需解析其前 849 页的原因。每个条目准确地记录了其对象的开始位置(以距文件开头的字节数计算):

xref
0 6                  % 6 entries, starting at object 0
0000000000 65535 f   % entry 0: head of the free list
0000000015 00000 n   % object 1 begins at byte 15
0000000074 00000 n   % object 2 begins at byte 74
0000000192 00000 n   % object 3 begins at byte 192
0000000291 00000 n   % object 4 begins at byte 291
0000000409 00000 n   % object 5 begins at byte 409

固定宽度是深思熟虑的。每个条目正好是 20 个字节:一个十位数字的偏移量、一个空格、一个五位数字的生成编号、一个空格、一个字符的类型以及一个双字节的行尾。因为行是统一的,所以阅读器可以通过算术运算而不是扫描直接索引到对象 n 的条目,因此提供对主体的随机访问的表本身也是可随机访问的。0 6 行是小节标头:它表示接下来的条目描述了从编号 0 开始的六个对象

对象 0 很特殊,而且始终存在。它的类型为 f 代表空闲(free),其生成编号为 65535,并且它是空闲对象编号链表的头部。在一个从未被编辑过的文件中,空闲列表只有这一个条目,这只是一种形式。它在增量更新期间发挥作用,那时删除对象会将其编号添加到该列表,以便以后的编辑可以回收它。其他条目的类型为 n 代表使用中(in-use),其十位数字是您为了读取该对象的定义而要寻址的偏移量

尾部是入口点,它位于末尾

即使尾部是最后写入的,它却是阅读器实际消耗的第一样东西。解析器打开文件,定位到末尾,然后向后回溯寻找 %%EOF。就在它上方是 startxref,后跟一个数字,该数字就是 xref 关键字的字节偏移量。借助它,阅读器直接跳转到交叉引用表,而无需扫描单个对象:

trailer
<<
/Root 5 0 R          % the document catalog
/Size 6              % one more than the highest object number
>>
startxref
459                  % byte offset of the xref table
%%EOF

尾部字典包含阅读器在执行任何其他操作之前所需的两个值。/Root 指向文档目录,即此处的对象 5,它是对象图的顶层,也是通向页面树的路径。/Size 是交叉引用表应包含的条目计数,由于槽 0 处的空闲条目,它比最高对象编号大一。从 %%EOF 开始,整个阅读序列应运而生:找到标记,读取 startxref 定位表,加载表以了解每个对象所在的位置,读取 /Root 找到目录,然后从那里按需解析对象。位于顶部的头部,直到很晚才被查阅。底部的映射才是阅读器首先需要的

增量更新追加第二个映射而不是重写

这种尾部优先的设计在文件发生更改时取得了回报。在不重写磁盘上已有的任何字节的情况下就可以编辑 PDF。新增和修改的对象被追加到末尾,后跟一个新的交叉引用部分和一个新的尾部,下方的原始文件保持原样。一个新的簿记是新尾部中的 /Prev 条目,它保存了上一个交叉引用表的字节偏移量:

% ... original file, unchanged, ends here ...

6 0 obj                          % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % a second xref section, for the new object only
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % byte offset of the earlier xref table
>>
startxref
680                              % offset of this new xref section
%%EOF

阅读器仍然从最后的 %%EOF 开始,仍然跟随 startxref 到最新的表,但现在会沿着 /Prev 链向后追溯到更旧的表,将它们合并,以便任何对象编号的最新条目获胜。交叉引用部分形成贯穿文件的链表,每一个都会覆盖在它之前其所触及的对象。编辑被替换的对象仍然物理上存在于其旧偏移量处;它只是不再可达,因为后来的 xref 条目指向了更新的地方

这正是使签名的 PDF 能够被验证的机制。数字签名覆盖文件的某个字节范围,并且由于增量更新仅进行追加,因此被签名的字节永远不会移动。签名仍可根据原始范围进行验证,而后面的修订则位于其后,每个修订都有自己的 xref 和尾部。这也是为什么 PDF 可以携带可恢复历史记录的原因:每个被取代的对象仍然存在于磁盘上早期交叉引用部分下,这对于版本跟踪是一项特性,而对于认为“删除”就意味着字节已消失的人来说,这是一项责任

代价是增长。每次编辑都会追加;没有任何内容会被就地回收,因此多次修订的文件会积聚大量无效对象和一长串 xref 部分。补救措施是完全重写:加载文档并重新保存,这将对幸存的对象重新编号,丢弃不可达的对象,并发出一个干净的交叉引用表。两种策略直接权衡。追加速度快,保留签名和历史;重写速度慢,并丢弃了两者,换取的是一个紧凑的文件

实践中阅读这四个部分

了解布局足以手动调试大多数“文件无法打开”的问题。如果阅读器拒绝 PDF,通常罪魁祸首都在两端,而不是在中间。不完整的下载会丢失尾部,因此缺少 startxref%%EOF,导致阅读器没有入口点;宽容的阅读器会退回到扫描整个文件以重建 xref,而这正是该表本旨在避免的慢速路径。糟糕的文本模式传输会破坏流字节,或者偏移量不再与现实匹配,并且对象会从错误的位置加载。当表中的偏移量不再指向真正的 obj 关键字时,文件在结构上就被破坏了,即使每个对象单独来看都没问题

对于新代码而言,布局带来的教训是让库负责字节的薄记工作。交叉引用表中的偏移量必须精确到字节,与每个对象的实际位置一致,尾部必须指向正确的表,且增量更新必须通过 /Prev 正确地链接。像针对 Delphi 和 C++Builder 的 HotPDF Component 这样的原生组件在写入文件时处理所有这些问题,包括在追加增量版本和重写紧凑版本之间进行选择。如果您想看看如何从无到有建立相同的结构而不是对其进行解剖,那么有关从头开始构建 PDF 文档的姊妹篇文章按顺序介绍了发出头部、对象、xref 和尾部的全过程