Markdownを出所にGitで仕様書を管理する — PDFはWeasyPrintで都度生成

仕様書の出所をGitに置く。差分が読めるようにするセマンティックな改行、本文が変わっていないことの検証、そしてWeasyPrintによるPDFの都度生成について。

白いカードに、緑色の山括弧と緑色の角丸長方形に挟まれた濃紺の小文字のweasyprintというWeasyPrintのロゴ
目次

はじめに

どの版の仕様書に対して実装しているのか、たびたび分からなくなっていました。文書はGoogle Driveにあってgitには写しがなく、誰かの編集が入っても手元の作業ツリーは何も変わりません。1日おいた2つのエクスポートを差分で比べたところ、見たこともない編集がコードのふるまいを変えていました。

エクスポートをコミットしても解決しません。PDFはgitが毎回まるごと保存するバイナリで、HTMLのエクスポートは行の折り返しが毎回変わります。どちらも1語の修正が文書全体の書き換えとして現れます。

そこで仕様書はMarkdownとしてリポジトリに移し、PDFはWeasyPrintで都度作るものにしました。

Markdownはリポジトリに、PDFは都度生成

レビュアーとエージェントの両方が1行ずつ読める候補は、Markdownだけでした。Claude Codeなら、仕様書を古くしたコードと同じコミットでその仕様書を直せます。

あとはPDFに都度変換する手段さえあれば、いつでもクライアントに渡せます。WeasyPrintがそれを簡単にしてくれました。

出所はどこにあるか — リポジトリかホスト型エディタか

ドキュメントのツールを決める前に片づけるべき問いは、正を誰が編集するのかです。 リポジトリが正なら、そこから配布物を生成します。正が外にあるなら、ミラーと追跡という別の仕事になり、見返りも別物になります。

正がどこにあるかで PDF 生成の意味が変わるカードが左右に 2 枚並ぶ。左はリポジトリが正を持つ場合で、Markdown が正であり、そこから PDF を生成する。正は 1 つだけで、差分もレビューできる。右はホスト型エディタが正を持つ場合で、文書を Markdown のミラーへエクスポートし、そこから PDF を生成する。1 つの文書が 3 つになり、ファイル単位より細かい差分は取れない。リポジトリが正を持つMarkdown正生成PDF読者に渡す正は 1 つだけ。差分がレビューできる。ホスト型エディタが正を持つホスト型エディタ正エクスポートMarkdown のミラーエクスポートごとに折り返しが変わる生成PDF1 つの文書が 3 つになる。ファイル単位より細かい差分が取れない。
Markdownのソースと生成されたPDFは、2通りの配置のどちらにもなります。プロジェクトがどちらにいるかで、PDFを生成する意味があるのか、3つ目の写しが増えるだけなのかが決まります。

ここでは正がホスト型エディタでした。Markdownの写しをコミットしてPDFを生成すれば、出所が別の場所にある文書の3つ目の描画ができ、どちらとも競合します。

差分で比べた2つのエクスポートは、表が1つと、未解決事項の番号の振り直しで違っていました。番号を振り直したリストはリンクをすべて生かしたまま、それらしい別の項目を静かに指すようになります。どれだけ丁寧に読んでも気づけない種類の変更です。

差分が読めるように本文を折り返し直す

セマンティックな改行、1文1行

エクスポートされた仕様書でいちばん長い行は519文字でした。その幅では1語の修正が段落まるごとのハンクになり、レビュアーは何が動いたのかを探すために全部を読むことになります。

Semantic Line Breaksがこれを直す作法です。文ごとに改行し、必要なら独立節のあとでも改行します。Markdownは連続する行を1つの段落にまとめるので、表示は変わらないまま、ソースが行単位で指せるようになります。

同じ 1 語の修正でも変わる差分の大きさパネルが上下に 2 つ並ぶ。上は段落を 519 文字の 1 行として保存した場合で、折り返された 4 行すべてが変更として強調される。下は同じ段落を 1 文 1 行で保存した場合で、4 行のうち 2 行目だけが強調される。修正内容はどちらも同じで、レビュアーが読まなければならない範囲の大きさだけが違う。ソース: 1 行 519 文字変更として表示される範囲:段落ぜんぶソース: 1 文 1 行変更として表示される範囲:1 文だけ
同じ1語の修正を、1行が長い場合と1文1行の場合で比べたもの。

