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

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

白色卡片上的 WeasyPrint 標誌,深藍色的小寫 weasyprint 字樣夾在綠色角括號與綠色圓角長方形之間
本頁目錄

引言

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

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

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

Markdown 放在 Git,PDF 隨時產生

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

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

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

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

正本放在哪裡,決定產生 PDF 有沒有意義兩張卡片並排。左邊是 repo 持有正本的情況,Markdown 就是正本,再由它產生 PDF,正本只有一份,差異也可以審查。右邊是線上編輯器持有正本的情況,文件先匯出成 Markdown 鏡像,再由鏡像產生 PDF,一份文件因此變成三份,而且拿不到比整個檔案更細的差異。repo 持有正本Markdown正本產生PDF交給讀者只有一份正本。差異可以審查。線上編輯器持有正本線上編輯器正本匯出Markdown 鏡像每次匯出都會重新斷行產生PDF一份文件變成三份。拿不到比整個檔案更細的差異。
一份 Markdown 原始檔加上一份產生出來的 PDF,可以是兩種完全不同的配置。專案屬於哪一種,決定了產生 PDF 到底有沒有意義,還是只多出第三份副本。

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

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

重新換行,讓差異讀得懂

語意換行,一句一行

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

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

同樣一個字的修正,差異區塊大小卻不同上下兩個面板。上方是把整段存成單一行 519 個字元的情況,換行後的四列全部被標示為變更。下方是同一段改成一句一行的情況,四列之中只有第二列被標示。兩者的修正內容完全相同,不同的只是審查者必須閱讀的範圍大小。原始碼:單行 519 個字元顯示為變更的範圍:整個段落原始碼:一句一行顯示為變更的範圍:只有一句
同樣改一個字,在一行很長與一句一行兩種情況下的比較。

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

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

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

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

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

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]*,這個樣式剛好就漏掉一份滿是條列的規格書最常出現的那種行。

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

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

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

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

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

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

把差異逐步縮小到只剩刻意加入內容的循環原始匯出檔送進機械轉換腳本,腳本每一輪加上一條規則,並由此每次重新產生預期的文字。這份預期的文字與 repo 中目前的檔案一起比較,比較時移除所有空白。比較回報的就是剩下的差異,也就是尚未處理的部分,作者據此加上下一條規則再跑一次。只剩下刻意加入的內容時循環就結束。原始匯出檔不加修改機械轉換腳本每一輪加一條規則預期的文字每次重新產生目前的檔案已提交在 repo移除所有空白之後比較剩下的差異尚未處理的部分加上下一條規則只剩下刻意加入的內容就結束
每一輪都會替指令碼加上一條規則,所以預期的文字每跑一次就是一個新的值。比較回報的不是通過與否,而是還剩多少事情沒做。

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

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

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

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

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

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

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

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

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

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

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

完全比對的許可清單為何對不上刪除共有三列。第一列完整顯示要刪除的連結。第二列是 difflib 回報的內容,同樣的字元被拆成七個片段,因為連結中有三處也出現在留下來的連結裡而被判定為保留,中間便成為四段各自獨立的刪除。第三列是許可清單的項目,它持有的是一整行,因此對不上 difflib 產生的任何一個片段。要刪除的連結https://docs.google.com/document/u/0/d/XXXXXXXXXXXXs-XXXXXXXX/editdifflib 回報的內容https://docs.google.com/document/u/0/d/XXXXXXXXXXXXs-XXXXXXXX/edit回報為刪除留下來的連結也有同樣的字元,因此判定為保留許可清單的項目對不上任何一個片段https://docs.google.com/document/u/0/d/XXXXXXXXXXXXs-XXXXXXXX/edit
允許清單持有的是一整行;檢查回報的卻是四段刪除,中間還夾著三個它拿去跟檔案裡其他連結對齊的碎片,兩邊對不上任何一處。文件 ID 已經遮蔽,其餘都是原樣。

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

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

遷移後的檔案存在著 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 如何變成 PDF由左至右的流程。repo 裡的 Markdown 只讀取而不改寫。對它的副本做兩道轉換:把標題裡的錨點取出,並消掉日文字元之間的換行。結果轉成 HTML,再由 WeasyPrint 繪製成 PDF。另一條分支用 parse_meta 讀取同一份 Markdown 產生封面,同樣交給 WeasyPrint。repo 裡的 Markdown只讀取,不改寫把標題裡的錨點取出消掉日文之間的換行HTMLWeasyPrintPDFparse_meta封面標題、副標、版次
轉換之前會對副本做兩道改寫;封面則是另外獨立讀一次同一份檔案產生的。這條流程和驗證那幾節談的 Google Docs 遷移是兩回事。

版次只留一個來源

我移植指令碼的那個專案,把文件版次放在兩個地方: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 目錄自然不在那些路徑裡面。把載入器指過去,建置就能夠運作了:

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*$ 會把標題後面的空行連同錨點一起帶走,而前面那條消除換行的規則接著就把「針」和「本」黏在一起:

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

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

總結

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

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

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

參考連結

分享這篇文章