# 在 Git 中用 Markdown 來管理規格書的正本，並使用 WeasyPrint 來隨時產生 PDF

> 規格書的正本搬進 Git：用語意換行換回可讀的差異，用一道檢查證明內文沒被改動，再用 WeasyPrint 隨時產出交件用的 PDF。

- Source: https://oharu121.com/zh-tw/blog/markdown-source-of-truth-git-weasyprint-pdf-pipeline/
- Published: 2026-09-19T14:26:43+09:00
- Tags: Markdown, PDF, WeasyPrint, Git, 開發工具

---
## 引言

我常常搞不清楚手上的程式碼，對應的到底是規格書的哪一版。規格書放在 Google Drive，`git` 看不到，所以別人改了什麼，我的工作目錄一點動靜也沒有。後來我把相隔一天的兩份匯出檔拉出來比對，才發現有些修改我根本沒看過，而且**已經改掉了程式該做的事**。

那把匯出檔一起提交進去呢？沒用。PDF 在 `git` 眼中是二進位檔，每一版都整份存一次；HTML 匯出檔則是每匯出一次，換行就換一個位置。結果一樣：改一個字，差異看起來像整份文件被重寫。

所以我最後選擇把規格書搬進了 Git，以 Markdown 的形式儲存。PDF 則交給 WeasyPrint 隨時產生。

## Markdown 放在 Git，PDF 隨時產生

**能讓審查的人和 AI 代理都逐行讀懂的格式，只有 Markdown。** 而且規格書一旦因為程式改動而失準，Claude Code 可以在同一次 commit 裡順手把它補上。

剩下的就只有一件事：需要一個隨時能轉出 PDF 的辦法，才能隨時交件給客戶。這件事可以用 WeasyPrint 輕鬆辦到。

## 來源在哪一邊 — repo 還是線上編輯器

**挑文件工具之前，得先決定一件事：正本由誰編輯。** 正本放在 repo，接下來要做的就是從它產生各種發布用的檔案；正本放在外面，那要做的其實是鏡像和追蹤，是完全不同的一件事，帶來的好處也不一樣。

*Figure — MastershipDecision: 一份 Markdown 原始檔加上一份產生出來的 PDF，可以是兩種完全不同的配置。專案屬於哪一種，決定了產生 PDF 到底有沒有意義，還是只多出第三份副本。*

一開始正本是放在 Google Drive。這種情況下需要先匯出 Markdown 副本後放進 Git、再從它生成 PDF，等於讓同一份文件存在著三種樣貌，而來源還在 Google Drive，三份互相打架。

我比對的那兩份匯出檔，差別是多了一個表格，還有未解決事項的清單被重新編號。重新編號最麻煩的地方在於：**每個連結都還是通的，只是悄悄指向了另一個看起來也很合理的項目**。這種改動再仔細讀也讀不出來。

## 重新換行，讓差異讀得懂

### 語意換行，一句一行

匯出來的規格書裡，最長的一行有 519 個字元。**這種狀況下改一個字，差異區塊就會涵蓋整個段落**，審查的人得整段讀完，才找得到動過的地方。

