diff --git a/CHANGELOG.md b/CHANGELOG.md index fe578f6f..2170a0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ## [Unreleased] ### Added +- **base イメージに、Office 文書・PDF を扱う軽量の道具を足しました(PLAN63 / #160)。** + `poppler-utils`(`pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo`)、`python3-pil`、 + `python3-defusedxml`、`python3-lxml` で、PDF を画像にする・調べる、OOXML を壊さずに + 読み書きすることが base だけでできます。あわせて欧文の metric 互換の + `fonts-crosextra-carlito` / `fonts-crosextra-caladea` を入れ、`Calibri` / `Cambria` の + 指定が正しい字幅の書体(Carlito / Caladea)へ解決されるようにしました(これまでは + どちらも中国語フォントへ落ちていました)。**LibreOffice と `pip` は入れていません。** + Python パッケージが要るときは既にある `uv` / `uvx` を使ってください。 + **反映には `devbase build base --no-cache` が要ります。** - **名前の形に合わないプロジェクト(`_foo` など)が `projects/` に載る時点で、警告を 1 行出すように しました(PLAN66 / #203)。** `devbase plugin install` / `update` / `sync` は、プラグインの プロジェクト・衝突のときに合成する別名 `<名前>.`・`projects/` 直下の実ディレクトリの @@ -19,6 +28,19 @@ それ以外に受け付ける名前と、エラーの文言は変わりません。 ### Fixed +- **base コンテナで日本語が中国語のフォントで描画される問題を直しました(PLAN63 / #161)。** + 総称ファミリ(`sans-serif` / `sans` / `serif` / `monospace`)と、イメージに無い書体名 + (`Meiryo` / `Yu Gothic` / `MS PGothic` / `Noto Sans JP` など)が、Noto CJK の **JP** + フェイスへ解決されるようになります。これまでは `sans-serif` そのものが中国語フォント + (WenQuanYi Zen Hei)へ解決され、Chromium / Playwright のスクリーンショット・PDF の生成・ + 画像の生成のすべてが中国語の字形で写っていました。欧文(`Arial` / `Times New Roman` / + `Courier New`)は Liberation の metric 互換のままで、`lang=zh-cn` / `lang=ko` を明示した + 指定は、その言語の、しかも同じ様式(sans / serif / 等幅)のフェイスのままです。 + **言語を明示しない中国語は日本語の字形で描かれるようになります**(意図した変更です)。 + 設定は `/etc/fonts/local.conf` に置いており、個人の `~/.config/fontconfig/fonts.conf` で + 上書きできます。**反映には `devbase build base --no-cache` が要ります。** `devbase up` + だけでは変わらず、派生イメージ(`general` など)を使っているプロジェクトは、その派生 + イメージも建て直してください。 - **`group_aliases` のある置き場で、機密の参照の見出しがグループの読み替えの前と後を出すように しました(PLAN64 / #188)。** `devbase env list` の節の見出しと件数の行、`devbase env backend test` の参照ごとの行、`devbase env backend migrate` の移行の計画の一覧と `--to age` の完了後の一覧が、 diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index c874f796..f0d94798 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -19,7 +19,13 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ openssh-client \ curl ca-certificates gnupg lsb-release \ libnss3 libxrandr2 libxss1 \ - fonts-noto-cjk fonts-noto-cjk-extra; \ + fonts-noto-cjk fonts-noto-cjk-extra \ + # 欧文の metric 互換 (Calibri -> Carlito / Cambria -> Caladea)。 + # 30-metric-aliases.conf が既に対応を持っており、実体が無いと中国語フォントへ落ちる。 + fonts-crosextra-carlito fonts-crosextra-caladea \ + # 文書を扱う軽量の道具 (#160)。PDF を画像にする・調べる、OOXML を壊さずに読み書きする。 + # LibreOffice (展開 372〜459MB) と pip は入れない。Python パッケージは uv / uvx で賄う。 + poppler-utils python3-pil python3-defusedxml python3-lxml; \ # ロケール設定 locale-gen en_US.UTF-8; \ update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8; \ @@ -250,6 +256,19 @@ COPY --chmod=0755 tmux-clean /usr/local/bin/tmux-clean RUN sudo ln -sf tmux-first /usr/local/bin/tmux1 \ && sudo ln -sf tmux-clean /usr/local/bin/tmuxc +# フォントの既定。素の fontconfig は sans-serif を中国語フェイス (WenQuanYi Zen Hei) へ +# 向けるため、日本語を描くと中国語の字形で写る (#161)。/etc/fonts/local.conf は +# conf.d/51-local.conf 経由で読まれ、これを中国語へ向けている 64 / 65 より「先」になる。 +# conf.d/99-*.conf へ置くと が効かない。理由と実測は fonts-local.conf の +# 先頭のコメントにある。利用者は ~/.config/fontconfig/fonts.conf (スロット 50) で上書きできる。 +COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf + +# フォントのキャッシュを作り直す。上の Playwright の RUN が --with-deps で +# fonts-wqy-zenhei / fonts-ipafont-gothic / fonts-liberation を入れた「後」でなければ、 +# 後から入った書体を知らないキャッシュが残る。直後に ~/.cache を消しているため、 +# ここで /var/cache/fontconfig を作り直してコンテナ初回起動時の生成を避ける。 +RUN sudo fc-cache -f + # entrypoint と dind COPY --chmod=755 entrypoint.sh /entrypoint.sh COPY --chmod=755 dind /usr/local/bin/dind diff --git a/containers/base/fonts-local.conf b/containers/base/fonts-local.conf new file mode 100644 index 00000000..116ba6cc --- /dev/null +++ b/containers/base/fonts-local.conf @@ -0,0 +1,101 @@ + + + + + + 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 + + + sans + zh-cn + Noto Sans CJK SC + + + serif + zh-cn + Noto Serif CJK SC + + + monospace + zh-cn + Noto Sans Mono CJK SC + + + + sans-serif + ko + Noto Sans CJK KR + + + sans + ko + Noto Sans CJK KR + + + serif + ko + Noto Serif CJK KR + + + monospace + ko + Noto Sans Mono CJK KR + + diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 34c69a69..225ca964 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -363,7 +363,7 @@ graph TD | イメージ | ベース | 主な内容 | 用途 | |---------|-------|---------|------| -| **base** | Ubuntu 26.04 | Docker CLI、Python 3 | 最小限の開発環境 | +| **base** | Ubuntu 26.04 | Docker CLI、Python 3、日本語フォント、PDF / OOXML の道具 | 最小限の開発環境 | | **general** | base | AWS CLI、gcloud、Terraform、Node.js 20、AI CLI | 汎用開発環境 | | **php** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | | **php85** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | @@ -372,6 +372,46 @@ graph TD | **go** | base | Go 開発環境 | Go 開発 | | **snapshot** | Ubuntu 26.04 | zstd のみ(約 80MB) | スナップショット専用 | +### 文字の描画と、文書を扱う道具(base 以降) + +base イメージは、文字を描くときの既定を**日本語**にしています。総称ファミリ(`sans-serif` / +`sans` / `serif` / `monospace`)と、イメージに無い書体名(`Meiryo` / `Yu Gothic` / +`MS PGothic` / `Noto Sans JP` など)は、いずれも Noto CJK の **JP** フェイスへ解決されます。 +fontconfig は Chromium / Playwright のスクリーンショット、PDF の生成、画像の生成がすべて +参照するため、日本語を含むページを撮っても日本語の字形で写ります。 + +| 指定 | 解決先 | +|------|--------| +| `sans-serif` / `sans` | Noto Sans CJK JP | +| `serif` | Noto Serif CJK JP | +| `monospace` | Noto Sans Mono CJK JP | +| `Arial` / `Times New Roman` / `Courier New` | Liberation Sans / Serif / Mono(metric 互換) | +| `Calibri` / `Cambria` | Carlito / Caladea(metric 互換) | +| `sans-serif:lang=zh-cn` など、言語を明示した指定 | その言語の、同じ様式のフェイス(SC / KR) | + +> **言語を明示しない中国語は、日本語の字形で描かれます。** `lang` を伴わない `sans-serif` は +> どちらかの言語を選ばざるをえないためで、意図した振る舞いです。中国語・韓国語で描きたい +> ときは `lang=zh-cn` / `lang=ko` を明示するか、書体を名指ししてください。 + +設定は `/etc/fonts/local.conf`(`containers/base/fonts-local.conf`)にあります。個人の設定 +`~/.config/fontconfig/fonts.conf` はこれより先に読まれるため、コンテナの中で上書きできます。 + +文書を扱う道具も base に入っています。 + +| 道具 | 用途 | +|------|------| +| `pdftoppm` / `pdftocairo` | PDF を画像(PNG / JPEG / SVG)にする | +| `pdfinfo` / `pdffonts` | PDF のページ数・寸法・埋め込みフォントを調べる | +| `python3 -c "import PIL"` | 画像の読み書き・変換(Pillow) | +| `python3 -c "import lxml, defusedxml"` | OOXML(.docx / .xlsx / .pptx)を壊さずに読み書きする | + +> **LibreOffice と `pip` は入っていません。** LibreOffice は展開 372〜459MB で base の規律に +> 見合わないため入れていません。Python パッケージが要るときは `uv` / `uvx` を使ってください。 + +> **これらは `devbase build base --no-cache` で base を建て直すと反映されます。** +> `devbase up` だけでは反映されません。派生イメージ(`general` など)を使っている +> プロジェクトは、その派生イメージも建て直してください。 + ### AI CLI エイリアス general イメージ以降のコンテナ内では、以下の AI CLI ツールがエイリアスとして利用可能です。 diff --git a/issues/PLAN63_base-image-rendering-impl.md b/issues/PLAN63_base-image-rendering-impl.md new file mode 100644 index 00000000..9aef3496 --- /dev/null +++ b/issues/PLAN63_base-image-rendering-impl.md @@ -0,0 +1,180 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 の実装 + +## 関連リンク + +- 対象 issue: devbasex/devbase#161, devbasex/devbase#160 +- 要求と受け入れ条件: [PLAN63_base-image-rendering.md](PLAN63_base-image-rendering.md) +- 設計: [PLAN63_base-image-rendering-design.md](PLAN63_base-image-rendering-design.md) +- 設計 Pull Request: #221(マージ済み) / release Pull Request: #212 +- 範囲外として起票済み: #219(`containers/docs` と LibreOffice)、#220(arm64 の Chromium) + +## モード + +`standard`。base イメージの本番の振る舞い(総称ファミリの解決先と同梱するパッケージ)を変え、 +すべての派生イメージとプロジェクトへ届くため(要求の「ワークフローモード」の根拠のとおり)。 + +## 目的と非目的 + +達成したい状態: + +- base コンテナで日本語を描いたとき、日本語のフェイス(Noto CJK の JP)で描かれる +- 欧文(`Arial` / `Times New Roman` / `Courier New`)と、中国語・韓国語を**明示した**指定は壊れない +- PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測ることが + base だけでできる + +やらないこと(今回はやらない、の意味で): + +- LibreOffice の追加(要求の前提 3) +- `containers/docs` の新設(前提 4 / #219) +- `fonts-wqy-zenhei` の削除(前提 2) +- `ENV LANG` の設定(前提 7) +- `pip` の追加(前提 8) +- Playwright のブラウザの導入方法と arm64 の Chromium(前提 5 / #220) +- 派生イメージ(`containers/general` ほか)と `containers/lfm` の Dockerfile の変更 + +## 前提 + +- 前提 1: 設定は `/etc/fonts/local.conf` に置く。`conf.d/99-*.conf` では `` が + 効かない(設計の決定 1 の実測) +- 前提 2: 中国語・韓国語の規則は**総称ファミリを名指ししたときだけ**効かせる。`lang` だけを + 条件にすると `Arial:lang=zh-cn` から Liberation Sans を奪う(設計の決定 2 の実測) +- 前提 3: `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない(#216)。 + 証跡は手元で採って Pull Request 本文へ載せる +- 前提 4: この変更は `devbase build base --no-cache` を建てるまで手元に反映されない + +## 受け入れ条件 + +要求の 16 件をそのまま引き継ぐ。ここでは検証手段の対応だけを書く。 + +- [ ] 1〜6(フォントの解決先)— `tests/containers/test_base_image_font_matching.py` の解決先の表 +- [ ] 7・8(置き場所と、動かせない理由のコメント)— `tests/containers/test_base_dockerfile_fonts.py` +- [ ] 9・10・12(道具の有無)— 同じ `docker run` の中の `command -v` と `python3 -c "import …"` +- [ ] 11(`Calibri` → Carlito / `Cambria` → Caladea)— 解決先の表の欧文の行 +- [ ] 13(イメージの増分が 40 MB 以下)— ビルドの前後の `docker images`。手で測る +- [ ] 14(`uv run --locked pytest tests/ -q` が 0) +- [ ] 15(`devbase build base --no-cache` が arm64 で成功する) +- [ ] 16(派生イメージ 1 つで同じ解決先になる)— 手で確かめる + +## 代替案と採否 + +設計の「決定の記録」(決定 1〜9)が持つ。ここでは写さない。 + +## 不変条件 + +- `fc-match Arial` / `Times New Roman` / `Courier New` は Liberation の 3 つのままである +- `fc-match <中国語・韓国語を明示した総称ファミリ>` は、その言語の、**同じ様式**のフェイスを返す +- `soffice` / `libreoffice` / `pip` / `pip3` は `PATH` に無い +- Dockerfile に `fonts-wqy-zenhei` を消す命令が無い + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 公開インタフェース(CLI の引数・環境変数・コマンド) | 無し | 変えない | +| データ | 無し | 変えない | +| base コンテナの中で描かれる文字のフェイス | **変わる** | 建て直すまで変わらない。CHANGELOG で `devbase build base --no-cache` が要ることを知らせる | + +## 修正対象 + +- `containers/base/fonts-local.conf`(新設) +- `containers/base/Dockerfile`(1 つ目の `RUN` の 1 回目の `apt-get install` / 末尾の `COPY` 群) +- `tests/containers/test_base_dockerfile_fonts.py`(新設) +- `tests/containers/test_base_image_font_matching.py`(新設。Docker が要る) +- `docs/user/container-operations.md` +- `CHANGELOG.md` +- `issues/PLAN63_base-image-rendering-impl.md`(この文書) + +## タスク分解 + +### Task 1: フォントの設定ファイルと、その形の回帰テスト + +- **対象ファイル:** `containers/base/fonts-local.conf`(新設)、 + `tests/containers/test_base_dockerfile_fonts.py`(新設) +- **変更内容:** 4 つの ``(`sans-serif` / `sans` / `serif` / `monospace` → Noto CJK JP)、 + 未導入の書体の受け皿 1 つ(`append` / `binding="weak"`)、総称ファミリ 4 つ × 言語 2 つ = + 8 つの ``(`` と `` の両方を持つ)。 + 先頭のコメントに、`/etc/fonts/local.conf` から動かせない理由(`51-local.conf` のスロット、 + `conf.d` の番号順、`99` での実測)を残す +- **満たす受け入れ条件:** 8(コメント)と、1〜6 の土台 +- **進め方:** 先に `test_base_dockerfile_fonts.py` の XML の形の検査(整形式・`` 4 つ・ + `` 9 つ・`lang` の `` が `family` の `` を必ず持つ・コメントの語)を書いて + 落とし、`fonts-local.conf` を足して通す + +### Task 2: Dockerfile の 6 パッケージと `COPY` / `fc-cache` + +- **対象ファイル:** `containers/base/Dockerfile`、`tests/containers/test_base_dockerfile_fonts.py` +- **変更内容:** 1 つ目の `RUN` の**1 回目**の `apt-get install` の一覧へ + `fonts-crosextra-carlito fonts-crosextra-caladea poppler-utils python3-pil + python3-defusedxml python3-lxml` を足す(新しい `RUN` を立てない)。末尾の `COPY` 群 + (`tmux.conf` と同じ区画、`USER ubuntu` より後)へ + `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` と `RUN sudo fc-cache -f` を足す +- **満たす受け入れ条件:** 7、および 9〜13 の土台 +- **進め方:** 先に Dockerfile の文字列検査(6 つが 1 回目の一覧にあること、`COPY` の宛先が + `/etc/fonts/local.conf` で `conf.d/` を宛先にする `COPY` が無いこと、`fc-cache -f` が 1 度だけで + `COPY` より後かつ Playwright の `RUN` より後にあること、`libreoffice` / `soffice` / `pip` を + 入れていないこと、`fonts-wqy-zenhei` を消す命令が無いこと)を書いて落とし、Dockerfile を直す + +### Task 3: 建てたイメージの中の解決先を固定するテスト + +- **対象ファイル:** `tests/containers/test_base_image_font_matching.py`(新設) +- **変更内容:** `scope="session"` の fixture が 1 回の `docker run` で `fc-match` の全行・ + `fc-match -s sans-serif:lang=ja` の 1 件目・`command -v` の結果・`python3 -c "import …"` の + 終了コードをまとめて採り、辞書で返す。skip は 4 段 + (`shutil.which('docker')` → `docker info` → `docker image inspect devbase-base:latest` → + イメージの中に `/etc/fonts/local.conf` があるか)。skip の文言に + `devbase build base --no-cache` を書く +- **満たす受け入れ条件:** 1〜6・9・10・11・12 +- **進め方:** テストを書き、建て直す前は skip になることを確かめる。Task 4 でイメージを + 建て直した後に、実際に通ることを確かめる + +### Task 4: イメージを建て直して実測する + +- **対象ファイル:** 無し(証跡の採取) +- **変更内容:** `devbase build base --no-cache` を作業ツリーの `containers/base` で実行し、 + 前後の `docker images` を記録する。解決先の表の 26 行を `fc-match` で採り、Pull Request + 本文へ載せる。派生イメージ 1 つを建て直して同じ解決先になることを確かめる +- **満たす受け入れ条件:** 1〜6・9〜13・15・16 +- **進め方:** テスト駆動を適用しない(測定のため)。結果は Pull Request 本文の Test plan へ + +### Task 5: 利用者向け文書と CHANGELOG + +- **対象ファイル:** `docs/user/container-operations.md`、`CHANGELOG.md` +- **変更内容:** イメージの詳細の表の base の行へ、日本語のフェイスと文書の道具を足す。 + CHANGELOG の `[Unreleased]` に `### Added`(6 パッケージ)と `### Fixed`(日本語が中国語 + フォントで描画される)を書き、**イメージを建て直すまで反映されない**ことを添える +- **満たす受け入れ条件:** 要求の「対象範囲」の文書の行 +- **進め方:** テスト駆動を適用しない(文書のため) + +## 影響範囲 + +- base から派生するすべてのイメージ(`general` / `go` / `php` / `php85` / `bi-tools` / + `latex` / `trygroup`)。いずれも `FROM devbase-base:latest` のため、建て直せば効く +- `containers/lfm` は base 由来ではないため影響しない(要求の「未確認のまま残ること」) +- 稼働中のコンテナは、イメージを建て直しただけでは入れ替わらない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `lang` の条件が広すぎて欧文の指定から書体を奪う(設計の決定 2 の壊れ方) | Task 1 の XML の形の検査で、`lang` の `` が `family` の `` を必ず持つことを固定する。Task 3 で `Arial:lang=zh-cn` の行を実測で固定する | +| ビルドキャッシュが壊れた層を配り 0 バイトのファイルを作る | `--no-cache` を必ず付ける | +| 建て直す前は Docker のテストが赤くなる | skip の 4 段目(`/etc/fonts/local.conf` の有無)で skip にする | +| 1 つ目の `RUN` の文字列が変わり、巨大な層が建て直される | 設計の決定 4 のとおり避けない。`--no-cache` でどのみち全部建て直る | +| 触る対象の構造 | Dockerfile と `tests/containers/` は既に薄く、先に整える必要は無い | + +## 切り戻し手順 + +データの移行は無い。戻すには 4 つが要る(要求の「切り戻し手順」のとおり)。 + +1. ブランチの revert +2. `devbase build base --no-cache` +3. 使っている派生イメージの建て直し +4. 稼働中のコンテナの作り直し(`devbase down` → `devbase up`)。**`devbase rebuild` は使えない** + +## 完了の定義 + +- [ ] 受け入れ条件 16 件すべてに検証手段と結果が対応している +- [ ] `uv run --locked pytest tests/ -q` が `exit=0` +- [ ] `devbase build base --no-cache` が成功し、増分が 40 MB 以下 +- [ ] 解決先の表の 26 行が Pull Request 本文に載っている +- [ ] 実装を載せた Draft の Pull Request がある(#238) diff --git a/tests/containers/test_base_dockerfile_fonts.py b/tests/containers/test_base_dockerfile_fonts.py new file mode 100644 index 00000000..64a06ea7 --- /dev/null +++ b/tests/containers/test_base_dockerfile_fonts.py @@ -0,0 +1,254 @@ +"""base イメージの日本語の描画と、文書を扱う軽量の道具の「形」 (PLAN63 / #161, #160) + +Docker を起動せず、``containers/base/Dockerfile`` と ``containers/base/fonts-local.conf`` の +文字列と構造だけを固定する。実際の解決先 (``fc-match`` が何を返すか) は +``tests/containers/test_base_image_font_matching.py`` が建てたイメージの中で固定する。 + +ここで固定するのは次の 5 つである。 + +- 6 パッケージが **1 つ目の RUN の 1 回目の** ``apt-get install`` の一覧にある (設計の決定 4) +- ``COPY`` の宛先が ``/etc/fonts/local.conf`` であり ``conf.d/`` ではない (決定 1) +- ``fc-cache -f`` が 1 度だけ、``COPY`` より後、かつ Playwright の ``RUN`` より後にある (決定 5) +- ``fonts-local.conf`` が 4 つの ```` と 9 つの ```` を持ち、言語の規則が + 総称ファミリの ```` を必ず伴う (決定 2。この ```` を省くと欧文の指定を奪う) +- 入れないもの (LibreOffice / pip) と、消さないもの (``fonts-wqy-zenhei``) が守られている +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] / "containers" / "base" +DOCKERFILE = BASE_DIR / "Dockerfile" +FONTS_CONF = BASE_DIR / "fonts-local.conf" + +# 設計「解決の経路」で決めた、総称ファミリの向き先 +GENERIC_TO_JP = { + "sans-serif": "Noto Sans CJK JP", + "sans": "Noto Sans CJK JP", + "serif": "Noto Serif CJK JP", + "monospace": "Noto Sans Mono CJK JP", +} +# 言語を明示したときの向き先。**様式 (sans / serif / 等幅) を保つ** +LANG_RULES = { + ("sans-serif", "zh-cn"): "Noto Sans CJK SC", + ("sans", "zh-cn"): "Noto Sans CJK SC", + ("serif", "zh-cn"): "Noto Serif CJK SC", + ("monospace", "zh-cn"): "Noto Sans Mono CJK SC", + ("sans-serif", "ko"): "Noto Sans CJK KR", + ("sans", "ko"): "Noto Sans CJK KR", + ("serif", "ko"): "Noto Serif CJK KR", + ("monospace", "ko"): "Noto Sans Mono CJK KR", +} +FALLBACK_FAMILY = "Noto Sans CJK JP" +NEW_PACKAGES = ( + "poppler-utils", + "python3-pil", + "python3-defusedxml", + "python3-lxml", + "fonts-crosextra-carlito", + "fonts-crosextra-caladea", +) + + +def _statements() -> str: + """コメント行を除いた Dockerfile の本文 (説明の注記に assertion が反応しないように)""" + return "\n".join( + line for line in DOCKERFILE.read_text().splitlines() + if not line.lstrip().startswith("#") + ) + + +def _run_blocks() -> list[str]: + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける + + ``RUN`` の本文は ``\\`` の行継続で複数行にまたがる。``RUN`` で始まる**行**だけを + 見ると、拾えるのは 1 行目 (``RUN set -eux; \\``) だけでパッケージ名は 1 つも + 入らない。命令ごとに継続行まで連結してから検査する。 + """ + lines = _statements().splitlines() + blocks: list[str] = [] + block: list[str] | None = None + for line in lines: + if block is None: + if not line.startswith("RUN "): + continue + block = [] + block.append(line) + if not line.rstrip().endswith("\\"): + blocks.append("\n".join(block)) + block = None + if block is not None: # 最終行が \ で終わっていても取りこぼさない + blocks.append("\n".join(block)) + assert blocks, "RUN が 1 つも見つからない" + return blocks + + +def _first_run_block() -> str: + """1 つ目の RUN の 1 命令分 (行継続を含む) を取り出す""" + return _run_blocks()[0] + + +def _first_apt_install(block: str) -> str: + """1 つ目の RUN の**1 回目**の apt-get install の一覧だけを取り出す + + 1 つ目の RUN は apt-get install を 2 回呼ぶ。1 回目は Ubuntu の標準のアーカイブから、 + 2 回目は後から足したリポジトリ (docker-ce / terraform / gh / nodejs) からである。 + """ + calls = [m.start() for m in re.finditer(r"apt-get install", block)] + assert len(calls) >= 2, "1 つ目の RUN に apt-get install が 2 回無い" + return block[calls[0]:calls[1]] + + +# --------------------------------------------------------------------------- +# Dockerfile: 6 パッケージ (#160) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("package", NEW_PACKAGES) +def test_the_six_packages_are_in_the_first_apt_install(package): + """6 つとも標準のアーカイブにあるので、1 回目の一覧へ置く (決定 4)""" + assert re.search(rf"(? が効かない""" + assert re.search( + r"^COPY --chmod=0644 fonts-local\.conf /etc/fonts/local\.conf$", + _statements(), + flags=re.MULTILINE, + ) + + +def test_nothing_is_copied_into_fonts_conf_d(): + assert not re.search(r"^COPY\b.*\s/etc/fonts/conf\.d/", _statements(), flags=re.MULTILINE) + + +def test_fc_cache_runs_once_and_after_the_copy(): + text = _statements() + assert len(re.findall(r"fc-cache -f", text)) == 1 + assert text.index("COPY --chmod=0644 fonts-local.conf") < text.index("fc-cache -f") + + +def test_fc_cache_runs_after_playwright_installs_its_fonts(): + """決定 5。--with-deps が後から入れる書体を知らないキャッシュを残さない""" + text = _statements() + assert text.index("npx playwright install") < text.index("fc-cache -f") + + +# --------------------------------------------------------------------------- +# fonts-local.conf の構造 +# --------------------------------------------------------------------------- + +def _root() -> ET.Element: + root = ET.fromstring(FONTS_CONF.read_text()) + assert root.tag == "fontconfig" + return root + + +def test_the_conf_is_well_formed_xml(): + _root() + + +def test_the_four_generic_families_are_aliased_to_the_jp_faces(): + aliases = _root().findall("alias") + got = {} + for alias in aliases: + family = alias.findtext("family") + preferred = [f.text for f in alias.findall("prefer/family")] + assert preferred, f"{family} の に が無い" + got[family] = preferred[0] + assert got == GENERIC_TO_JP + + +def test_there_are_nine_matches_one_fallback_and_eight_language_rules(): + matches = _root().findall("match") + assert len(matches) == 9 + fallbacks = [m for m in matches if not m.findall("test")] + assert len(fallbacks) == 1 + + +def test_the_fallback_is_a_weakly_bound_append(): + """決定 3。弱い結合なので、実在する指定 (Arial など) を妨げない""" + fallback = next(m for m in _root().findall("match") if not m.findall("test")) + assert fallback.get("target") == "pattern" + edits = fallback.findall("edit") + assert len(edits) == 1 + edit = edits[0] + assert edit.get("name") == "family" + assert edit.get("mode") == "append" + assert edit.get("binding") == "weak" + assert edit.findtext("string") == FALLBACK_FAMILY + + +def test_every_language_rule_also_tests_the_generic_family(): + """決定 2。 を省くと Arial:lang=zh-cn から Liberation Sans を奪う""" + got = {} + for match in _root().findall("match"): + tests = match.findall("test") + if not tests: + continue + assert match.get("target") == "pattern" + names = [t.get("name") for t in tests] + assert "family" in names, "言語の規則が総称ファミリの を持たない" + assert "lang" in names + family = next(t.findtext("string") for t in tests if t.get("name") == "family") + lang_test = next(t for t in tests if t.get("name") == "lang") + assert lang_test.get("compare") == "contains" + edits = match.findall("edit") + assert len(edits) == 1 + assert edits[0].get("name") == "family" + assert edits[0].get("mode") == "prepend" + assert edits[0].get("binding") == "strong" + got[(family, lang_test.findtext("string"))] = edits[0].findtext("string") + assert got == LANG_RULES + + +def test_the_leading_comment_explains_why_the_location_cannot_move(): + """受け入れ条件 8。置き場所を動かせない理由と、その実測を先頭のコメントに残す""" + text = FONTS_CONF.read_text() + head = text[:text.index("")] + for token in ("51-local.conf", "conf.d", "99", "/etc/fonts/local.conf", "#161"): + assert token in head, f"先頭のコメントに {token} が無い" diff --git a/tests/containers/test_base_image_font_matching.py b/tests/containers/test_base_image_font_matching.py new file mode 100644 index 00000000..a151afe5 --- /dev/null +++ b/tests/containers/test_base_image_font_matching.py @@ -0,0 +1,187 @@ +"""建てた base イメージの中の解決先 (PLAN63 / #161, #160) + +Dockerfile の文字列検査 (``test_base_dockerfile_fonts.py``) では足りない。固定したいのは +「``COPY`` の行があること」ではなく「``fc-match sans-serif`` が日本語を返すこと」で、後者は +文字列からは分からない。設計の決定 2 のような壊れ方 (``lang`` の条件が広すぎて欧文の指定から +書体を奪う) は、Dockerfile を読んでも見えない。 + +**Docker が無い / イメージが無い / イメージが古いときは skip する。** この変更より前に建てた +``devbase-base:latest`` を持っている人が ``pytest tests/`` で全員赤くなるのを避けるためで、 +古いイメージを「失敗」として知らせると、赤の意味が「壊れている」と「イメージが古い」で混ざる。 + +**``docker run`` はセッションで 1 回に抑える。** ``fc-match`` を 28 回別々に走らせると、 +コンテナの起動だけで数十秒かかる。 +""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +IMAGE = "devbase-base:latest" +BUILD_HINT = f"`devbase build base --no-cache` で {IMAGE} を建て直すと、この検査が効く" + +# イメージの中に /etc/fonts/local.conf が無い (= この変更より前のイメージ) ときの終了コード +STALE_IMAGE_EXIT = 90 + +# 設計「解決先の表」の「変更後」の列。26 行のうち Meiryo / Yu Gothic / MS PGothic の行を +# 3 つへ開いてある +EXPECTED_MATCHES = { + # 総称ファミリ (受け入れ条件 1・2・3) + "sans-serif": "Noto Sans CJK JP", + "sans-serif:lang=ja": "Noto Sans CJK JP", + "sans": "Noto Sans CJK JP", + "serif": "Noto Serif CJK JP", + "monospace": "Noto Sans Mono CJK JP", + # 日本語環境でよく指定される書体名と、イメージに無い書体名 (受け入れ条件 4) + "Noto Sans JP": "Noto Sans CJK JP", + "Meiryo": "Noto Sans CJK JP", + "Yu Gothic": "Noto Sans CJK JP", + "MS PGothic": "Noto Sans CJK JP", + "Zen Kaku Gothic New": "Noto Sans CJK JP", + # 欧文は壊れない (受け入れ条件 5) + "Arial": "Liberation Sans", + "Times New Roman": "Liberation Serif", + "Courier New": "Liberation Mono", + # 欧文の metric 互換が直る (受け入れ条件 11。変更前はどちらも WenQuanYi Zen Hei) + "Calibri": "Carlito", + "Cambria": "Caladea", + # 他言語は壊れない。様式 (sans / serif / 等幅) も保つ (受け入れ条件 6) + "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", + # 欧文の指定は言語で変わらない。決定 2 の壊れ方を捕まえるのはこの 3 行である + "Arial:lang=zh-cn": "Liberation Sans", + "Times New Roman:lang=zh-cn": "Liberation Serif", + "Arial:lang=ko": "Liberation Sans", + # 実在する書体を名指しした指定は奪わない + "WenQuanYi Zen Hei": "WenQuanYi Zen Hei", + "IPAPGothic": "IPAPGothic", +} + +# 受け入れ条件 9・12 +EXPECTED_COMMANDS = { + "pdftoppm": True, + "pdfinfo": True, + "pdffonts": True, + "pdftocairo": True, + "uv": True, + "soffice": False, + "libreoffice": False, + "pip": False, + "pip3": False, +} + +_PROBE = r""" +set -u +# この変更より前に建てたイメージなら、測らずに抜ける +test -f /etc/fonts/local.conf || exit {stale} + +# ~/.bashrc が入れている PATH の行と同じ状態にする。uv / claude / agy は +# $HOME/.local/bin にあり、bash -c は対話でも login でもないため .bashrc を読まない +export PATH="$HOME/.local/bin:$PATH" + +first_family() {{ + # fc-match の既定の出力は `: "" "