从零手写 PDF(01):Hello, World——构建一个最小可用 PDF
从零手写 PDF(01):Hello, World——构建一个最小可用 PDF
系列目标:把 PDF 当作一种可读的文件格式来理解——先从"能跑"的最小例子开始,再逐步扩展到图形、多页、压缩与资源复用。
系列目录
- 第 01 篇(本文):手写一个最小 PDF(1 页 + 1 行文字),并用工具补齐为可打开的标准 PDF
- 第 02 篇:在内容流里画线/画矩形(理解路径、描边、填充)
- 第 03 篇:多页 PDF(Pages 树怎么长出来)
- 第 04 篇:更接近真实世界(压缩流、资源复用、可选结构等)
为什么要了解 PDF 的底层结构?
PDF(Portable Document Format,可移植文档格式)是当今最流行的页面描述语言之一。它和 HTML/CSS 那种"内容与呈现分离、可回流(Reflowable)"的思路不同,PDF 更强调版面固定、所见即所得——无论在什么设备上打开,排版都一致。
了解 PDF 底层结构有几个实际好处:
- 调试 PDF 生成问题:当你用代码库生成 PDF 出错时,能看懂底层结构才能快速定位问题
- 自动化处理:批量提取文本、合并文档、添加水印等操作,理解结构后才能精准操作
- 安全审计:了解 PDF 可以嵌入哪些内容(JavaScript、附件、表单等),有助于安全分析
- 学习文件格式设计:PDF 的"对象图 + 随机访问"设计是经典范例,值得学习
准备工作
本文会先写一个"结构不完整但逻辑正确"的 hello-broken.pdf,再用 pdftk 自动补齐关键结构并输出 hello.pdf。
- 需要工具:pdftk(免费命令行工具,支持 Windows/macOS/Linux)
- 输出文件:
hello-broken.pdf(手写)、hello.pdf(修复后可打开)
核心概念:PDF 的三层结构
理解 PDF 最重要的是建立一个三层心智模型:

1. 对象层(Document Content)
PDF 文档由很多对象组成,对象之间用间接引用(如 2 0 R)连接成一张图。常见对象类型:
| 类型 | 示例 | 说明 |
|---|---|---|
| Name | /Page | 以 / 开头的名称 |
| 整数/实数 | 50、36.0 | 数值 |
| 字符串 | (Hello, World!) | 用圆括号包裹 |
| 数组 | [0 0 612 792] | 有序集合 |
| 字典 | << /Type /Page >> | 键值对集合 |
| 间接引用 | 2 0 R | 引用对象 2(生成号 0) |
| 流 | stream...endstream | 二进制数据(如绘制指令、图片) |
2. 内容层(Page Content)
真正"把文字/图形画到页面上"的指令序列,通常写在 stream ... endstream 里。格式是:操作数在前,操作符在后。
/F0 36 Tf ← 操作数: /F0, 36 操作符: Tf(设置字体)
(Hello, World!) Tj ← 操作数: 字符串 操作符: Tj(绘制文本)
3. 文件结构层(File Structure)
让阅读器能快速随机访问任意对象,而不必从头读到尾:
| 元素 | 作用 |
|---|---|
%PDF-1.x | 文件头,标识 PDF 版本 |
xref | 交叉引用表:对象编号 → 字节偏移 |
trailer | 尾部字典:指向根对象 /Root |
startxref | 指出 xref 表的起始位置 |
%%EOF | 文件结束标记 |
最小 PDF 需要哪些对象?
一个"最小但能显示文字"的 PDF,对象之间的引用关系如下:

最小对象清单:
| 对象 | 作用 | 关键字段 |
|---|---|---|
| Catalog | 根对象,文档入口 | /Type /Catalog, /Pages |
| Pages | 页面树 | /Type /Pages, /Kids, /Count |
| Page | 单个页面 | /Type /Page, /MediaBox, /Resources, /Contents, /Parent |
| Resources | 资源容器 | /Font(字体字典) |
| Font | 字体定义 | /Type /Font, /BaseFont, /Subtype |
| Contents | 内容流 | 绘制指令的 stream |
实战:手写 hello-broken.pdf
新建文件 hello-broken.pdf,把下面内容完整粘贴进去:
%PDF-1.0
1 0 obj
<< /Type /Pages
/Count 1
/Kids [2 0 R]
>>
endobj
2 0 obj
<< /Type /Page
/MediaBox [0 0 612 792]
/Resources 3 0 R
/Parent 1 0 R
/Contents [4 0 R]
>>
endobj
3 0 obj
<< /Font
<< /F0
<< /Type /Font
/BaseFont /Times-Italic
/Subtype /Type1 >>
>>
>>
endobj
4 0 obj
<< >>
stream
1. 0. 0. 1. 50. 700. cm
BT
/F0 36. Tf
(Hello, World!) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Catalog
/Pages 1 0 R
>>
endobj
xref
0 6
trailer
<< /Size 6
/Root 5 0 R
>>
startxref
0
%%EOF
为什么这个文件是"坏的"?
我们故意省略或填错了以下内容:
| 缺失/错误项 | 说明 |
|---|---|
xref 偏移 | 没有填写每个对象的真实字节偏移 |
startxref | 填了 0,不是 xref 的真实位置 |
/Length | 内容流没有声明长度 |
| 二进制标记 | 缺少头部的二进制标识行 |
这些都是阅读器需要的关键信息,缺失会导致无法打开或只能容错打开。
关键内容流指令详解
内容流在对象 4 0 obj 的 stream ... endstream 之间,逐行解释:
1. 0. 0. 1. 50. 700. cm ← 设置变换矩阵(注意 1. 代表浮点数 1.0)
BT ← 开始文本对象
/F0 36. Tf ← 选择字体 F0,字号 36pt
(Hello, World!) Tj ← 绘制字符串
ET ← 结束文本对象
变换矩阵 cm 操作符
1 0 0 1 50 700 cm 是一个 6 元素的变换矩阵 [a b c d e f],对应:
| a b 0 | | 1 0 0 |
| c d 0 | = | 0 1 0 |
| e f 1 | | 50 700 1 |
当 a=1, b=0, c=0, d=1 时,这是一个纯平移矩阵,把坐标系原点(也就是后面绘制操作的 (0,0) 点)移动到 (50, 700)。如果不移动,默认原点在页面左下角。
文本操作符
| 操作符 | 含义 | 示例 |
|---|---|---|
BT | Begin Text,开始文本对象 | BT |
ET | End Text,结束文本对象 | ET |
Tf | 设置字体和字号 | /F0 36 Tf |
Tj | 绘制字符串 | (Hello!) Tj |
用 pdftk 修复为可打开的 PDF
在 hello-broken.pdf 所在目录执行:
pdftk hello-broken.pdf output hello.pdf
用任意 PDF 阅读器打开 hello.pdf,你应该能看到页面上出现 "Hello, World!"(Times-Italic 字体,36pt,位于页面左上方)。
pdftk 帮你补齐了什么?
| 补齐项 | 说明 |
|---|---|
| 二进制标记行 | 在 %PDF-1.0 后加一行不可打印字符,确保被识别为二进制文件 |
/Length | 为内容流计算并添加字节长度 |
xref 表 | 计算每个对象的字节偏移并填入 |
startxref | 填入 xref 表的真实起始位置 |
为什么需要 xref / trailer / startxref?
核心目的:随机访问
想象一个 500 页的 PDF,如果没有 xref,阅读器想显示第 450 页就必须从头解析到第 449 页——这太慢了。
有了 xref,阅读器可以:
- 先读
startxref→ 找到 xref 位置 - 读
trailer→ 找到根对象/Root - 从根对象顺藤摸瓜 → 直接跳到第 450 页对象
- 通过 xref 查该对象的字节偏移 → 直接 seek 过去读取
时间复杂度从 O(n) 降到 O(1)。
本篇练习
建议你真的动手改 hello-broken.pdf,然后重新用 pdftk 修复,观察效果:
| 练习 | 修改内容 | 观察点 |
|---|---|---|
| A | 把 (Hello, World!) 改成其他英文短句 | 文字变化 |
| B | 把 36 改成 12 或 72 | 字号变化 |
| C | 把 50 700 改成 50 100 | 位置下移(PDF 坐标系原点在左下角) |
| D | 把 /Times-Italic 改成 /Helvetica 或 /Courier | 字体变化 |
| E | 把 /MediaBox [0 0 612 792] 改成 [0 0 595 842] | 纸张从 US Letter 变成 A4 |
提示:PDF 坐标系原点在页面左下角,Y 轴向上。
(50, 700)表示距左边 50pt、距底部 700pt。
常见问题
Q: 为什么用 Type1 内置字体而不是 TrueType?
A: Type1 的 14 种标准字体(Times、Helvetica、Courier 等)是 PDF 阅读器必须内置的,不需要嵌入字体文件,最简单。真实场景中通常需要嵌入字体以保证跨平台一致性。
Q: /MediaBox [0 0 612 792] 这些数字是什么?
A: 单位是 point(1 point = 1/72 英寸)。612 × 792 点 = 8.5 × 11 英寸 = US Letter 纸张。A4 是 595 × 842 点。
Q: 生成号(如 2 0 R 中的 0)是什么?
A: 用于增量更新。当对象被修改时,生成号加 1。新建的 PDF 里所有对象生成号通常都是 0。
下一篇预告
第 02 篇我们继续沿用"手写内容流"的方式,加入最基础的图形路径操作:
m(moveto)、l(lineto):定义路径S(stroke):描边re(rectangle)、f(fill):画矩形并填充
让你在同一页上同时画出:标题文字 + 一条水平分隔线 + 一个矩形框,从"会写字"进阶到"会画图形"。
