從零手寫 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):畫矩形並填充
讓你在同一頁上同時畫出:標題文字 + 一條水平分隔線 + 一個矩形框,從"會寫字"進階到"會畫圖形"。
