在 Git 中用 Markdown 來管理規格書的正本,並使用 WeasyPrint 來隨時產生 PDF
規格書的正本搬進 Git:用語意換行換回可讀的差異,用一道檢查證明內文沒被改動,再用 WeasyPrint 隨時產出交件用的 PDF。

本頁目錄
引言
我常常搞不清楚手上的程式碼,對應的到底是規格書的哪一版。規格書放在 Google Drive,git 看不到,所以別人改了什麼,我的工作目錄一點動靜也沒有。後來我把相隔一天的兩份匯出檔拉出來比對,才發現有些修改我根本沒看過,而且已經改掉了程式該做的事。
那把匯出檔一起提交進去呢?沒用。PDF 在 git 眼中是二進位檔,每一版都整份存一次;HTML 匯出檔則是每匯出一次,換行就換一個位置。結果一樣:改一個字,差異看起來像整份文件被重寫。
所以我最後選擇把規格書搬進了 Git,以 Markdown 的形式儲存。PDF 則交給 WeasyPrint 隨時產生。
Markdown 放在 Git,PDF 隨時產生
能讓審查的人和 AI 代理都逐行讀懂的格式,只有 Markdown。 而且規格書一旦因為程式改動而失準,Claude Code 可以在同一次 commit 裡順手把它補上。
剩下的就只有一件事:需要一個隨時能轉出 PDF 的辦法,才能隨時交件給客戶。這件事可以用 WeasyPrint 輕鬆辦到。
來源在哪一邊 — repo 還是線上編輯器
挑文件工具之前,得先決定一件事:正本由誰編輯。 正本放在 repo,接下來要做的就是從它產生各種發布用的檔案;正本放在外面,那要做的其實是鏡像和追蹤,是完全不同的一件事,帶來的好處也不一樣。
一開始正本是放在 Google Drive。這種情況下需要先匯出 Markdown 副本後放進 Git、再從它生成 PDF,等於讓同一份文件存在著三種樣貌,而來源還在 Google Drive,三份互相打架。
我比對的那兩份匯出檔,差別是多了一個表格,還有未解決事項的清單被重新編號。重新編號最麻煩的地方在於:每個連結都還是通的,只是悄悄指向了另一個看起來也很合理的項目。這種改動再仔細讀也讀不出來。
重新換行,讓差異讀得懂
語意換行,一句一行
匯出來的規格書裡,最長的一行有 519 個字元。這種狀況下改一個字,差異區塊就會涵蓋整個段落,審查的人得整段讀完,才找得到動過的地方。
Semantic Line Breaks 就是為此而生的慣例:一句話結束就換行,必要時獨立子句之後也可以換。Markdown 本來就會把連續的行併成同一段,所以表面上看起來完全沒變,原始檔卻變得可以逐行指認。
這件事是寫成指令碼跑的,不是手動改的。在日文句號 。 後面切開,是指令碼可以一致套用的規則;更重要的是,指令碼不會做到一份 500 行的文件中途覺得無聊,順手去動別的地方。
日文的軟換行在 PDF 裡會變成空白
一句一行在日文裡有一個英文沒有的副作用。軟換行到了 HTML 就是換行字元,渲染器再把它收成一個空白。而英文剛好需要這個空白來隔開兩個句子。
但日文的句子之間根本就沒有空白,中文也一樣(日中韓特性接近,俗成 CJK)。 於是為了差異而加進去的每一個換行,在 PDF 裡都變成段落中間莫名其妙的空隙。
CSS Text 規格其實處理了這件事:夾在兩個東亞字元之間的換行會被丟掉,瀏覽器也都落實了。但如果依賴它,就等於賭 PDF 渲染器也落實了同一套作法。與其賭,不如讓建置流程在轉換前就先去掉:
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]*,這個樣式剛好就漏掉一份滿是條列的規格書最常出現的那種行。
原始檔維持一句一行不變,被動手的只有交給渲染器的那份副本。
匯出檔沒辦法照原樣提交。 反斜線跳脫、跨文件連結、換行,全都得先改寫一遍,而這一遍幾乎每一行都會動到。正因為每一行都動過,「內文到底有沒有變」這個問題,用眼睛讀是答不出來的。
證明這次遷移什麼都沒改到
把匯出檔變成可以審查的東西
要證明的東西其實很單純:除了空白以外,一個字元都沒有變。
負責改寫的指令碼是一塊一塊長出來的。拿掉跳脫,重跑;改寫連結,重跑;轉換表格,再重跑。每一輪剩下的差異,就是還沒處理完的部分,所以這項檢查本身就是一個待辦清單。
預期的文字之所以每次重算、而不是存起來,是因為指令碼會在兩次執行之間增大;存下來的那一份等於是舊版指令碼的產物,拿它來比,比的是一個已經不存在的改寫版本。
剩下的人工部分,是指令碼無法判斷的部分:一張中繼資料表格、一份修訂紀錄、未解決事項清單上穩定的識別碼,這些都是新加的內容。所以檢查要確保的是,只有新增,沒有別的。
把兩邊的空白都拿掉之後,兩個字串應該只在刻意加東西的地方不一樣:
WS = re.compile(r"\s+")a, b = WS.sub("", expected), WS.sub("", current)接下來差異回報的每一處刪除,都要能夠被解釋。
這項檢查是寫指令碼那段期間在跑的,沒有放進 CI。 Markdown 成為來源之後本來就會一直被改,這種檢查如果常駐,第一次正常的編輯就會出錯。
刻意的刪除要在比對之前先移掉
有些刪除是故意的:刪除線記號、表格欄標題,還有一個指向早就不存在的文件的連結。檢查得放過這幾個刻意的變更、而其他的變動一律視作錯誤。第一版採用的做法是列出一份完全比對的允許刪除清單,列入刻意刪除的字串。結果清單上明明有的字串,照樣被判定為錯誤。
difflib 對齊文字的方式跟人讀的方式不一樣。人看到的是「這一行整個不見了」,而它只是拿兩份字元流去找兩邊都有的最長片段。問題是,有些片段就躲在你要刪掉的那一行裡面:只要同樣幾個字在後面還出現過,它就會把那幾個字判定成留下來的,在這段刪除中間打出一個洞。
比較是把整份文件當成一個移除所有空白的字串在跑的,而那份檔案前前後後連了同一個線上工作區十幾次。換句話說,被刪掉的那條連結,在檔案別的地方有一堆幾乎一模一樣的文字可以對齊。
就這樣,有三個碎片被判定成留下來的:oogle、oc,還有孤單一個 s。那一行因此被拆成四段互不相連的刪除,三個碎片卡在中間。把整行寫進清單也沒有用,因為整行從頭到尾就沒有以單一個 opcode 的形式出現過。
所以正確的做法是:在比較之前,就把刻意刪除的那幾行整行從預期的文字裡拿掉。 拿掉之前會先確認那一行真的存在,這樣清單裡打錯字就會直接報錯,而不是安安靜靜地放過。
通過所有檢查的垂直定位字元
遷移後的檔案存在著 14 個 U+000B 垂直定位字元,而整條流程完全沒有任何相關的處理。
匯出工具只要碰到表格儲存格裡有換行,就會塞一個進去。它們是控制字元,所以渲染器無法畫出來,grep 也搜不到;前面那個忽略空白的比較更是直接把它們當成空白跳過去。str.rstrip() 早就從某一行結尾吃掉了一個,也沒有人發現。
修正方式是把它們轉成 <br>,而且要排在去除行尾空白之前,才不會被 rstrip 搶先一步。
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,原始檔也從頭到尾不會被改寫:
版次只留一個來源
我移植指令碼的那個專案,把文件版次放在兩個地方:Markdown 開頭的表格,以及印封面用的建置指令碼裡的常數。升一次修訂要改四個地方,而且這兩份副本隨時可能對不上。
改成讓建置流程直接讀那張表格。 parse_meta 這個函式會在第一個 --- 把檔案切開,把標題、副標題,以及上面每一列兩欄的資料都取出來交給封面範本。版次要是缺了,建置會直接中止,而不是產出一份封面開天窗的 PDF。
PDF 列進 gitignore
WeasyPrint 會把產生時間寫進每一份檔案。 這表示內容一個字沒改,重新產生出來的位元組還是不一樣;如果提交 PDF 到 repo,每建置一次就多幾 MB 的差異。所以乾脆一開始什麼都不提交,檔名也就不必背負版次,直接取自文件自己的標題就好。
WeasyPrint 在 macOS 上找不到自己的函式庫
brew install pango 裝好了,建置還是掛:
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 目錄自然不在那些路徑裡面。把載入器指過去,建置就能夠運作了:
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*$ 會把標題後面的空行連同錨點一起帶走,而前面那條消除換行的規則接著就把「針」和「本」黏在一起:
## 実装方針
本書では設計と実装の対応を示す。## 実装方針本書では設計と実装の対応を示す。// the heading swallowed its own first paragraph目錄就這樣把整行都收了進去,而修正方式是 [ \t]*。這兩道改寫單獨看都沒有錯:Markdown 本來就在換行處結束一個標題,少一個空行原本不痛不癢,直到消除換行的規則把那個換行也一起拿走為止。
總結
把文件放進 git 當來源,本質上先是一個正本歸誰的決定,其次才輪到工具怎麼選。正本如果在別的地方,產生 PDF 只是多生一個版本出來而已。 要等 repo 真正持有這份文件、它的差異也變得可以審查,好處才會出現。
大部分的成本來自兩件事:
- 差異要有意義,內文得先按句子邊界重新換行;而日文還多一道,渲染器在輸出時得把那些換行再消掉一次。
- 一次動到每一行的遷移,必須自己證明什麼都沒改。這需要三樣東西:一個忽略空白的比較、一項專門盯著該比較所忽略的控制字元的斷言,以及整行移除而非字串比對的刻意刪除。