折り返し直しは手作業ではなくスクリプトで行いました。日本語の句点。のあとで分けるのはスクリプトが一様に適用できる規則です。しかもスクリプトは500行の文書の途中で飽きて、ついでに別のところを整え始めたりしません。

日本語のソフト改行はPDFで空白になる

1文1行には、英語にはなく日本語にだけ出る副作用があります。ソフト改行はHTMLでは改行文字になり、レンダラはそれを空白にまとめます。英語なら2文のあいだにその空白が欲しいところです。

日本語は文と文のあいだに空白を置きません。そのため差分のために入れた改行は、段落の途中の隙間として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]*がないとこのパターンは、箇条書きだらけの仕様書がまさに作っている行を取りこぼします。

ソースは1文1行のままです。つぶすのはレンダラに渡す写しだけです。

エクスポートはそのままではコミットできません。 バックスラッシュのエスケープ、文書をまたぐリンク、改行のすべてを先に書き換える必要があり、その作業はほぼ全行に触ります。だからこそ「本文は変わっていないのか」を読んで答えることができなくなります。

移管で何も変わっていないことを証明する

エクスポートをレビューできる形にする

証明すべきことは狭いものでした。空白以外の文字は1つも変わっていない、ということです。

書き換えを行うスクリプトは、要素を1つずつ足しながら作りました。エスケープを外して再実行。リンクを書き換えて再実行。表を変換して再実行。差分にまだ出ているものが、片づいていない部分です。つまりこの検査はそのままやることリストになります。

意図した追加だけが残るまで差分を減らしていくループ元のエクスポートを機械変換スクリプトに通す。スクリプトには1周ごとに規則を1つ足し、そこから期待するテキストを毎回作り直す。その期待するテキストと、リポジトリにある現在のファイルを、空白をすべて除いて比較する。比較が報告するのが残った差分であり、まだ片づいていない部分にあたる。そこから次の規則を足して同じ流れに戻る。意図した追加だけが残れば完了となる。元のエクスポート手を入れない機械変換スクリプト1周ごとに規則を1つ足す期待するテキスト毎回作り直す現在のファイルリポジトリのもの空白をすべて除いて比較する残った差分まだ片づいていない部分次の規則を足す意図した追加だけが残れば完了
1周ごとにスクリプトへ規則が1つ増えるので、期待するテキストは毎回別の値になります。比較が報告するのは残りの作業であって、合否ではありません。

期待するテキストを保存せずに毎回作り直すのは、このためです。スクリプトは実行のあいだに育つので、保存した写しは古いスクリプトの出力になり、比較はもう存在しない書き換えを試すことになります。

手作業の段階は、スクリプトには決められないものすべてです。メタデータの表、改訂履歴、未解決事項の一覧に振る安定した識別子。これらは追加であり、検査が示すべきなのは、それらが追加だけであることです。

両側から空白を取り除けば、2つの文字列は意図して足したところだけで違うはずです:

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

そのあと差分が報告する削除は、すべて説明がつかなければなりません。

これはスクリプトを書いているあいだに走らせたもので、CIではありません。 Markdownが出所になった以上その内容は変わってよいものなので、この検査を常設すれば最初のまともな編集で落ちます。

意図した削除は差分の前に取り除く

意図した削除もありました。取り消し線の記号、表の列見出し、すでに存在しない文書へのリンクです。検査はそれらを見逃し、ほかは落とす必要があったので、最初の版は消えてよい文字列を完全一致の許可リストで持っていました。その許可リストに載っている文字列で落ちました。

difflibは人が読むようには行をそろえません。最長の共通部分列を探し、それを削除された行の内部に見つけます。

比較は文書ごとに、空白をすべて除いた1つの文字列に対して走ります。そしてそのファイルは同じホスト型のワークスペースを十数回リンクしていました。そのため削除されたリンクには、そろえる相手になるほとんど同じテキストが別の場所にありました。