[Semantic Line Breaks](https://sembr.org/) 就是為此而生的慣例：一句話結束就換行，必要時獨立子句之後也可以換。Markdown 本來就會把連續的行併成同一段，所以**表面上看起來完全沒變，原始檔卻變得可以逐行指認**。

*Figure — DiffGranularity: 同樣改一個字，在一行很長與一句一行兩種情況下的比較。*

這件事是寫成指令碼跑的，不是手動改的。在日文句號 `。` 後面切開，是指令碼可以一致套用的規則；更重要的是，指令碼不會做到一份 500 行的文件中途覺得無聊，順手去動別的地方。

### 日文的軟換行在 PDF 裡會變成空白

一句一行在日文裡有一個英文沒有的副作用。軟換行到了 HTML 就是換行字元，渲染器再把它收成一個空白。而英文剛好需要這個空白來隔開兩個句子。

**但日文的句子之間根本就沒有空白，中文也一樣（日中韓特性接近，俗成 CJK）。** 於是為了差異而加進去的每一個換行，在 PDF 裡都變成段落中間莫名其妙的空隙。

CSS Text 規格其實處理了這件事：夾在兩個東亞字元之間的換行會被丟掉，瀏覽器也都落實了。但如果依賴它，就等於賭 PDF 渲染器也落實了同一套作法。與其賭，不如讓建置流程在轉換前就先去掉：

```python title="tools/build_docs_pdf.py"
CJK = (
    "\u3000-\u303f"  # punctuation
    "\u3040-\u309f"  # hiragana
    "\u30a0-\u30ff"  # katakana
    "\u4e00-\u9fff"  # kanji
    "\uff00-\uffef"  # full-width forms
)
CJK_SOFTBREAK_RE = re.compile(rf"(?<=[{CJK}])\n[ \t]*(?=[{CJK}])")
```

那段可省略的水平空白很容易漏寫，漏了也很難發現。清單項目底下的接續行前面有縮排，少了 `[ \t]*`，這個樣式剛好就漏掉一份滿是條列的規格書最常出現的那種行。

原始檔維持一句一行不變，被動手的只有交給渲染器的那份副本。

**匯出檔沒辦法照原樣提交。** 反斜線跳脫、跨文件連結、換行，全都得先改寫一遍，而這一遍幾乎每一行都會動到。正因為每一行都動過，「內文到底有沒有變」這個問題，用眼睛讀是答不出來的。

## 證明這次遷移什麼都沒改到

### 把匯出檔變成可以審查的東西

要證明的東西其實很單純：**除了空白以外，一個字元都沒有變。**

負責改寫的指令碼是一塊一塊長出來的。拿掉跳脫，重跑；改寫連結，重跑；轉換表格，再重跑。**每一輪剩下的差異，就是還沒處理完的部分**，所以這項檢查本身就是一個待辦清單。

*Figure — VerificationStages: 每一輪都會替指令碼加上一條規則，所以預期的文字每跑一次就是一個新的值。比較回報的不是通過與否，而是還剩多少事情沒做。*

預期的文字之所以每次重算、而不是存起來，是因為指令碼會在兩次執行之間增大；存下來的那一份等於是舊版指令碼的產物，拿它來比，比的是一個已經不存在的改寫版本。

剩下的人工部分，是指令碼無法判斷的部分：一張中繼資料表格、一份修訂紀錄、未解決事項清單上穩定的識別碼，這些都是新加的內容。所以檢查要確保的是，*只有*新增，沒有別的。

把兩邊的空白都拿掉之後，兩個字串應該只在刻意加東西的地方不一樣：

```python
WS = re.compile(r"\s+")
a, b = WS.sub("", expected), WS.sub("", current)
```

接下來差異回報的每一處刪除，都要能夠被解釋。

**這項檢查是寫指令碼那段期間在跑的，沒有放進 CI。** Markdown 成為來源之後本來就會一直被改，這種檢查如果常駐，第一次正常的編輯就會出錯。

### 刻意的刪除要在比對之前先移掉

有些刪除是故意的：刪除線記號、表格欄標題，還有一個指向早就不存在的文件的連結。檢查得放過這幾個刻意的變更、而其他的變動一律視作錯誤。第一版採用的做法是列出一份完全比對的允許刪除清單，列入刻意刪除的字串。**結果清單上明明有的字串，照樣被判定為錯誤。**

`difflib` 對齊文字的方式跟人讀的方式不一樣。人看到的是「這一行整個不見了」，而它只是拿兩份字元流去找兩邊都有的最長片段。**問題是，有些片段就躲在你要刪掉的那一行裡面**：只要同樣幾個字在後面還出現過，它就會把那幾個字判定成留下來的，在這段刪除中間打出一個洞。

**比較是把整份文件當成一個移除所有空白的字串在跑的**，而那份檔案前前後後連了同一個線上工作區十幾次。換句話說，被刪掉的那條連結，在檔案別的地方有一堆幾乎一模一樣的文字可以對齊。

就這樣，有三個碎片被判定成留下來的：`oogle`、`oc`，還有孤單一個 `s`。那一行因此被拆成四段互不相連的刪除，三個碎片卡在中間。把整行寫進清單也沒有用，因為整行從頭到尾就沒有以單一個 opcode 的形式出現過。

*Figure — DiffFragments: 允許清單持有的是一整行；檢查回報的卻是四段刪除，中間還夾著三個它拿去跟檔案裡其他連結對齊的碎片，兩邊對不上任何一處。文件 ID 已經遮蔽，其餘都是原樣。*

**所以正確的做法是：在比較之前，就把刻意刪除的那幾行整行從預期的文字裡拿掉。** 拿掉之前會先確認那一行真的存在，這樣清單裡打錯字就會直接報錯，而不是安安靜靜地放過。

### 通過所有檢查的垂直定位字元

遷移後的檔案存在著 14 個 `U+000B` 垂直定位字元，而**整條流程完全沒有任何相關的處理。**

匯出工具只要碰到表格儲存格裡有換行，就會塞一個進去。它們是控制字元，所以渲染器無法畫出來，`grep` 也搜不到；前面那個忽略空白的比較更是直接把它們當成空白跳過去。`str.rstrip()` 早就從某一行結尾吃掉了一個，也沒有人發現。

修正方式是把它們轉成 `<br>`，而且要排在去除行尾空白之前，才不會被 `rstrip` 搶先一步。

```python
found = {
    f"U+{ord(ch):04X}"
    for ch in set(text)
    if ch not in "\n\t" and unicodedata.category(ch) in ("Cc", "Cf", "Co", "Cs")
}
```

> **小心喔**
>
> **一個會忽略空白的驗證，需要另一項檢查來看它忽略掉了什麼。** 控制字元正好掉在這兩者之間的縫隙裡，所以才會大搖大擺地通過一個專門用來抓改動的比較。

## 隨時產生 PDF

建置流程讀進 Markdown，寫出 PDF。中間的產物一個都不 commit，原始檔也從頭到尾不會被改寫：

*Figure — PdfPipeline: 轉換之前會對副本做兩道改寫；封面則是另外獨立讀一次同一份檔案產生的。這條流程和驗證那幾節談的 Google Docs 遷移是兩回事。*

### 版次只留一個來源

我移植指令碼的那個專案，把文件版次放在兩個地方：Markdown 開頭的表格，以及印封面用的建置指令碼裡的常數。升一次修訂要改四個地方，而且這兩份副本隨時可能對不上。

**改成讓建置流程直接讀那張表格。** `parse_meta` 這個函式會在第一個 `---` 把檔案切開，把標題、副標題，以及上面每一列兩欄的資料都取出來交給封面範本。版次要是缺了，建置會直接中止，而不是產出一份封面開天窗的 PDF。

### PDF 列進 gitignore

**WeasyPrint 會把產生時間寫進每一份檔案。** 這表示內容一個字沒改，重新產生出來的位元組還是不一樣；如果提交 PDF 到 repo，每建置一次就多幾 MB 的差異。所以乾脆一開始什麼都不提交，檔名也就不必背負版次，直接取自文件自己的標題就好。

### WeasyPrint 在 macOS 上找不到自己的函式庫

`brew install pango` 裝好了，建置還是掛：

```text
OSError: cannot load library 'libgobject-2.0-0': dlopen(libgobject-2.0-0, 0x0002): tried: 'libgobject-2.0-0' (no such file)
```

WeasyPrint 是透過 `dlopen` 載入 Pango 和它的相依套件，而 `dlopen` 只看動態載入器的搜尋路徑。`uv` 裝的 Python 不是 Homebrew 的 Python，Homebrew 的 `lib` 目錄自然不在那些路徑裡面。把載入器指過去，建置就能夠運作了：

```make title="Makefile"
BREW_PREFIX := $(shell brew --prefix 2>/dev/null)
DOCS_DYLD := $(if $(BREW_PREFIX),DYLD_FALLBACK_LIBRARY_PATH="$(BREW_PREFIX)/lib",)

docs-pdf:
	$(DOCS_DYLD) uv run --group docs python tools/build_docs_pdf.py
```

真正值得一提的是那個條件判斷。無條件設定的話，在沒有 Homebrew 的機器上只會留下 `"/lib"` 這個值；而 **`DYLD_FALLBACK_LIBRARY_PATH` 是直接取代預設的搜尋清單，不是在後面追加**，於是用其他方式裝 Pango 的人反而載入不到函式庫。

## 比對行尾空白要用 `[ \t]*`，不要用 `\s*`

建置流程會在轉換之前，把每個標題裡的 `` `<a id="…"></a>` `` 錨點取出來，免得目錄把原始 HTML 一起繼承過去。問題出在 **`\s` 也會比對到換行**：`\s*$` 會把標題後面的空行連同錨點一起帶走，而前面那條消除換行的規則接著就把「針」和「本」黏在一起：

```text title="anchor lifted with [ \t]*$"
## 実装方針

本書では設計と実装の対応を示す。
```

```text title="anchor lifted with \s*$"
## 実装方針本書では設計と実装の対応を示す。
// the heading swallowed its own first paragraph
```

目錄就這樣把整行都收了進去，而修正方式是 `[ \t]*`。**這兩道改寫單獨看都沒有錯**：Markdown 本來就在換行處結束一個標題，少一個空行原本不痛不癢，直到消除換行的規則把那個換行也一起拿走為止。

## 總結

把文件放進 `git` 當來源，本質上先是一個正本歸誰的決定，其次才輪到工具怎麼選。**正本如果在別的地方，產生 PDF 只是多生一個版本出來而已。** 要等 repo 真正持有這份文件、它的差異也變得可以審查，好處才會出現。

大部分的成本來自兩件事：

1. 差異要有意義，內文得先按句子邊界重新換行；而日文還多一道，渲染器在輸出時得把那些換行再消掉一次。
2. 一次動到每一行的遷移，必須自己證明什麼都沒改。這需要三樣東西：一個忽略空白的比較、一項專門盯著該比較所忽略的控制字元的斷言，以及整行移除而非字串比對的刻意刪除。

## 參考連結

- [Semantic Line Breaks，依意義單位換行的慣例](https://sembr.org/)
- [WeasyPrint 的文件，包含它在匯入時載入的 Pango 與系統函式庫相依](https://doc.courtbouillon.org/weasyprint/stable/)
- [CSS Text Module Level 3，其區段換行的轉換規則會丟掉兩個東亞字元之間的換行](https://www.w3.org/TR/css-text-3/#line-break-transform)
- [Python difflib，其 SequenceMatcher 的 opcode 產生了文中所述的最長共同子序列對齊](https://docs.python.org/3/library/difflib.html)
