打开文件的那一瞬间:PDF 的内部结构
2.1 PDF 不是二进制黑盒
很多人以为 PDF 是像 .docx 或 .exe 那样的纯二进制格式,用文本编辑器打开只能看到乱码。实际上不完全是——PDF 是一种混合格式,其结构骨架是纯 ASCII 文本,只有图片和压缩流等数据才是二进制的。
为了证明这一点,下面是一份全世界最小的合法 PDF——总共 29 行:
%PDF-1.4
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]
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 24 Tf 100 700 Td (Hello PDF!) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000266 00000 n
0000000360 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
430
%%EOF
你现在就可以试:新建一个文本文件,把上面的内容一字不差地粘进去,保存后把后缀名改成 .pdf,然后用 Sumatra PDF、Chrome 或任何阅读器打开——你会看到一个白色的 Letter 大小页面,左上角写着 "Hello PDF!"。
看不懂没关系。接下来几节会逐段拆解,每遇到新语法就停下来讲清楚。
2.2 四段式结构:先看全貌
在逐行细读之前,先有一个全局认知。每份 PDF 文件都由四个部分组成:
┌──────────────────────────┐
│ Header(文件头) │ ← 1 行:%PDF-1.x 版本声明
├──────────────────────────┤
│ Body(主体) │ ← N 个对象:页面、字体、图片...
├──────────────────────────┤
│ Xref Table(交叉引用表) │ ← 每个对象在文件中的字节位置
├──────────────────────────┤
│ Trailer(尾部) │ ← 指向根对象 + Xref 的位置 + %%EOF
└──────────────────────────┘
对应到上面那 29 行代码:
- Header = 第 1 行(
%PDF-1.4) - Body = 对象 1~5(中间那一大段)
- Xref =
xref到 6 行数字 - Trailer =
trailer到%%EOF
有了这个全貌,接下来的逐行拆解就不会迷路了——你始终知道"我现在在四段中的哪一段"。
2.3 从零开始读一份 PDF
2.3.1 第一行:版本声明
%PDF-1.4
每份 PDF 文件的第一行都是版本声明。% 在 PDF 中是注释符号(和 Python 的 # 类似),但文件开头这行有特殊含义:告诉阅读器"这是一份 PDF,版本 1.4"。
仅此而已。一行搞定。
2.3.2 对象:PDF 的积木块
接下来的内容是 PDF 的"主体"——由若干个**对象(object)**组成。让我们看第一个:
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
三行代码,四个新概念。逐个来:
1 0 obj ... endobj——对象的"包装纸"
obj 和 endobj 就像括号,把一个对象的内容包起来。前面的 1 0 是这个对象的身份证:
1= 对象编号(你可以理解为 ID)0= 版本号(第一次创建是 0,以后被修改过会变成 1、2...)
所以 1 0 obj 的意思就是:"以下内容是对象 #1(初始版本)。"
<< ... >>——字典(Dictionary)
双尖括号 << >> 在 PDF 中表示一个字典——一组键值对。如果你熟悉 JSON,它等价于 {};如果你熟悉 Python,它等价于 dict。
字典里面的东西是一对一对的:键在前,值紧跟其后。
/Type、/Catalog、/Pages——名称(Name)
以斜杠 / 开头的词叫名称(Name),是 PDF 的关键字/标签。它们是固定的、规范定义好的。你可以把 / 理解为"这个词有特殊含义"的标记。
/Type是键名,表示"这个对象是什么类型"/Catalog是值,表示"文档目录"(整个 PDF 的根入口)/Pages是另一个键名,表示"页面树在哪里"
2 0 R——引用(Reference)
2 0 R 表示"指向对象 #2(版本 0)"。R 就是 Reference 的意思。
为什么不把对象 2 的内容直接写在这里?因为一个对象可能被多个地方引用——比如一个字体可能被 10 个页面共用。用引用(R)就像超链接,避免了重复。
把这三行翻译成人话:
对象 #1 是文档目录(Catalog),它说:所有页面的信息去找对象 #2。
2.3.3 页面树:组织多页文档
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
现在你应该能读懂大部分了:
- 对象 #2,类型是
/Pages(页面树节点) /Kids [3 0 R]——"子节点"是一个数组[],里面只有一项:对象 #3/Count 1——总共 1 页
方括号 [] 在 PDF 中表示数组(有序列表)。这里只有一个子节点,但如果是 200 页的文档,数组里就会有 200 个引用。
人话: 对象 #2 说"我管着所有页面,目前就 1 页,具体内容去问对象 #3。"
2.3.4 页面本身:尺寸、内容、资源
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
这个对象复杂一点,拆开看:
| 键 | 值 | 含义 |
|---|---|---|
/Type |
/Page |
这是一个页面 |
/Parent |
2 0 R |
父节点是对象 #2(页面树) |
/MediaBox |
[0 0 612 792] |
页面尺寸(左下角 x,y 到右上角 x,y) |
/Contents |
4 0 R |
绘图指令在对象 #4 里 |
/Resources |
<< /Font << /F1 5 0 R >> >> |
这页用到的资源(字体 F1 → 对象 #5) |
关于 /MediaBox [0 0 612 792]:
PDF 的尺寸单位是点(point),1 点 = 1/72 英寸。所以 612 × 792 点 = 8.5 × 11 英寸 = 美国 Letter 纸的标准尺寸。如果是 A4 纸,会是 [0 0 595.276 841.89](210mm × 297mm)。
注意 /Resources 的值本身又是一个字典(字典里套字典)——PDF 中嵌套是很常见的。这里的意思是:"这一页用到了一种字体,代号叫 F1,它的定义在对象 #5 里。"
2.3.5 内容流:真正的"画面"
4 0 obj
<< /Length 44 >>
stream
BT /F1 24 Tf 100 700 Td (Hello PDF!) Tj ET
endstream
endobj
这里出现了新东西:流(stream)。
stream 和 endstream 之间的内容是这个对象携带的大块数据。<< /Length 44 >> 这个字典告诉阅读器:流里面有 44 个字节。
流里面那行 BT /F1 24 Tf 100 700 Td (Hello PDF!) Tj ET 是 PDF 的绘图指令——告诉阅读器在这一页上画什么。现在你只需要知道一个大概:
| 指令 | 含义 |
|---|---|
BT |
Begin Text——"我要开始画文字了" |
/F1 24 Tf |
用字体 F1,大小 24 磅 |
100 700 Td |
把画笔移到坐标 (100, 700) |
(Hello PDF!) Tj |
在当前位置画出括号里的文字 |
ET |
End Text——"文字画完了" |
这些绘图指令的详细语法是第三章的内容,这里只要理解"对象 #4 存放了画面的具体指令"就够了。
2.3.6 字体声明
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
对象 #5 告诉阅读器:代号 F1 的字体是什么。
/Type /Font——我是一个字体/Subtype /Type1——具体格式是 Type 1(Adobe 经典字体格式,第四章会细讲)/BaseFont /Helvetica——字体名叫 Helvetica(一种系统自带的无衬线字体)
这里的 Helvetica 没有嵌入字体数据,只是引用了系统字体名。如果接收端没有 Helvetica,阅读器会尝试找类似的替代品。(第四章会详细讨论字体嵌入问题。)
2.3.7 交叉引用表:文件的"目录页"
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000266 00000 n
0000000360 00000 n
前面我们定义了 5 个对象(编号 1-5)。但阅读器怎么知道对象 #3 在文件的哪个位置?难道要从头扫描整个文件找 3 0 obj 这几个字?
交叉引用表(Cross-Reference Table,简称 Xref)就是解决这个问题的——它记录了每个对象在文件中的精确字节位置。
0 6= 从对象 #0 开始,共 6 个条目- 每行格式:
字节偏移 版本号 状态 n= 正在使用(in-use),f= 已释放(free)- 对象 #0 永远是 free 的(这是规范要求的占位符)
所以阅读器想找对象 #3,直接查表:第 115 字节处。一次 seek 操作就到了,不需要扫描前面的内容。
这就是 PDF 能秒开第 50 页的关键——有了 Xref,阅读器可以跳到任意对象,不需要从头读到尾。
2.3.8 Trailer:整个文件的入口
trailer
<< /Size 6 /Root 1 0 R >>
startxref
430
%%EOF
最后几行:
trailer后面的字典<< /Size 6 /Root 1 0 R >>说:文件里共 6 个对象,根目录是对象 #1startxref 430说:交叉引用表在文件的第 430 字节处%%EOF表示文件结束
阅读器打开 PDF 的流程是倒着来的:
- 跳到文件末尾,找到
%%EOF - 往前找
startxref→ 知道 Xref 在第 430 字节 - 跳到第 430 字节,读取 Xref 表 → 知道了所有对象的位置
- 读取 Trailer → 知道根目录是对象 #1
- 沿着 对象 #1 → 对象 #2 → 对象 #3 找到第一页
- 加载对象 #4(绘图指令)和对象 #5(字体)→ 渲染页面
整个过程不需要从头到尾扫描文件。即使是一份 500 页、50MB 的 PDF,打开第一页也只需读取几 KB 的数据。
2.3.9 回顾
现在回头看 2.1 中那份完整的 29 行代码,你应该能自己读懂每一行了:
- 第 1 行:版本声明
- 对象 1:文档目录,指向页面树
- 对象 2:页面树,管理所有页面
- 对象 3:页面定义(尺寸、内容、资源)
- 对象 4:内容流(绘图指令)
- 对象 5:字体声明
- Xref:所有对象的字节位置索引
- Trailer:文件入口(根对象 + Xref 位置)
5 个对象通过引用(R)互相连接,形成一棵树:
Catalog (1)
└── Pages (2)
└── Page (3)
├── Contents (4) → 绘图指令
└── Resources
└── Font F1 (5) → Helvetica
所有真实的 PDF——不管是 3 页的简历还是 800 页的教材——都是这个结构的扩展。但骨架永远是这个形状。
最后,PDF 语法的全部基础你已经掌握了:
| 语法 | 含义 | 类比 |
|---|---|---|
N M obj ... endobj |
定义一个编号对象 | 给积木贴标签 |
<< /Key Value >> |
字典(键值对) | JSON 的 {} |
/Name |
名称(关键字) | JSON 的 key |
N M R |
引用另一个对象 | 超链接 |
[item1 item2] |
数组 | JSON 的 [] |
stream ... endstream |
大块数据 | 文件附件 |
(text) |
字符串 | 引号括起来的文字 |
带着这些知识,后面几节的进阶内容就不会觉得突兀了。
2.4 交叉引用表进阶
2.4.1 Xref 的完整格式
前面我们已经知道了 Xref 的基本含义:记录每个对象的字节位置。这里补充一些进阶细节。
xref
0 6 ← 从对象 0 开始,共 6 个条目
0000000000 65535 f ← 对象 0:free(永远是空闲占位符)
0000000009 00000 n ← 对象 1:在文件第 9 字节处,版本 0,正在使用
0000000058 00000 n ← 对象 2:在第 58 字节处
0000000115 00000 n ← 对象 3:在第 115 字节处
0000000266 00000 n ← 对象 4:在第 266 字节处
0000000360 00000 n ← 对象 5:在第 360 字节处
每行的格式是固定的 20 字节:10位偏移 5位版本 状态(含空格和换行,精确到字节)。这个固定宽度设计让阅读器可以直接用算术计算出第 N 个条目的位置,不需要逐行解析。
状态标记:
n(in-use):对象正在使用,偏移指向它在文件中的位置f(free):对象已被删除,偏移指向下一个 free 对象(形成空闲链表)tips
为什么 Xref 如此重要?
想象一份 500 页的教科书 PDF,文件大小 50MB。如果没有 Xref,阅读器想显示第 300 页就必须从头扫描整个文件找到那一页的数据。有了 Xref,阅读器可以:
- 读文件末尾的 Trailer → 得到 Page Tree 的根
- 查 Xref 找到根对象的偏移 → 跳过去读它
- 沿着 Page Tree 找到第 300 页的对象引用
- 查 Xref 找到该页对象的偏移 → 跳过去读它
- 渲染该页
整个过程只需要读取极少量的数据,大部分文件内容根本不会被加载到内存。
2.4.2 交叉引用流(PDF 1.5+)
从 PDF 1.5 开始,Xref 表也可以用压缩流存储,称为 Cross-Reference Stream。这让大文件的 Xref 占用更少空间,也让整个文件可以用统一的流式方式处理。
现代 PDF 生成器通常使用这种压缩格式。如果你用文本编辑器打开一个现代 PDF,可能看不到传统的 xref 文本表——因为它已经被压缩成二进制流了。
2.5 增量保存:PDF 的"追加写入"模型
2.5.1 为什么 PDF 只增不减
当你用 Adobe Acrobat 给一份 PDF 加一个批注,保存时发生了什么?
答案不是"修改原文件中的对象"——而是在文件末尾追加新内容:
┌──────────────────┐
│ 原始 Header │
│ 原始 Body │
│ 原始 Xref │
│ 原始 Trailer │
├──────────────────┤ ← 原文件到此为止
│ 新增/修改的对象 │
│ 新的 Xref │ ← 只包含变化了的条目
│ 新的 Trailer │ ← Prev 指向上一个 Xref
└──────────────────┘
这种设计叫增量保存(Incremental Save)。好处是:
- 保存极快:不需要重写整个文件,只追加变化部分
- 支持撤销:删掉最后一段追加,文件就回到之前的状态
- 数字签名不失效:签名覆盖原始文件的字节范围,追加新内容不会修改已签名区域
坏处也很明显:
- 文件只增不减:每次编辑都让文件变大,即使你删除了一页。一份 PDF 被反复编辑后,文件大小可能是原来的好几倍
- 安全隐患:被"删除"的内容其实还在文件里(只是 Xref 标记为 free),用文本编辑器可以直接看到
2.5.2 "另存为"的真正含义
当你用"另存为"(Save As)而不是"保存"(Save)时,阅读器通常会做一次完整重写——把所有有效对象收集起来,重新生成一份干净的 PDF,去掉历史增量。这就是为什么"另存为"后文件通常会变小。
在终端下,很多工具可以执行这个操作:
# 用 Ghostscript 重写/压缩 PDF
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 \
-dNOPAUSE -dBATCH -sOutputFile=output.pdf input.pdf
# 用 qpdf 线性化和去增量
qpdf --linearize input.pdf output.pdf
2.6 线性化:让 Web 上的 PDF 能"边下边看"
2.6.1 问题:HTTP 下的 PDF
普通 PDF 的 Xref 在文件末尾。这意味着通过 HTTP 下载一份 PDF 时,浏览器必须下载完整个文件才能开始渲染第一页——因为它需要先拿到末尾的 Xref。
对于一份 100MB 的扫描版教材,这意味着用户要等到全部下载完才能看到任何内容。
2.6.2 解决方案:线性化 PDF
线性化(Linearized PDF,也叫 "Fast Web View")重新组织文件结构:
┌──────────────────────────────────────┐
│ Header + 线性化字典 │
│ 第 1 页的所有对象(字体、图片等) │
│ Xref(或 hint table) │
│ 第 2~N 页的对象 │
│ 完整 Xref │
│ Trailer │
└──────────────────────────────────────┘
关键变化:
- 第一页的数据放在文件开头,不需要等到下载完就能渲染
- Hint Table 放在前面,告诉阅读器每一页大概在什么位置
配合 HTTP Range Request(部分下载),阅读器可以:
- 下载文件开头 → 渲染第一页
- 用户翻到第 50 页 → 只请求那一页的字节范围
这就是为什么有些网页上的 PDF 打开后能秒看第一页,有些却要等很久——取决于 PDF 是否做过线性化。
2.6.3 如何检测
用 qpdf 工具可以检查:
qpdf --check-linearization document.pdf
或者直接用文本编辑器看文件开头几行——线性化 PDF 的第一个对象里会有 /Linearized 1 字样。
2.7 流(Stream):PDF 里的大块数据
2.7.1 什么会存成流
PDF 中的大块数据——图片像素、字体数据、压缩的页面内容——都存在 stream 对象中:
4 0 obj ← 对象编号 4,版本 0
<< /Length 44 >> ← 字典:声明流的字节长度为 44
stream
BT /F1 24 Tf 100 700 Td (Hello PDF!) Tj ET ← 绘图指令(下一章详解)
endstream
endobj
字典部分描述流的元数据(长度、压缩方式),stream 和 endstream 之间是实际数据。
2.7.2 压缩过滤器
流可以使用各种压缩/编码方式,通过 /Filter 指定:
| 过滤器 | 用途 |
|---|---|
/FlateDecode |
zlib/deflate 压缩(最常用) |
/DCTDecode |
JPEG 压缩(图片) |
/JPXDecode |
JPEG 2000 压缩 |
/JBIG2Decode |
JBIG2 压缩(黑白扫描件) |
/LZWDecode |
LZW 压缩(旧格式) |
/ASCII85Decode |
Base85 编码(让二进制变可读) |
/RunLengthDecode |
游程编码 |
过滤器可以串联——比如先 JPEG 压缩图片,再 ASCII85 编码让它变成纯文本。解码时反向依次处理。
2.7.3 为什么普通 PDF 里的文字"看不懂"
现代 PDF 生成器几乎都会对页面内容流使用 FlateDecode 压缩。所以你用文本编辑器打开一份正常的 PDF,看到的 stream 内容是一堆乱码——那是 deflate 压缩后的字节。
用 qpdf 可以解压所有流,得到可读的文本:
qpdf --qdf --object-streams=disable input.pdf readable.pdf
解压后你就能看到页面内容流里的绘图指令了——下一章会详细讲这些指令是什么。
2.8 动手实验:用 Python 解析 PDF 结构
这里用一小段 Python 代码来探索一份真实 PDF 的内部结构:
import fitz # PyMuPDF
doc = fitz.open("example.pdf")
# 基本信息
print(f"页数: {doc.page_count}")
print(f"PDF 版本: {doc.metadata['format']}")
print(f"加密: {doc.is_encrypted}")
# 查看页面对象
page = doc[0] # 第一页
print(f"\n第一页尺寸: {page.rect}") # 坐标矩形
print(f"第一页旋转: {page.rotation}°")
# 提取页面内容流(原始绘图指令)
xref = page.xref # 页面对象的 xref 编号
print(f"页面对象 xref: {xref}")
# 查看对象的原始 PDF 源码
print(doc.xref_object(xref))
输出类似:
页数: 42
PDF 版本: PDF 1.7
加密: False
第一页尺寸: Rect(0.0, 0.0, 595.276, 841.89)
第一页旋转: 0°
页面对象 xref: 3
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.276 841.89] ... >>
其中 595.276 × 841.89 就是 A4 纸的标准点数尺寸(1 点 = 1/72 英寸,所以 595.276 ÷ 72 ≈ 8.27 英寸 = 210mm)。
2.9 本章小结
PDF 的文件结构设计体现了几个核心权衡:
- 随机访问 vs 顺序读取:Xref 让跳转快,但增加了文件复杂度
- 追加写入 vs 就地修改:增量保存让编辑快,但文件膨胀
- 压缩 vs 可读性:FlateDecode 让文件小,但不能直接用文本编辑器阅读
- 兼容性 vs 简洁性:三十年的向后兼容让规范越来越厚
下一章我们进入最核心的部分:页面内容流里的绘图指令——PDF 为什么说"不是文字,是画"。