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

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

- Source: https://oharu121.com/ja/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`には写しがなく、誰かの編集が入っても手元の作業ツリーは何も変わりません。1日おいた2つのエクスポートを差分で比べたところ、**見たこともない編集がコードのふるまいを変えていました**。

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

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

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

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

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

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

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

*Figure — MastershipDecision: Markdownのソースと生成されたPDFは、2通りの配置のどちらにもなります。プロジェクトがどちらにいるかで、PDFを生成する意味があるのか、3つ目の写しが増えるだけなのかが決まります。*

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

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

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

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

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

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

*Figure — DiffGranularity: 同じ1語の修正を、1行が長い場合と1文1行の場合で比べたもの。*

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

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

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

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

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

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

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

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

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

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

*Figure — VerificationStages: 1周ごとにスクリプトへ規則が1つ増えるので、期待するテキストは毎回別の値になります。比較が報告するのは残りの作業であって、合否ではありません。*

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

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

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

```python
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として現れることはないからです。

*Figure — DiffFragments: 許可リストが持っているのは行です。検査が報告したのは4つの削除で、ファイル内の別のリンクに対してそろえられた3つの断片がそのあいだに挟まっており、リストの項目はそのどれとも一致しません。文書のIDは伏せてあり、ほかはそのままです。*

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

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

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

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

`<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")
}
```

> **気を付けて**
>
> **空白を無視する検証には、無視しているものを見る別の検査が要ります。** 制御文字はその2つのあいだに落ちます。だからこそ、変更を捕まえるために作った比較をすり抜けました。

## PDFを都度生成する

ビルドはMarkdownを読んでPDFを書きます。そのあいだのものは何もコミットせず、ソースのファイルも書き換えません:

*Figure — PdfPipeline: 変換の前に2つの書き換えを写しに対して行い、表紙は同じファイルをもう一度、別に読んで作ります。これは検証の節で扱っているGoogle Docsからの移管とは別のパイプラインです。*

### 版数の出所を1つにする

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

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

### PDFはgitignoreする

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

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

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

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

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)
