graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

TrueTypeFontで遊んでみた。

最近はどうだかよく知らないけど、一昔前にウェブ界隈でフォントのサブセット化なるものが流行ってたらしい。ウェブフォント、つまりHTMLと一緒にフォントを提供して、コンテンツ制作者が意図する通りの文字列描画を行うというものがあるんだけど、特に日本語フォントは、配信するにはデータサイズが大きすぎる。だから、コンテンツ内で使われる文字だけを取り出して配信すればいいんじゃないか、というもの。

ただとても残念なことに、海外系のウェブフォントサービスで日本語を本気でサポートしてるものは稀で、自前でサブセット化するにしても、某有名ツールではカーニング情報が剥がれてしまう*1とか、ちょっと頑張ってぐぐった程度ではいいかんじの解説付きサンプルも見つからない。

なんでそんな状況になるのかをちょっとだけ調べてみた。

以下、TTFの仕様が大きすぎてまともな実装にはなってないけど、テスト用に書いたスクリプト
https://github.com/hal1932/test/blob/master/testSubset.py

目的

どうすれば十分に信頼できるフォントのサブセット化ができるのかを調べる。

準備

まずは、テストに使える改変OKなライセンスのフォントが必要なので、M+ FONTSを落としてくる。
M+ FONTS | ABOUT

次に、TTF編集ができるソフトウェアライブラリがいるので、十分な実績のありそうなFontToolsをセットアップ。

pip install numpy
pip install FontTools

TTF解析ツールには、Microsoft公式のTTFDumpを使うことにした。
Microsoft Typography - Internal dev tools

扱うフォントがTTCの場合は、FontToolsで扱うためにTTFに分解する必要があるので、UniteTTCも用意しておく。
UniteTTC


データ定義

TTFに含まれる情報

TTFを編集するにあたり何が扱われるべきなのかを確認する。以下のページの「1.1 展開(要件定義)」がとてもわかりやすかった。
d.hatena.ne.jp

フォントが使われる状況によっては、これに加えて絵文字を考慮する必要があるかもしれない。

TTFフォーマット

以下のページがわかりやすい。
An Introduction to TrueType Fonts: A look inside the TTF format

このページの解説によると、TTF自体は複数のテーブルで構成されていて、各テーブルに諸々の情報が詰まってる。サブセット化を行うためには、これらすべてのテーブルから、不要な文字についての情報を削除すればよさそうだ。見たところ一番データサイズが大きいのは 'glyf' テーブルだと想像できるので、まずはこれを削るところから考えてみる。

文字コードとグリフの対応付け

目的のグリフ情報が 'glyf' テーブルにあることはわかったけど、このテーブルのキーは「グリフID」という謎の数値になってるらしい。文字コードとグリフIDの対応表は 'cmap' テーブル内のサブテーブルにあるらしいので、文字コードに対応するグリフを取得するには、「文字コード→'cmap'→'cmap'サブテーブル→'glyf'」という手順を踏むことになる。

なぜそんな面倒なプロセスが必要かというと、まず、OSによって文字コードとグリフの対応表が異なるから。同じ文字コードでも、OSによって異なるグリフになる可能性がある。OSの歴史はTTFの歴史よりも長いんだから仕方ない。

また、ひとくちに「文字コード」といっても色々あって、Unicode 1.0 と Unicode 2.0 で表現できる文字は違うから、Unicode 1.0 で「文字コード-グリフID表」を引いたときと、Unicode 2.0 で引いたときで結果は異なる。ビット数も収録文字数も違うんだから仕方ない。

ついでに、特にマルチバイト文字圏で顕著な特徴として、言語によっては同じ文字コードにマップされているグリフが変わることがある。いわゆる「中華フォント」と呼ばれる現象。言語が違うんだから仕方ない。

このあたりの仕様は以下のページにとてもよく整理されてる。
The Naming Table

というわけでこのあたりのマッピングを正確に取得するには、以下のような手続きになる……と思う。

  1. グリフを取得したい文字列Aを用意する。
  2. 対象となるTTFの 'cmap' 内にあるサブテーブルを確認して、対応している Platform ID, Encoding ID, Language ID に応じたAの文字コード表現Bを取得する。
  3. 各サブテーブルからBに対応するグリフIDを検索する
  4. 検索したグリフIDに対応するグリフを取得する

フォントのサブセット化を行うには、ここで取得されなかったグリフが保存されている領域を、すべて潰してしまえばいい。

実演