その根拠で「残った」と判定された断片が3つあります。oogle、oc、そして単独のsです。行は4つのばらばらな削除として現れ、そのあいだにこの3つが挟まりました。行全体をリストに載せても役に立ちません。行全体が1つのopcodeとして現れることはないからです。

完全一致の許可リストが削除に一致しない理由3つの行がある。1行目は削除したいリンクを丸ごと示す。2行目はdifflibが報告したもので、同じ文字列が7つの断片に分かれている。リンクの中の3箇所が、残ったリンクにも現れるため「残った」と判定され、その間が4つの別々の削除になるからである。3行目は許可リストの項目で、行を丸ごと持っているため、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
許可リストが持っているのは行です。検査が報告したのは4つの削除で、ファイル内の別のリンクに対してそろえられた3つの断片がそのあいだに挟まっており、リストの項目はそのどれとも一致しません。文書のIDは伏せてあり、ほかはそのままです。

そこで意図した削除は、比較の前に期待するテキストから行ごと取り除きます。 取り除く前にその行があったことを検査で確かめるので、リストの打ち間違いは、何か別のものを黙って見逃すのではなく、はっきり落ちます。

すべての検査をすり抜けた垂直タブ

移管後のファイルにはU+000Bの垂直タブが14個残っていて、パイプラインのどれ1つとしてそれを報告していませんでした。

エクスポータは表のセルに改行があると1つ吐きます。制御文字なのでどのレンダラも描きませんし、grepにも出てきません。先ほどの空白を無視する比較も、それを空白として数えて飛ばしていました。str.rstrip()はすでに行末から1つ食べていて、誰も気づいていませんでした。

<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を書きます。そのあいだのものは何もコミットせず、ソースのファイルも書き換えません:

1つのMarkdownがPDFになるまで左から右へ進むパイプライン。リポジトリのMarkdownは読むだけで書き換えない。そのコピーに2つの変換をかける。見出しからアンカーを外し、日本語どうしの間の改行をつぶす。結果をHTMLにし、WeasyPrintがPDFに描画する。もう1つの経路では、同じMarkdownをparse_metaで読んで表紙を作り、これもWeasyPrintに渡す。リポジトリのMarkdown読むだけで書き換えない見出しのアンカーを外す日本語間の改行をつぶすHTMLWeasyPrintPDFparse_meta表紙タイトル・副題・版数
変換の前に2つの書き換えを写しに対して行い、表紙は同じファイルをもう一度、別に読んで作ります。これは検証の節で扱っているGoogle Docsからの移管とは別のパイプラインです。

版数の出所を1つにする

スクリプトを移植してきたプロジェクトは、文書の版数を2か所に持っていました。Markdownの先頭の表と、表紙を刷るビルドスクリプトの定数です。改訂を上げるには4か所を直す必要があり、2つの写しは食い違ってよい状態でした。

ビルドは代わりに表を読みます。 parse_metaという関数が最初の---でファイルを分け、タイトル、サブタイトル、そしてその上にある2列の行をすべて取って、表紙のテンプレートに渡します。版数が欠けていれば、表紙の欄が空のPDFを作るのではなくビルドを中止します。

PDFはgitignoreする

WeasyPrintは生成時刻をファイルに刻みます。そのため内容が同じでも作り直すたびにバイト列が変わり、PDFをコミットしているリポジトリはビルドのたびに数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は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を入れた人のライブラリ読み込みが壊れます。

行末の空白は\s*ではなく[ \t]*で拾う

ビルドは変換の前に、各見出しから`<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を生成しても3つ目の版が増えるだけです。 見返りが出るのは、リポジトリが文書を持ち、その差分がレビューできるようになってからです。

費用の大半を占めたのは2つです:

  1. 差分に意味が出る前に、本文は文の切れ目で折り返し直さなければなりません。そして日本語では、出口でその改行をもう一度つぶす必要があります。
  2. 全行に触る移管は、何も変えていないことを証明しなければなりません。それには空白を無視する比較と、その比較が無視する制御文字に対する別の表明と、文字列一致ではなく行ごとに取り除く意図した削除が要ります。

参考リンク

この記事をシェア