diff --git a/issues/PLAN63_base-image-rendering-design.md b/issues/PLAN63_base-image-rendering-design.md new file mode 100644 index 00000000..e5acd3b8 --- /dev/null +++ b/issues/PLAN63_base-image-rendering-design.md @@ -0,0 +1,425 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 の設計 + +要求と受け入れ条件は [PLAN63_base-image-rendering.md](PLAN63_base-image-rendering.md) にある。 +この文書は「どう作るか」だけを扱う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 総称ファミリ(`sans-serif` / `sans` / `serif` / `monospace`)と、イメージに無い書体名の指定を、Noto CJK の JP フェイスへ向ける | base とその派生イメージで文字を描くすべての道具(Chromium / Playwright / PDF の生成 / 画像の生成) | +| F2 | 中国語・韓国語を明示した指定を、その言語の、同じ様式のフェイスへ向ける | 同上 | +| F3 | PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測る道具を base へ入れる | `document-skills` を使う利用者 | +| F4 | 上の 3 つを回帰テストで固定し、利用者向け文書と CHANGELOG を合わせる | devbase の開発者・利用者 | + +## 解決の経路 + +fontconfig は `/etc/fonts/fonts.conf` から始まり、そこにある `` 1 行で `conf.d` を +**ファイル名の番号順**に読む。`local.conf` は `conf.d/51-local.conf` 経由で読まれる。 + +```mermaid +graph TD + FC["/etc/fonts/fonts.conf
include conf.d の 1 行だけ"] --> D[conf.d を番号順に読む] + D --> C30["30-metric-aliases.conf
Arial→Liberation Sans
Calibri→Carlito / Cambria→Caladea"] + C30 --> C50["50-user.conf
→ ~/.config/fontconfig/fonts.conf"] + C50 --> C51["51-local.conf
→ /etc/fonts/local.conf
★ 置き場所はここ"] + C51 --> C64["64-wqy-zenhei.conf
sans-serif の prefer に WenQuanYi Zen Hei"] + C64 --> C65["65-nonlatin.conf
sans-serif の prefer 一覧にも WenQuanYi Zen Hei"] + C65 --> C70["70-fonts-noto-cjk.conf"] +``` + +**`` は、一致した総称ファミリの直前へ挿入する(prepend)。** そのため +**最も早く読まれた `` が先頭に残る。** 効くのは順序が後だからではなく、先だからである。 +`51` は `64` / `65` より先なので、`local.conf` の指定が勝つ。 + +## 実測(2026-09-22 / arm64 の `devbase-base:latest`) + +Ubuntu 26.04 / fontconfig 2.17.1 / イメージのサイズ 7.09GB。稼働中のコンテナへ設定と +パッケージを入れて測った(イメージは建て直していない)。 + +### 解決先の表(この設計が固定するもの) + +| 指定 | 変更前 | 変更後 | +| --- | --- | --- | +| `sans-serif` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `sans-serif:lang=ja` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `sans` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `serif` | WenQuanYi Zen Hei | **Noto Serif CJK JP** | +| `monospace` | WenQuanYi Zen Hei Mono | **Noto Sans Mono CJK JP** | +| `Noto Sans JP` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Meiryo` / `Yu Gothic` / `MS PGothic` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Zen Kaku Gothic New`(未導入) | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Arial` | Liberation Sans | Liberation Sans | +| `Times New Roman` | Liberation Serif | Liberation Serif | +| `Courier New` | Liberation Mono | Liberation Mono | +| `Calibri` | WenQuanYi Zen Hei | **Carlito**(パッケージの追加による) | +| `Cambria` | WenQuanYi Zen Hei | **Caladea**(同上) | +| `sans-serif:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | +| `sans:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | +| `serif:lang=zh-cn` | Noto Serif CJK SC | Noto Serif CJK SC | +| `monospace:lang=zh-cn` | Noto Sans Mono CJK SC | Noto Sans Mono CJK SC | +| `sans-serif:lang=ko` | WenQuanYi Zen Hei | **Noto Sans CJK KR** | +| `sans:lang=ko` | WenQuanYi Zen Hei | **Noto Sans CJK KR** | +| `serif:lang=ko` | WenQuanYi Zen Hei | **Noto Serif CJK KR** | +| `monospace:lang=ko` | WenQuanYi Zen Hei Mono | **Noto Sans Mono CJK KR** | +| `Arial:lang=zh-cn` | Liberation Sans | Liberation Sans | +| `Times New Roman:lang=zh-cn` | Liberation Serif | Liberation Serif | +| `Arial:lang=ko` | Liberation Sans | Liberation Sans | +| `WenQuanYi Zen Hei`(名指し) | WenQuanYi Zen Hei | WenQuanYi Zen Hei | +| `IPAPGothic`(名指し) | IPAPGothic | IPAPGothic | + +`fc-match -s sans-serif:lang=ja` の並びも変わる。 + +| | 1 番目 | 2 番目 | 3 番目 | +| --- | --- | --- | --- | +| 変更前 | WenQuanYi Zen Hei | IPAPGothic | Loma(タイ語) | +| 変更後 | **Noto Sans CJK JP** | WenQuanYi Zen Hei | IPAPGothic | + +### パッケージの追加の実測 + +`apt-get install --no-install-recommends` で 6 つを指定した結果である。 + +| 項目 | 値 | +| --- | --- | +| 指定するパッケージ | 6 | +| 依存を含めて新規に入るパッケージ | **24** | +| ディスクの増分 | **約 24 MB**(apt のリストとキャッシュを含む測り方。7.09GB に対して +0.34%) | +| 使えるようになるコマンド | `pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo` | +| 使えるようになるモジュール | `PIL` 12.1.1 / `defusedxml` 0.7.1 / `lxml` 6.0.2 | +| 入らないもの | `soffice` / `libreoffice` / `pip` / `pip3` | + +## 構成要素 + +| 要素 | 変更 | 責務 | +| --- | --- | --- | +| `containers/base/fonts-local.conf`(新設) | 足す | 総称ファミリの先頭を日本語フェイスにする。未導入の書体の受け皿を置く。中国語・韓国語を明示した指定を守る。**先頭に、置き場所を動かせない理由をコメントで持つ** | +| `containers/base/Dockerfile` の 1 つ目の `RUN` の `apt-get install` | 変える | `fonts-noto-cjk` の行の隣へ 6 パッケージを足す | +| `containers/base/Dockerfile` の末尾の `COPY` 群 | 変える | `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` を足し、直後に `RUN sudo fc-cache -f` を置く | +| `tests/containers/test_base_dockerfile_fonts.py`(新設) | 足す | Docker を起動せずに、Dockerfile と `fonts-local.conf` の**形**を固定する | +| `tests/containers/test_base_image_font_matching.py`(新設) | 足す | 建てたイメージの中で `fc-match` の**解決先の表**を固定する。イメージが無い / 古いときは skip | +| `docs/user/container-operations.md` | 変える | base の説明に、日本語のフェイスと文書の道具の行を足す | +| `CHANGELOG.md` | 変える | `[Unreleased]` に `### Added`(6 パッケージ)と `### Fixed`(日本語が中国語フォントで描画される)。**イメージを建て直すまで反映されない**ことを添える | + +次のものは変えない。 + +- `fonts-wqy-zenhei` を削除しないこと(要求の前提 2)。Dockerfile に導入の行は無く、 + `npx playwright install --with-deps chromium` が依存として入れる +- `npx playwright install --with-deps chromium` とその後のクリーンアップ(前提 5、#220) +- 派生イメージの Dockerfile(すべて `FROM devbase-base:latest`) +- `containers/lfm`(base 由来ではない) + +## 入出力の契約 + +### `containers/base/fonts-local.conf` + +先頭のコメントは**受け入れ条件 8 が要求する内容**(置き場所を動かせない理由と、その実測)を持つ。 + +```xml + + + + + + sans-serifNoto Sans CJK JP + sansNoto Sans CJK JP + serifNoto Serif CJK JP + monospaceNoto Sans Mono CJK JP + + + + Noto Sans CJK JP + + + + + sans-serif + zh-cn + Noto Sans CJK SC + + + +``` + +規則は 4 つの `` と 9 つの `` で構成する。`` の内訳は、未導入の書体の +受け皿が 1 つと、**総称ファミリ 4 つ × 言語 2 つ = 8 つ**である。 + +| 総称ファミリ | `lang=zh-cn` で前置する | `lang=ko` で前置する | +| --- | --- | --- | +| `sans-serif` | `Noto Sans CJK SC` | `Noto Sans CJK KR` | +| `sans` | `Noto Sans CJK SC` | `Noto Sans CJK KR` | +| `serif` | `Noto Serif CJK SC` | `Noto Serif CJK KR` | +| `monospace` | `Noto Sans Mono CJK SC` | `Noto Sans Mono CJK KR` | + +これらのフェイスは `fonts-noto-cjk` / `fonts-noto-cjk-extra` に既にあり、追加の導入は要らない +(`fc-list : family` で JP / SC / KR の Sans・Serif・Sans Mono の 9 つの存在を確認済み)。 + +### `containers/base/Dockerfile` の差分の形 + +**1 つ目の `RUN`(最初の `apt-get install`)**: `fonts-noto-cjk fonts-noto-cjk-extra` の行の隣へ +足す。理由は決定 4。 + +```dockerfile + libnss3 libxrandr2 libxss1 \ + fonts-noto-cjk fonts-noto-cjk-extra \ + fonts-crosextra-carlito fonts-crosextra-caladea \ + poppler-utils python3-pil python3-defusedxml python3-lxml; \ +``` + +**末尾の `COPY` 群**(`ai-cli-aliases.sh` / `tmux.conf` と同じ区画、`USER ubuntu` より後): + +```dockerfile +COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf +RUN sudo fc-cache -f +``` + +`COPY` の所有者は `USER` の指定によらず root になるため、既存の `tmux.conf` と同じ形でよい。 +`fc-cache` は root で走らせる必要があるので `sudo` を付ける(同じ区画の +`sudo install -d` / `sudo ln -sf` と同じ流儀。`ubuntu` は NOPASSWD の sudo を持つ)。 + +## 処理の流れ + +ビルドの層と、この変更が触る位置。 + +```mermaid +graph TD + L1["RUN 1: apt(locales / git / fonts-noto-cjk …)
+ docker / terraform / gh / node / chromium
★ ここへ 6 パッケージを足す"] --> L2["RUN: ユーザーとグループ"] + L2 --> L3["RUN: aws / gcloud / uv / npm globals"] + L3 --> L4["RUN: bao"] + L4 --> L5["RUN(ubuntu): claude / agy / kiro
npx playwright install --with-deps chromium
→ ここで fonts-wqy-zenhei が入る
→ 直後に sudo rm -rf ~/.cache"] + L5 --> L6["COPY 群: ai-cli-aliases.sh / tmux.conf / entrypoint.sh
★ ここへ fonts-local.conf の COPY と fc-cache -f を足す"] +``` + +**`fc-cache -f` は `L5` より後でなければならない。** `L5` の `--with-deps` が +`fonts-wqy-zenhei` / `fonts-ipafont-gothic` / `fonts-liberation` などを入れ、その直後に +`~/.cache` を消す。`L5` より前で走らせると、後から入った書体を知らないキャッシュが残る。 + +## 決定の記録 + +### 決定 1: 置き場所は `/etc/fonts/local.conf` から動かさない + +`conf.d/99-*.conf` へ置くと効かない。同じ内容で実測した(2026-09-22、arm64)。 + +| 置き場所 | `fc-match sans-serif` | `fc-match sans-serif:lang=ja` | +| --- | --- | --- | +| `/etc/fonts/local.conf` | **Noto Sans CJK JP** | **Noto Sans CJK JP** | +| `/etc/fonts/conf.d/99-devbase-fonts.conf` | WenQuanYi Zen Hei | WenQuanYi Zen Hei | + +理由は「解決の経路」に書いたとおりで、`` が prepend であり、**最も早く読まれた +`` が先頭に残る**ためである。`51-local.conf` は `64` / `65` より先に読まれる。 + +**この実測では、もう 1 つ分かったことがある。** `99-` へ置いた場合でも +`` の規則(zh-cn / ko の前置)は効いていた。効かないのは +`` だけである。**規則の種類によって順序への依存が違う**ため、 +「99 でも一部は効く」ことを知らないと、部分的に直ったのを見て置き場所の問題を見落とす。 + +この理由は `containers/base/fonts-local.conf` の先頭のコメントにも同じ内容で残す(受け入れ条件 8)。 + +**`conf.d/00-*.conf` のように小さい番号を使う案は採らない。** 動きはするが、 +`local.conf` は fontconfig が「システムの局所的な設定」のために用意した場所で、番号の +付け替えで順序を取りに行くのは他のパッケージの番号と競合する。 + +### 決定 2: 中国語・韓国語の規則は、総称ファミリを名指ししたときだけ効かせる + +**#161 の本文にある形(`lang` だけを見て前置する)は採らない。実測で壊れるものが見つかった。** + +3 つの変種を同じイメージの中で比べた(2026-09-22、arm64)。 + +**3 つの列はどれも `/etc/fonts/local.conf` を置いた状態である。** 違うのは zh-cn / ko の +`` の書き方だけで、4 つの `` と受け皿はどの列にもある。**`local.conf` を置く +前の値(今の base イメージそのまま)は、この表ではなく「解決先の表」の「変更前」の列にある。** +`local.conf` が無ければ `sans-serif` 自体が `WenQuanYi Zen Hei` なので、`lang=ko` も +`WenQuanYi Zen Hei` になる。`` を置いてはじめて、ko の指定が日本語のフェイスへ +引き寄せられる ── それがこの表の 1 列目である。 + +| 指定 | zh-cn / ko の規則なし | #161 の形(`lang` だけ) | この設計(`family` + `lang`) | +| --- | --- | --- | --- | +| `sans-serif:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | Noto Sans CJK SC | +| `serif:lang=zh-cn` | Noto Serif CJK SC | **Noto Sans CJK SC** ← 様式が崩れる | Noto Serif CJK SC | +| `monospace:lang=zh-cn` | Noto Sans Mono CJK SC | **Noto Sans CJK SC** ← 等幅でなくなる | Noto Sans Mono CJK SC | +| `sans-serif:lang=ko` | **Noto Sans CJK JP** ← 韓国語が日本語の字形になる | Noto Sans CJK KR | Noto Sans CJK KR | +| `serif:lang=ko` | **Noto Serif CJK JP** ← 同上 | **Noto Sans CJK KR** ← 様式が崩れる | Noto Serif CJK KR | +| `Arial:lang=zh-cn` | Liberation Sans | **Noto Sans CJK SC** ← 欧文が奪われる | Liberation Sans | +| `Times New Roman:lang=zh-cn` | Liberation Serif | **Noto Sans CJK SC** ← 同上 | Liberation Serif | + +**`binding="strong"` の前置は、名指しの書体よりも強い。** そのため `lang` だけを条件にすると、 +`lang=zh-cn` が付いたすべてのパターン ── `Arial` や `Times New Roman` を名指しした欧文の +指定を含む ── から、指定した書体を奪う。Chromium は中国語のページで `lang=zh-cn` を +載せるため、これは絵に描いた話ではない。 + +**zh-cn / ko の規則を 1 つも置かない案も採らない。** `zh-cn` は OS 既定の +`65-nonlatin.conf` と `70-fonts-noto-cjk.conf` が正しく扱うので規則なしでも合うが、 +**`ko` は合わない**(決定 8 の `sans-serif` の prefer が JP を先頭にするため、韓国語が +日本語の字形になる。これは `local.conf` を置いたことで初めて起きる)。 +`ko` だけ書くと非対称で、なぜ `zh-cn` が無いのかが後から読めない。8 つ並べて対称にする。 + +### 決定 3: 未導入の書体の受け皿は、弱い結合の `append` 1 つで足りる + +`` は、すべてのパターンの末尾へ +`Noto Sans CJK JP` を足す。**弱い結合なので、実在する指定を妨げない。** `Arial` は +`30-metric-aliases.conf`(スロット 30、このファイルより先)の強い結合で Liberation Sans へ +向くため、受け皿は末尾に付くだけで結果を変えない。実測で確認した(`Arial` / `Times New Roman` / +`Courier New` / `Calibri` / `Cambria` のいずれも変わらない)。 + +条件(``)を付けて「未導入のときだけ」足す形は採らない。fontconfig に「その書体が +実在するか」を問う `` は無く、弱い結合がまさにその働きをする。 + +### 決定 4: 6 パッケージは、1 つ目の `RUN` の最初の `apt-get install` の一覧へ足す + +Dockerfile の 1 つ目の `RUN` は `apt-get install` を 2 回呼ぶ。1 回目は Ubuntu の標準の +アーカイブから(`locales` / `git` / `fonts-noto-cjk` など)、2 回目は足したリポジトリから +(`docker-ce` / `terraform` / `gh` / `nodejs` / `chromium-browser`)。 + +**2 つの一覧はどちらも同じ `RUN` の中にあり、キャッシュの鍵は 1 つである。** どちらへ書いても +キャッシュの効き方は同じになる。6 つはすべて標準のアーカイブにあり外部のリポジトリを要さない +ので、1 回目の一覧へ、既にあるフォントの行の隣へ置く。 + +**新しい `RUN` を立てる案は採らない。** 立てれば、この 6 つを後から足し引きしても 1 つ目の +巨大な層のキャッシュは無効にならない。しかし代償が 3 つある。 + +| 代償 | 内容 | +| --- | --- | +| 層が 1 つ増える | base は既に層が多い。派生イメージ 7 つがこの上に積む | +| `apt-get update` をもう 1 回走らせる | 1 つ目の `RUN` は最後に `rm -rf /var/lib/apt/lists/*` でリストを消す。新しい `RUN` はリストを取り直す必要がある | +| クリーンアップを書き写す | `apt-get clean` と `rm -rf` を 2 か所に持つことになる | + +**キャッシュが無効になる条件は、その `RUN` の命令の文字列が変わるか、親の層が変わるか、 +`--no-cache` を渡すかの 3 つである。** 取得先の中身が変わってもキャッシュは無効にならない +(`curl | bash` は、キャッシュが効いている間は実行されない)。 + +**この変更そのものが 1 つ目の `RUN` の文字列を変えるため、層を分けても分けなくても、この +変更では 1 度建て直される。** 層を分けて守れるのは「次にこの 6 つを足し引きしたとき」だけで、 +その頻度は低い。`devbase build base --no-cache` はどのみち全部を建て直す。層を増やして +守るほどの利得が無い。 + +### 決定 5: `COPY` と `fc-cache -f` は末尾の `COPY` 群へ置く + +**`fc-cache -f` は、Playwright が `--with-deps` でフォントを入れる `RUN` より後でなければ +ならない。** その `RUN` は `fonts-wqy-zenhei` / `fonts-ipafont-gothic` / `fonts-liberation` などを +入れ、直後に `~/.cache` を消す。 + +末尾へ置くことには、キャッシュの上の利点もある。`fonts-local.conf` を書き換えたとき、 +無効になるのは末尾の数層だけで、巨大な `RUN` は建て直されない。 + +**フォントのパッケージを入れる 1 つ目の `RUN` の中で `fc-cache` を走らせる案は採らない。** +Playwright が後から入れるフォントを知らないキャッシュが残り、しかも `fonts-local.conf` を +1 文字直すたびにイメージ全体が建て直しになる。 + +なお `fc-cache` は `local.conf` の内容を反映するためではない(設定は照合のたびに読まれる)。 +走らせるのは、`~/.cache` を消した後に `/var/cache/fontconfig` を作り直し、コンテナの初回起動 +時のキャッシュ生成を避けるためである。 + +### 決定 6: 回帰テストは 2 段にする。形は Docker なしで、解決先は Docker ありで固定する + +**Dockerfile の文字列検査だけでは足りない。** 固定したいのは「`COPY` の行があること」では +なく「`fc-match sans-serif` が日本語を返すこと」で、後者は文字列からは分からない。決定 2 の +ような壊れ方(`lang` の条件が広すぎて欧文を奪う)は、Dockerfile を読んでも見えない。 + +| テスト | Docker | 何を固定するか | +| --- | --- | --- | +| `tests/containers/test_base_dockerfile_fonts.py` | 不要 | 6 パッケージが一覧にあること。`COPY` の宛先が `/etc/fonts/local.conf` であり `conf.d/` ではないこと。`fc-cache -f` が `COPY` より後にあること。`fonts-local.conf` が整形式の XML で、4 つの `` と 9 つの ``(受け皿 1 つと、総称ファミリ 4 つ × 言語 2 つの 8 つ)を持つこと。先頭のコメントが置き場所の理由(`51-local.conf` / `conf.d` / `99`)に触れていること。`libreoffice` / `soffice` / `pip` を入れていないこと。Dockerfile に `fonts-wqy-zenhei` を対象とする `apt-get remove` / `apt-get purge` / `dpkg -r` が無いこと | +| `tests/containers/test_base_image_font_matching.py` | 要る | 「解決先の表」の各行。1 回の `docker run` で全部の `fc-match` を採り、行ごとに突き合わせる | + +**Docker が要るテストは、`tests/snapshot/test_restore_incremental.py` の先例に合わせる。** +そこは fixture の中で `shutil.which('docker')` → `docker info` → `docker image inspect` の 3 段を +見て `pytest.skip` する(marker は使わない。`pyproject.toml` に marker の登録も `addopts` も無い)。 + +**それに 1 段足す。イメージの中に `/etc/fonts/local.conf` が無ければ skip する。** 理由は、 +この変更より前に建てた `devbase-base:latest` を持っている人が全員 `pytest tests/` で赤くなる +のを避けるためである。skip の文言に `devbase build base --no-cache` を書き、建て直せば検査が +効くようにする。**古いイメージを「失敗」として知らせる案は採らない。** 正しい作業ツリーで +テストが赤くなり、赤の意味が「壊れている」と「イメージが古い」で混ざる。 + +**このテストは CI では動かない。** `.github/workflows/ci.yml` にイメージを建てるジョブは 1 つも +無く、runner に `devbase-base:latest` は無いので skip になる。さらに +**`release/v3.7.0` を base にした Pull Request では検査ジョブが 1 件も動かない** +(`on.pull_request.branches` が `main` だけ。#216)。**`gh pr checks` の `no checks reported` と +`mergeStateStatus: CLEAN` は「通った」ことを意味しない。** そのため、解決先の表の証跡は +手元で採って Pull Request 本文へ貼る。 + +**環境変数(`DEVBASE_IMAGE_TESTS=1` など)で明示的に有効化する案は採らない。** 既存の +Docker を使うテストが環境変数を要求しておらず、流儀が 2 つに割れる。イメージの中に +`/etc/fonts/local.conf` があるかどうかは、「この検査が意味を持つ状態か」をそのまま表す。 + +### 決定 7: 設定は Dockerfile のヒアドキュメントではなく、独立したファイルにする + +`containers/base/` のビルドコンテキストはこのディレクトリそのものなので、ファイルを置けば +`COPY` で届く(`ai-cli-aliases.sh` / `tmux.conf` と同じ形)。独立したファイルにすると、 +XML として検査でき、差分が読め、置き場所の理由のコメントを長く書ける。#161 が求めている +「ファイル先頭のコメント」も、ヒアドキュメントの中では Dockerfile のコメントと混ざる。 + +### 決定 8: 日本語を既定にし、言語を明示しない中国語は日本語の字形で描く + +base の利用者は日本語話者で、扱う文書も日本語が多い。`lang` を伴わない `sans-serif` は +どちらかの言語を選ばざるをえず、**「日本語の文書が中国語の字形で描かれる」を裏返して +「言語を明示しない中国語の文書が日本語の字形で描かれる」にする。** + +裏返す先が小さいことは実測で確かめてある。中国語を明示した指定(`lang=zh-cn`)と、中国語の +書体を名指しした指定(`WenQuanYi Zen Hei`)はどちらも変わらない。Chromium は +`` のページで `lang` を載せる。 + +### 決定 9: 利用者の上書きの口は塞がない + +`/etc/fonts/conf.d/50-user.conf` が `~/.config/fontconfig/fonts.conf` を読む。スロット 50 は +`51-local.conf` より**先**なので、利用者が自分の `fonts.conf` で別の `` を書けば +そちらが勝つ。実測で確認した(利用者側で `sans-serif` を `WenQuanYi Zen Hei` に戻せた)。 + +この性質はこの設計が作るものではなく、`/etc/fonts/local.conf` を選んだことの帰結である。 +`conf.d/00-*.conf` を使っていたら、利用者の設定より先に読まれて上書きを塞いでいた。 + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| 1・2・3・4 | `test_base_image_font_matching.py` の解決先の表(JP の行)。受け入れ条件 2 は `fc-match` の 1 件だけでなく、**`fc-match -s sans-serif:lang=ja` の 1 件目が `Noto Sans CJK JP` であること**も確かめる(同じ `docker run` の中で `fc-match -s` の出力の先頭を採る)。Docker が無い / イメージが古いときは skip | +| 5・11 | 同じ表の欧文の行。5 は変更の前後で変わらない 3 つ(`Arial` / `Times New Roman` / `Courier New`)、11 は変更で直る 2 つ(`Calibri` → Carlito / `Cambria` → Caladea。**現状はどちらも `WenQuanYi Zen Hei`**) | +| 6 | 同じ表の `lang` を明示した行と、書体を名指しした行の 13 行(総称ファミリ 4 つ × 言語 2 つの 8 行、欧文を名指しした 3 行、実在する書体を名指しした 2 行(`WenQuanYi Zen Hei` / `IPAPGothic`))。**決定 2 の壊れ方を捕まえるのはこの行である** | +| 7 | `test_base_dockerfile_fonts.py`: `COPY` の宛先が `/etc/fonts/local.conf` であること、`conf.d/` を宛先にする `COPY` が無いこと、`fc-cache -f` が 1 度だけで `COPY` より後にあること | +| 8 | `test_base_dockerfile_fonts.py`: `fonts-local.conf` の先頭のコメントが `51-local.conf` と `conf.d` と `99` に触れていること。設計文書の側は目視 | +| 9・10・12 | `test_base_image_font_matching.py` と同じ `docker run` の中で、`command -v` と `python3 -c "import …"` の結果も採る | +| 13 | ビルドの前後の `docker images`。手で測って Pull Request 本文へ貼る | +| 14 | `uv run --locked pytest tests/ -q` | +| 15 | `devbase build base --no-cache` | +| 16 | 派生イメージを 1 つ建て直して `fc-match sans-serif` を見る。手で確かめて Pull Request 本文へ貼る | + +`test_base_image_font_matching.py` は `docker run` を**セッションで 1 回**に抑える +(`scope="session"` の fixture が 1 つのスクリプトを走らせ、結果を辞書にして返す)。 +`fc-match` を 20 回別々に `docker run` すると、コンテナの起動だけで数十秒かかる。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| LibreOffice と Chromium での実際の描画 | どちらも base に無いため実機未検証。確かめたのは `fc-match` の水準まで。LibreOffice は fontconfig とは別の照合も持つため、解決先が `fc-match` と一致しないことがある(#161 の由来になった `volareinc/nyle-dx` PR #5 では `NotoSansCJKsc` が埋め込まれていた)。`containers/docs`(#219)を作るときに、そこで確かめる | +| amd64 での再現 | 手元は arm64 のみ。amd64 では `google-chrome-stable` が追加で入るため、`--with-deps` が入れるフォントの顔ぶれが違いうる。`fc-match` の表が同じになるかは、amd64 の端末で建てるまで分からない | +| `containers/lfm` | `FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を自前で入れている(`containers/lfm/Dockerfile:23`)。同じ問題を抱えるかは未調査。抱えていれば別途起票する | +| イメージの増分の測り方 | 24 MB は稼働中のコンテナでの `du` の差で、apt のリストとキャッシュを含む。層としての増分は、実装の持ち場で `devbase build base --no-cache` の前後の `docker images` で測り直す。受け入れ条件 13 の合否のラインは、測り方の違いを吸収できるよう +0.5% 未満(40 MB 以下)にしてある。 | +| 利用者への周知 | 建て直すまで反映されないため、CHANGELOG に `devbase build base --no-cache` が要ることを書く。既に建てた人がいつ建て直すかは devbase の側から決められない | diff --git a/issues/PLAN63_base-image-rendering.md b/issues/PLAN63_base-image-rendering.md new file mode 100644 index 00000000..9d751978 --- /dev/null +++ b/issues/PLAN63_base-image-rendering.md @@ -0,0 +1,266 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 + +対象 issue: devbasex/devbase#161, devbasex/devbase#160 + +- ワークフローモード: `standard` + - 根拠: base イメージの本番の振る舞い(総称ファミリの解決先と、同梱するパッケージ)を変える。 + base はすべての派生イメージとプロジェクトの土台で、変更は再ビルドした全員に届く +- 2 件を 1 本にする理由: どちらも `containers/base/Dockerfile` の同じ apt / COPY の区画を触る。 + 分けると同じ箇所で競合し、レビューした差分と入る差分が変わる。#160 の本文も + 「#161 を先に入れるか、1 本の PR にまとめる」と書いている +- ベースブランチ: `release/v3.7.0`(release Pull Request は #212) + +## 目的 + +- **base コンテナで日本語を描いたとき、日本語のフェイスで描かれる。** 総称ファミリ + (`sans-serif` / `serif` / `monospace`)と、日本語環境でよく指定される書体名 + (`Meiryo` / `Yu Gothic` / `MS PGothic` / `Noto Sans JP`)と、イメージに無い書体名のすべてが、 + Noto CJK の JP フェイスへ解決される +- **欧文と、中国語・韓国語を明示した指定は壊さない。** `Arial` / `Times New Roman` / `Courier New` + は metric 互換のまま、`lang=zh-cn` / `lang=ko` はそれぞれの言語のフェイスのままにする +- **PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測る**が、 + base だけで(派生イメージも外部のサービスも使わずに)できる + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(CLI の引数・環境変数・コマンドは増減しない) | +| データ | 変わらない | +| 既存の振る舞い | **変わる。** base コンテナの中で描かれる文字のフェイスが変わる。イメージを建て直すまでは変わらない。CHANGELOG では #161 を Fixed、#160 を Added に書く | +| イメージのサイズ | +約 24 MB(7.09GB に対して +0.34%)。`fonts-local.conf` 自体は 0 | +| 利用者の操作 | **`devbase build base --no-cache` が要る。`devbase up` だけでは反映されない。** 派生イメージ(`containers/general` など)を使っているプロジェクトは、その派生イメージも建て直す。稼働中のコンテナは `devbase down` → `devbase up` で作り直す | + +## 前提 + +- **前提 1: base の既定は日本語にする。** 利用者は日本語話者で、扱う文書も日本語が多い。 + 中国語・韓国語は `lang` を明示したときだけそのフェイスを保つ形にする(言語を明示しない + 中国語の文書は日本語の字形で描かれる。これは意図した振る舞いで、変更前の「日本語の文書が + 中国語の字形で描かれる」を裏返したものである) +- **前提 2: `fonts-wqy-zenhei` は削除しない。** #161 が実測付きで結論している。削除しても + OS 既定の `65-nonlatin.conf` が `sans-serif` の prefer 一覧に `WenQuanYi Zen Hei` を含むため + 日本語にはならず(タイ語の Loma か IPAPGothic へ落ちる)、中国語のページを豆腐にするだけになる +- **前提 3: LibreOffice は base に入れない。** #160 の決定。展開 372〜459 MB は base の規律に + 見合わない +- **前提 4: `containers/docs` は新設しない。** #160 の表題の範囲は base への追加であり、 + 派生イメージの新設はそれを超える。#219 として起票済み(派生イメージ・使い捨てコンテナ・ + Google Slides の 3 つの比べ方と、LibreOffice の実測表を引き写してある) +- **前提 5: Playwright のブラウザと arm64 の Chromium には手を付けない。** #161 の「決めること」は + #220 として起票済み。`playwright-kit` が実行時に `uv sync` + `playwright install chromium` を + 自前で行う設計であることを確認した(`devbasex/ai-plugins` の + `plugins/playwright-kit/skills/playwright-kit-ops/templates/run.sh` ほか) +- **前提 6: CI はこの変更を検査しない。** `.github/workflows/ci.yml` にイメージを建てるジョブは + 1 つも無く、`on.pull_request.branches` は `main` だけである。**`release/v3.7.0` を base にした + Pull Request では検査ジョブが 1 件も動かない**(#216)。そのため、イメージの中でしか確かめ + られない受け入れ条件の証跡は、**手元で建てたイメージから採って Pull Request 本文へ載せる** +- **前提 7: `ENV LANG` は設定しない。** 言語のヒントに頼らず、総称ファミリの解決先そのものを + 日本語にする(`fc-match sans-serif` は `lang` を明示しなくても日本語になる)。`LANG` を + `ja_JP.UTF-8` にすると、コンテナの中のすべてのコマンドの出力・ソート順・日付の書式が変わり、 + 影響がフォントの外へ出る +- **前提 8: 追加するのは 6 パッケージだけで、`pip` は足さない。** Python パッケージが要るときは + 既にある `uv` / `uvx` で賄う(`markitdown` は `uvx` のままにする。焼き込むと展開で 200 MB 前後) + +## 対象範囲 + +含む: + +- `containers/base/fonts-local.conf`(新設)と、それを `/etc/fonts/local.conf` へ置く `COPY`、 + および `fc-cache -f` の実行(#161) +- `containers/base/Dockerfile` への 6 パッケージの追加(#160) + (`poppler-utils` / `python3-pil` / `python3-defusedxml` / `python3-lxml` / + `fonts-crosextra-carlito` / `fonts-crosextra-caladea`) +- 回帰テスト(`tests/containers/`) +- 利用者向け文書と CHANGELOG + +含まない: + +- LibreOffice の追加(前提 3) +- `containers/docs` の新設(前提 4、#219) +- Playwright のブラウザの導入方法と arm64 の Chromium(前提 5、#220) +- `fonts-wqy-zenhei` の削除(前提 2) +- `ENV LANG` の設定(前提 7) +- `pip` の追加(前提 8) +- 派生イメージ(`containers/general` / `go` / `php` / `php85` / `bi-tools` / `latex` / `trygroup`)の + Dockerfile の変更。いずれも `FROM devbase-base:latest` なので、base を建て直せば自動的に効く +- `containers/lfm` の変更。`FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を + 自前で入れている。同じ問題を抱えるかは未調査(下の「未確認のまま残ること」) +- 新しい型・永続データ・画面の追加(そのためクラス図・ER 図・画面遷移図を作らない) + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | base イメージの構成物は `containers/base/` の直下に置く。ビルドコンテキストはこのディレクトリそのもの(`lib/devbase/commands/container.py` の `_build_single_image` が `str(image_dir)` を渡す)なので、`COPY` したいファイルはここに置けば届く | +| コーディング規約 | `containers/base/Dockerfile` の既存の書き方に合わせる。`COPY --chmod=` で権限を明示し、なぜそうするかを直前のコメントに書く | +| テスト戦略 | `tests/containers/` の既存の流儀に合わせる。既定は Docker に依存しない検査(Dockerfile とスクリプトの文字列・関数の呼び出し)。Docker が要る検査は `tests/snapshot/test_restore_incremental.py` の先例(fixture の中で `shutil.which` → `docker info` → `docker image inspect` の 3 段を見て `pytest.skip`)に合わせる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元で全体テスト、`devbase build base --no-cache` と解決先の表の採取 | +| 確認してから行う | 前提 1(日本語を既定にする)と受け入れ条件 6 の表の確定(設計 Pull Request の承認で確かめる) | +| 行わない | LibreOffice の追加、`containers/docs` の新設、`fonts-wqy-zenhei` の削除、`ENV LANG` の設定、Playwright まわりの変更 | + +## 実装計画 + +設計は [PLAN63_base-image-rendering-design.md](PLAN63_base-image-rendering-design.md)。 +**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。** ここでは触る対象だけを挙げる。 + +### 修正対象 + +- `containers/base/fonts-local.conf`(新設) +- `containers/base/Dockerfile` +- `tests/containers/test_base_dockerfile_fonts.py`(新設)、 + `tests/containers/test_base_image_font_matching.py`(新設。Docker が要る) +- `docs/user/container-operations.md`(base の説明に文書の道具とフォントの行を足す) +- `CHANGELOG.md` + +### 切り戻し手順 + +- データの移行は無い。変わるのはイメージの中身だけである +- 戻すには 4 つが要る。 + 1. ブランチの revert + 2. `devbase build base --no-cache` + 3. **base から派生したイメージの建て直し**(`containers/general` / `go` / `php` / `php85` / + `bi-tools` / `latex` / `trygroup` のうち使っているもの)。派生イメージは + `FROM devbase-base:latest` を自分のビルドの時点で焼き込むため、base のタグを戻しても + 建て直すまで古い層を持つ + 4. **稼働中のコンテナの作り直し**(`devbase down` → `devbase up`)。既に起動している + コンテナは、イメージを建て直しただけでは入れ替わらない。**`devbase rebuild` はここでは + 使えない。** `devbase build --expires=7` のシノニム(`lib/devbase/commands/container.py` + の `cmd_rebuild`)で、イメージのビルドしか行わずコンテナを作り直さないうえ、期限内なら + ビルドそのものを飛ばす +- **建て直すまで戻らない。** 同じことが適用の側にも当たる(受け入れ条件 16) + +## 受け入れ条件 + +**実測の基準**: 以下の「現状」はすべて、2026-09-22 に手元の `devbase-base:latest` +(Ubuntu 26.04 / arm64 / fontconfig 2.17.1 / 7.09GB)で採った。イメージを建て直さずに +確かめられるものは、稼働中のコンテナへ設定とパッケージを入れて確かめてある。 + +### フォントの解決先(#161) + +- [ ] 1. 建てた base イメージの中で `fc-match sans-serif` が `Noto Sans CJK JP` を返す + (現状: `WenQuanYi Zen Hei`)。`fc-match sans` も同じ(現状: `WenQuanYi Zen Hei`) +- [ ] 2. `fc-match sans-serif:lang=ja` も `Noto Sans CJK JP` を返す(現状: `WenQuanYi Zen Hei`)。 + `fc-match -s sans-serif:lang=ja` の 1 件目も `Noto Sans CJK JP` になる + (現状: `WenQuanYi Zen Hei` → `IPAPGothic` → `Loma` の順) +- [ ] 3. `fc-match serif` が `Noto Serif CJK JP`、`fc-match monospace` が `Noto Sans Mono CJK JP` を返す + (現状: `WenQuanYi Zen Hei` / `WenQuanYi Zen Hei Mono`) +- [ ] 4. `Noto Sans JP` / `Meiryo` / `Yu Gothic` / `MS PGothic` と、イメージに無い書体名 + (`Zen Kaku Gothic New`)が、いずれも `Noto Sans CJK JP` を返す(現状: すべて `WenQuanYi Zen Hei`) +- [ ] 5. **欧文が壊れない**(変更の前後で変わらない)。`Arial` → `Liberation Sans`、 + `Times New Roman` → `Liberation Serif`、`Courier New` → `Liberation Mono`。 + **`Calibri` と `Cambria` はここに含めない。** この 2 つは変更前に + `WenQuanYi Zen Hei`(中国語のフェイス)へ落ちており、「変わらない」ではなく + 「直る」ものである。受け入れ条件 11 で固定する +- [ ] 6. **他言語が壊れない。`lang` を明示した指定は、その言語の、しかも同じ様式(sans / serif / + 等幅)のフェイスを返す。** + + | 指定 | 返すもの | + | --- | --- | + | `sans-serif:lang=zh-cn` | `Noto Sans CJK SC` | + | `sans:lang=zh-cn` | `Noto Sans CJK SC` | + | `serif:lang=zh-cn` | `Noto Serif CJK SC` | + | `monospace:lang=zh-cn` | `Noto Sans Mono CJK SC` | + | `sans-serif:lang=ko` | `Noto Sans CJK KR` | + | `sans:lang=ko` | `Noto Sans CJK KR` | + | `serif:lang=ko` | `Noto Serif CJK KR` | + | `monospace:lang=ko` | `Noto Sans Mono CJK KR` | + | `Arial:lang=zh-cn` | `Liberation Sans`(欧文の指定は言語で変わらない) | + | `Arial:lang=ko` | `Liberation Sans`(同上) | + | `Times New Roman:lang=zh-cn` | `Liberation Serif` | + | `WenQuanYi Zen Hei`(名指し) | `WenQuanYi Zen Hei`(残る) | + | `IPAPGothic`(名指し) | `IPAPGothic`(残る。イメージに実在する日本語の書体を奪わない) | + +- [ ] 7. 設定は `/etc/fonts/local.conf` に置かれ、`/etc/fonts/conf.d/` には 1 つも置かれない。 + ビルドの中で `fc-cache -f` が 1 度走る + 検証: `tests/containers/` の Dockerfile の文字列検査 +- [ ] 8. `/etc/fonts/local.conf` から動かさない理由(`conf.d/99-*.conf` では効かないこと、および + その実測)が、`containers/base/fonts-local.conf` の先頭のコメントと設計文書の + 「決定の記録」の**両方**にある + 検証: `tests/containers/` の文字列検査(コメントの有無)と、設計文書の目視 + +### 文書を扱う道具(#160) + +- [ ] 9. `pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo` が `PATH` にある +- [ ] 10. `python3 -c "import PIL, defusedxml, lxml"` が終了コード 0 +- [ ] 11. **欧文の metric 互換が直る。** `fc-match Calibri` が `Carlito`、`fc-match Cambria` が + `Caladea` を返す(**現状はどちらも `WenQuanYi Zen Hei`**)。`fonts-crosextra-carlito` と + `fonts-crosextra-caladea` を足すことで直る +- [ ] 12. `soffice` / `libreoffice` / `pip` / `pip3` のいずれも `PATH` に無い(前提 3・8 のまま)。 + `uv` はある +- [ ] 13. 追加するのは 6 パッケージの指定だけで、依存を含めて新規に入るのは 24 パッケージ、 + **イメージの増分が 7.09GB に対して +0.5% 未満(40 MB 以下)**に収まる + 検証: `devbase build base --no-cache` の前後の `docker images` の差 + **合否のラインを「約 24 MB」にしない。** 24 MB は稼働中のコンテナでの `du` の差で、 + apt のリストとキャッシュを含む測り方である。`docker images` が出すのは層単位の値で、 + 層の重なりの分だけずれる。同じ数字を 2 つの測り方で比べると、正しい実装でも + 不合格になりうる + +### 退行しないこと + +- [ ] 14. `uv run --locked pytest tests/ -q` が終了コード 0(既存の `tests/containers/` の 8 ファイルを含む) +- [ ] 15. `devbase build base --no-cache` が arm64 で成功する。6 パッケージは Ubuntu 26.04 の + arm64 にすべて存在する(版は下の表) +- [ ] 16. base を建て直した後、派生イメージ(`containers/general` など)を建て直すと、同じ + 解決先になる(`FROM devbase-base:latest` のため)。1 つで確かめる + +| パッケージ | 版(2026-09-22 / arm64) | +| --- | --- | +| `poppler-utils` | 26.01.0-2ubuntu0.1 | +| `python3-pil` | 12.1.1-2ubuntu1.3 | +| `python3-defusedxml` | 0.7.1-3build1 | +| `python3-lxml` | 6.0.2-1build1 | +| `fonts-crosextra-carlito` | 20230309-2 | +| `fonts-crosextra-caladea` | 20200211-2 | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --locked pytest tests/ -q` | +| イメージの中の解決先 | `devbase build base --no-cache` の後、`docker run --rm --entrypoint /bin/bash devbase-base:latest -c 'fc-match ...'` の表(受け入れ条件 1〜6・9〜12)。出力を Pull Request 本文へ貼る | +| イメージのサイズ | ビルドの前後の `docker images` | +| CI | **動かない**(前提 6、#216)。`gh pr checks` の `no checks reported` と `mergeStateStatus: CLEAN` は「通った」ことを意味しない | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| LibreOffice と Chromium での実際の描画 | どちらも base に無いため実機未検証。確かめたのは `fc-match` の水準まで(#161 と同じ)。LibreOffice は fontconfig とは別の照合も持つ | +| `containers/lfm` | `FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を自前で入れている(`containers/lfm/Dockerfile:23`)。同じ問題を抱えるかは未調査。抱えていれば別途起票する | +| amd64 での再現 | 手元は arm64 のみ。amd64 では `google-chrome-stable` が追加で入るため、フォントの顔ぶれが違いうる | +## 依頼(原文) + +#161: + +> **base コンテナでは、日本語が中国語のフォントで描画される。** 総称ファミリの `sans-serif` そのものが中国語フォントへ解決される。 +> +> **影響は Office 文書の描画に限らない。** fontconfig は Chromium / Playwright のスクリーンショット、PDF の生成、画像の生成すべてが参照する。**日本語を含むページを撮ると、中国語の字形で写る。** +> +> `/etc/fonts/local.conf` を1つ置く。(…)Dockerfile へは `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` を足し、`fc-cache -f` を1度走らせる。**追加パッケージは要らない(サイズは 0)。** +> +> **`conf.d/99-*.conf` へ置くと効かない。** 同じ内容で実測した。 +> +> **`fonts-wqy-zenhei` を外す案は採らない。** rdepends が空なので削除自体はできるが、上の実測のとおり `65-nonlatin.conf` が残るため `sans-serif` はタイ語や IPAPGothic へ落ちるだけで、日本語にはならない。 + +#160: + +> **base コンテナに、Office 文書を画像へ描画する経路が1つも無い。**(…) +> +> **LibreOffice は base に入れない(決定)。** 展開 372〜459 MB は base の規律に見合わない。 +> +> ``` +> poppler-utils +> python3-pil python3-defusedxml python3-lxml +> fonts-crosextra-carlito fonts-crosextra-caladea +> ``` +> +> **展開 約24 MB / ダウンロード 約7 MB、7.09GB の base に対して +0.34%。** +> +> **`pip` は足さない。`uv` があるため、必要な Python パッケージはそちらで賄う。** +> +> #161 と本件は `containers/base/Dockerfile` の同じ apt / COPY の区画を触る。**#161 を先に入れるか、1 本の PR にまとめる。** +