冒頭のテストスクリプトを再掲。
https://github.com/hal1932/test/blob/master/testSubset.py

グリフ検索に使うコード体系を扱う 'cmap' サブテーブルを特定する

まずはターゲットとする mplus-1c-black.ttf の中身をTTF Dumpで確認してみる。
https://raw.githubusercontent.com/hal1932/test/master/mplus-1c-black.txt

情報量が膨大すぎてまともにチェックする気にはなれないんだけど、とりあえずWindows上で作業してるということで「Platform ID: 3」を検索してみる。

「Subtable 5」と「Subtable 6」がヒットする。5のほうに「Format: 4」とあって、さっきのページ をみると、どうやらこれは Encoding ID が 3, 4, 6 のどれかを表現するサブテーブルらしいということがわかる。すぐ下の Segment を追っていくと U+FFFF までが載っているということで、基本多言語面UCS-2)を表現する Encoding ID == 1 に相当するサブテーブルだとわかる。6のほうは「Format: 12」で U+00027FB7 まで載っている、つまり UCS-4 なので Encoding ID == 10 のサブテーブルになる。実際に FontTools で各サブテーブルの platformID と platEncID を確認すると、たしかに「3, 1」「3, 10」となっている。

UCS-4 は UCS-2 のスーパーセットなので、サブセットとして抜き出したい文字が Unicode であれば「Subtable 6」をチェックすればよさそうだ。

実際にはこれに加えて Language ID も考慮する必要があるのだけど、ここに辿り着いた時点で既に退っ引きならない雰囲気を感じていたので、これはあくまでテストコードなのだと割り切ることにした。

ttf = ttlib.TTFont(os.path.join(os.path.dirname(__file__), 'mplus-1c-black.ttf'))
tmp = filter(lambda t:t.platformID == WINDOWS, ttf['cmap'].tables)

「フォントに含まれるグリフ→文字コード」の辞書をつくる

FontToolsをみたかんじだと、先にこの対応表をつくってしまう方が楽で、実行速度も早そうだった。まぁそんなに対した話ではなくて、FontToolsから取得できるのが「文字コード→グリフ」の辞書だったので、キーとバリューを逆にしただけ。

# codeDic = [ { (name, code), ... } ]
codeDics = []
for t in tmp:
    print t.platformID, t.platEncID
    codeDic = {}
    for code, name in t.cmap.items():
        codeDic[name] = code
    codeDics.append(codeDic)

不要な文字に相当するグリフ情報を潰す

ここまでくればあとは不要なグリフを潰すだけなので、先に取得した辞書からグリフ情報を拾って、サブセット対象でなければ空のグリフを詰めるだけ。

for name, glyph in ttf['glyf'].glyphs.items():
    code = -1
    for codeDic in codeDics:
        if name in codeDic:
            code = codeDic[name]
    if not code in baseChars:
        ttf['glyf'].glyphs[name] = tttables._g_l_y_f.Glyph()

ただ、いざやってみたら、扱っている 'cmap' サブテーブルに含まれない文字が 'glyf' 内にかなりあることがわかった。軽く調べてみたところ、どうやら繁体字のうちのいくつか*2がこれに該当してる。確認はしてないけどおそらく、繁体字ということで People's Republic of China に相当する 0x0804 を、Language ID として 'cmap' サブテーブルから文字コードを引くときに指定してやれば、ちゃんと拾えるようになるような気がする。

結果の確認

というわけで、冒頭のスクリプトを実行してやると、無事 mplus-1c-black.ttf のサブセットフォントが出来上がる。サイズも小さくなってグリフが消えているのが確認できる。

f:id:hal1932:20161105165239j:plain
f:id:hal1932:20161105165245j:plain

実際にはきちんと言語ごとにグリフを検索してあげないといけないし、今回は 'glyf' しか編集してないけど、'post' や 'hmtx' 等の他のテーブルからも不要な情報を見つけて削除する必要がありそうだ。

結論

「TTFのまともなサブセット化」がなぜ難しいのかよくわかった。TTFは複雑すぎる。素人が手を出してはいけない。*3

*1:だからといって使えないというわけではなく、あくまで求めるデザインの精度次第。

*2:こういうの。 → Unicode Han Character 'U+8211' (U+8211)

*3:どうしても手を出す必要があるときは、フォントベンダが公式に用意しているサブセット化ツールを使うべきなんだと思う。もちろん、そんなものがきちんと存在して、それを自分が首尾よく手配できれば、の話ではある。。