Pillowのfont.getbbox()は、フォントがその文字のグリフを持っていなくても代替グリフ (いわゆる豆腐) の矩形を返すため、「この文字を描けるか」の判定には使えません。fontToolsでcmap (文字→グリフの対応表) を直接引くのが正解です。ただしmacOSでは、全文字にグリフを返す特殊フォント.LastResortがcmap判定を必ず通過するため、その除外もセットで必要です。
getbboxで判定してはいけない理由
日本語の特定の文字 (将棋の駒に使う「歩」「圭」「杏」など) を描けるフォントをシステムから列挙したとき、最初は「getbboxが有効な矩形を返すか」で判定していました。結果は694フェイスが合格。その中には点字フォントまで含まれていて、生成した画像には「?」型の豆腐が混ざりました。
原因は、グリフが無い文字を描くとフォントの.notdefグリフ (豆腐) が代わりに使われ、getbboxはその豆腐の矩形を返すからです。幅や高さが0かどうかを見ても検出できません。豆腐にはちゃんと幅があります。
cmapを直接読むのが正解
フォントファイルのcmapテーブルに文字が載っているかを確認すれば、描けるかどうかを確実に判定できます。fontToolsを使います。
from fontTools.ttLib import TTFont
def covers_all(path: str, chars: str, index: int = -1) -> bool:
"""フォントが chars の全文字のグリフを持つか"""
try:
tt = TTFont(path, fontNumber=index)
cmap = tt.getBestCmap()
tt.close()
except Exception:
return False
if not cmap: # cmapを持たない装飾フォントなど
return False
return all(ord(ch) in cmap for ch in chars)
2つ注意点があります。.ttc (コレクション形式) は複数フェイスを含むのでfontNumberを0から順に試して列挙すること。そしてgetBestCmap()はNoneを返すことがあるので、そのガードを入れることです。
.LastResortはcmap判定を必ず通過する
cmap判定に切り替えても、まだ1つだけ「全文字に合格する」フォントが残ります。macOSの.LastResortです。これはOSがフォールバックの最終手段に使う特殊フォントで、仕様として事実上すべてのコードポイントにグリフを対応させています。つまり「cmapに載っているか」では絶対に弾けません。
ファイル名が「.」で始まるフォントはシステム内部用なので、パスの段階で一律除外するのが手っ取り早い対処です。
if path.name.startswith("."): # .LastResort などシステム内部用
continue
getbbox判定で694フェイスだった候補は、cmap直読 + ドットファイル除外で52フェイスになり、豆腐の混入は無くなりました。フォントを列挙して使う処理 (画像生成・帳票・学習データの合成など) を書くときは、この2段構えにしておくと安全です。