◉ANTIGRAVITY LABEN
記事一覧/アプリ開発
▣ アプリ開発/2026-10-20中級

文言を直したら、新しく足した字だけが豆腐になりました。日本語 Web フォントのサブセットに欠け字チェックを足す手順

日本語の文言を一行直しただけで、新しく加えた字だけが豆腐(□)になることがあります。サブセット化したフォントに無い文字をファイルと行番号つきで洗い出し、1 文字でも欠けたら止める小さなスクリプトを、実際に動かした出力つきで書き残します。

Web フォントサブセットfontTools日本語3CI11個人開発113Antigravity390

個人開発のサイトで、日本語の文言を直したあと、私が表示を確かめに行くと、新しく足した字だけが四角い豆腐になっていました。ほかの文字はいつもどおりの書体です。

原因は単純で、日本語の Web フォントを使用文字だけに絞る「サブセット化」をしていたのに、文言を変えたあとで作り直していなかったからでした。

この手の見落としは、人の目に頼るかぎり、いつか通り抜けてしまいます。そこで、サブセットに無い文字を機械で見つけて、1 文字でも欠けたら止める小さな仕組みを組みました。個人開発の小さなサイトで組んだ手順を、実際に動かした出力つきで書き残します。

何が起きていたのか

日本語のフォントは、漢字だけで数千字あり、そのまま配信すると数 MB になります。そこで、ページで使う文字だけを残した軽いファイルを作って配信します。これがサブセット化です。

軽くなる代わりに、ファイルに入っていない文字は描けません。ブラウザは代わりの書体を探しますが、見つからなければ豆腐を出します。文言を変えるたびに「新しい字がサブセットに入っているか」を確かめる必要がある、ということです。

最初は、気をつけて作り直せばよいと考えていました。ところが、文言を直す人とフォントを作り直す人が同じ私でも、急いでいる日は忘れます。いまは、忘れる前提で、忘れたら検査が止める形にしておきたいと考えています。

用意するもの

動作は次の環境で確かめました。

項目内容
Python3.10
fontTools4.65.0(pip install fonttools)
brotli1.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, lineno

JSON のエスケープを戻している箇所が、この手順でいちばん効く部分です。「龍」のような字は、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 を一度だけ走らせてみていただければと思います。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

▣ アプリ開発2026-10-15
immutable を付けた資産を同じ URL で差し替えたら、利用者のブラウザだけが古いままでした
長期キャッシュを付けた JS を同じ URL で差し替えても、CDN をパージしてもブラウザは取り直しません。同じ URL・no-cache・クエリ付き・内容ハッシュ名の 4 方式を Chromium で並べて比べ、ビルドの最後にハッシュを付ける完全なスクリプトまでをまとめました。
▣ アプリ開発2026-09-29
Cloudflare をパージしても古い壁紙が届き続けた原因は、オリジン側で JPEG を握っていた XPageSpeed
同じ URL に上書きした壁紙画像が、Cloudflare をパージしても古いまま届き続けた運用の記録です。ローカル・オリジン・エッジの三者を突き合わせて層を名指しする検査、プレフィックスパージの使いどころ、Xserver の XPageSpeed を .htaccess で安全に切る書き方まで扱います。
▣ アプリ開発2026-09-19
見積書 PDF の自動化を一度にまとめて頼み、4 つに割り直すまで
受託の見積書を毎回コピーして作っていた手作業を、Antigravity に頼んで小さな PDF 出力ツールへ移した記録です。最初に全部をまとめて頼んで作り直すことになった理由と、データ・計算・見た目・保存の 4 つに割って渡し直した順番を、動くコードと一緒に書き残します。