個人開発のサイトで、日本語の文言を直したあと、私が表示を確かめに行くと、新しく足した字だけが四角い豆腐になっていました。ほかの文字はいつもどおりの書体です。
原因は単純で、日本語の Web フォントを使用文字だけに絞る「サブセット化」をしていたのに、文言を変えたあとで作り直していなかったからでした。
この手の見落としは、人の目に頼るかぎり、いつか通り抜けてしまいます。そこで、サブセットに無い文字を機械で見つけて、1 文字でも欠けたら止める小さな仕組みを組みました。個人開発の小さなサイトで組んだ手順を、実際に動かした出力つきで書き残します。
何が起きていたのか
日本語のフォントは、漢字だけで数千字あり、そのまま配信すると数 MB になります。そこで、ページで使う文字だけを残した軽いファイルを作って配信します。これがサブセット化です。
軽くなる代わりに、ファイルに入っていない文字は描けません。ブラウザは代わりの書体を探しますが、見つからなければ豆腐を出します。文言を変えるたびに「新しい字がサブセットに入っているか」を確かめる必要がある、ということです。
最初は、気をつけて作り直せばよいと考えていました。ところが、文言を直す人とフォントを作り直す人が同じ私でも、急いでいる日は忘れます。いまは、忘れる前提で、忘れたら検査が止める形にしておきたいと考えています。
用意するもの
動作は次の環境で確かめました。
| 項目 | 内容 |
|---|---|
| Python | 3.10 |
| fontTools | 4.65.0(pip install fonttools) |
| brotli | 1.2.0(woff2 の出力に必要。pip install brotli) |
| 元のフォント | Noto Serif CJK の Bold(.ttc) |
ファイルは三つです。文字を集める共通部品、サブセットを作るスクリプト、欠けた文字を調べるスクリプトに分けます。共通部品を一つにするのは、「作る側」と「調べる側」で文字の数え方がずれると、検査が意味を失うためです。
手順 1: サイト内の文字を集める
対象のファイルを順に読み、文字と、その出どころ(ファイルと行番号)を返します。
#!/usr/bin/env python3
"""サイトの文字を集める共通部品。build と check の両方がこれを読む。"""
import pathlib, re, unicodedata
TEXT_EXT = {".html", ".mdx", ".md", ".json", ".txt", ".tsx", ".ts"}
# 描画に字形が要らない文字(改行・タブ・ゼロ幅など)
SKIP_CATEGORIES = {"Cc", "Cf", "Zl", "Zp"}
def iter_chars(root: pathlib.Path):
"""(文字, ファイル, 行番号) を順に返す。"""
for path in sorted(root.rglob("*")):
if path.suffix not in TEXT_EXT or not path.is_file():
continue
text = path.read_text(encoding="utf-8")
# JSON は \uXXXX のエスケープを元に戻してから走査する
if path.suffix == ".json":
text = re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), text)
for lineno, line in enumerate(text.splitlines(), start=1):
for ch in line:
if unicodedata.category(ch) in SKIP_CATEGORIES:
continue
if ch == " ":
continue
yield ch, path, linenoJSON のエスケープを戻している箇所が、この手順でいちばん効く部分です。「龍」のような字は、JSON の中では 龍 と書かれていることがあり、文字をそのまま探す方法では見つかりません。
手順 2: 使用文字だけのフォントを作る
#!/usr/bin/env python3
"""サイト内の全文字だけを残した woff2 を作る。"""
import pathlib, sys
from fontTools import subset
from fontTools.ttLib import TTCollection
from scan_chars import iter_chars
src_ttc, site_dir, out = sys.argv[1], pathlib.Path(sys.argv[2]), sys.argv[3]
BASE = "".join(chr(c) for c in range(0x20, 0x7F)) # ASCII は常に残す
chars = sorted({c for c, _, _ in iter_chars(site_dir)} | set(BASE))
# .ttc から JP の1書体だけ取り出す(先頭が JP とは限らないので名前で探す)
font = next(f for f in TTCollection(src_ttc).fonts if f["name"].getDebugName(1).endswith(" JP"))
opts = subset.Options(flavor="woff2", notdef_outline=True)
sub = subset.Subsetter(opts)
sub.populate(text="".join(chars))
sub.subset(font)
subset.save_font(font, out, opts)
pathlib.Path(out).with_suffix(".chars.txt").write_text("".join(chars), encoding="utf-8")
print(f"chars={len(chars)} -> {out} ({pathlib.Path(out).stat().st_size} bytes)")最初は layout_features=["*"] を付けて、字形に関わる機能をすべて残していました。同じ 142 文字で比べると、付けた版は約 80 KB、付けない版は約 41 KB でした。縦書きや合字を使わないサイトでは、既定のままで足ります。
手順 3: 欠けた文字を洗い出す
#!/usr/bin/env python3
"""サブセット済みフォントに無い文字を、ファイルと行番号つきで出す。1文字でも欠けたら exit 1。"""
import pathlib, sys, unicodedata
from fontTools.ttLib import TTFont
from scan_chars import iter_chars
font_path, site_dir = sys.argv[1], pathlib.Path(sys.argv[2])
cmap = TTFont(font_path).getBestCmap()
missing = {}
for ch, path, lineno in iter_chars(site_dir):
if ord(ch) not in cmap:
missing.setdefault(ch, []).append(f"{path.relative_to(site_dir)}:{lineno}")
for ch, where in sorted(missing.items()):
name = unicodedata.name(ch, "?")
print(f"MISSING U+{ord(ch):04X} {ch} {name} {where[0]}" + (f" (+{len(where)-1})" if len(where) > 1 else ""))
print(f"missing={len(missing)}")
sys.exit(1 if missing else 0)調べる対象は、作る側が出した文字の一覧ではなく、配信するフォントファイルそのものです。一覧と実物がずれる事故(古いファイルを配っている、別の場所に置いている)まで拾えるのは、実物を見る方だけです。
実際に動かした結果
まず、一行目と二行目だけのページで作り、検査を通します。
$ python3 build_subset.py /usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc site fonts/body.woff2
chars=117 -> fonts/body.woff2 (26016 bytes)
$ python3 check_glyphs.py fonts/body.woff2 site; echo exit=$?
missing=0
exit=0次に、冒頭の出来事を真似て、新しい一文と JSON のデータを足します。フォントは作り直しません。
$ python3 check_glyphs.py fonts/body.woff2 site; echo exit=$?
MISSING U+96C6 集 CJK UNIFIED IDEOGRAPH-96C6 page.html:3
MISSING U+9F8D 龍 CJK UNIFIED IDEOGRAPH-9F8D data/cards.json:1
MISSING U+FF5E ~ FULLWIDTH TILDE page.html:3
missing=26
exit=1一行足しただけで、26 文字が欠けました。ひらがなの「い」「う」「く」まで含まれています。サブセットは「いま使っている字」しか持たないので、新しい文に含まれる字は、見慣れた字でも入っていないことがあるのです。
作り直すと通ります。
$ python3 build_subset.py /usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc site fonts/body.woff2
chars=143 -> fonts/body.woff2 (41724 bytes)
$ python3 check_glyphs.py fonts/body.woff2 site; echo exit=$?
missing=0
exit=0直感に反したこと
二つあります。
一つめは、波ダッシュです。画面に出ていた「~」は全角チルダ(U+FF5E)で、日本語の文章でよく使う波ダッシュ「〜」(U+301C)とは別の文字です。見た目はほぼ同じなのに、サブセットには別々に入ります。元のフォントは両方を持っていました(確認済み)。ですので、欠けたのは字形が無いからではなく、使う文字の一覧に入っていなかったからです。
二つめは、豆腐が出るのは新しい字だけとは限らない点です。上の実行で欠けた 26 文字には、ひらがなが 11 字含まれていました。「ひらがなは前から使っているから大丈夫」という感覚は、サブセットでは当てになりません。
公開前に必ず通す
終了コードが 1 になる作りにしてあるので、公開の直前に走らせれば、欠けたまま出すことはありません。
python3 build_subset.py "$SRC_TTC" site fonts/body.woff2 && python3 check_glyphs.py fonts/body.woff2 site作ってから検査する順にしておけば、サブセットの作り忘れだけでなく、元のフォントにそもそも無い字(異体字など)も検査が止めてくれます。後者は作り直しでは直らないので、字を替えるか、その部分だけ別の書体を指定する判断になります。
CSS 側では、unicode-range を使って、サブセットを複数に分ける方法もあります。ただし今回の検査は一つのファイルを前提にしており、分割したときの確認までは動かしていません。
エージェントに文言を任せる場合
エージェントに文言の修正を頼むと、一度に何十行も変わります。頼む前に「終わりの条件」を先に書いておくのがよいと考えています。例えば、次のような一文です。
作業の終わりに、check_glyphs.py を実行し、exit 0 になるまで続けてください。欠けた文字が出たら、字を替える前に一覧を私に見せてください。
この書き方にしているのは、エージェントが「欠けた字を勝手に別の字へ置き換えて通す」ことを避けたいからです。通すこと自体が目的になると、文章が変わってしまいます。作業を任せる前に確かめておきたいこと全般は、同期フォルダの除外を確かめる記事 にも書きました。
文言の変更は、フォントの変更でもあります。 公開の前に、この一行の検査だけは外さないようにしたいところです。
まずは、手元のサイトの文言と配信中のフォントに対して、check_glyphs.py を一度だけ走らせてみていただければと思います。