[Astro] #157 Font Inspector — HarfBuzz WASMでフォントの中身を覗く(TrueTypeヒンティングVMの逆アセンブラ付き)

[Astro] #157 Font Inspector — HarfBuzz WASMでフォントの中身を覗く(TrueTypeヒンティングVMの逆アセンブラ付き)

はじめに

前回(#156)は、SameBoy の C ソースを Emscripten で自前ビルドして、Astro に組み込みました。

今回は Font Inspector です。

WASM ツールの開拓マップで、Tier 1 に1つだけ残っていた「HarfBuzz + FreeType WASM で作るフォントビューア」です。AI にこのマップを見せると、毎回決まって最初に出てくる題材でした。フォントには特に興味がなかったので、二度と出てこないように片付けてしまおう、というのが正直な動機です。

ところが中を開けてみると、TrueType フォントの中には、文字を小さいサイズでもきれいに見せるための「スタックマシン用の機械語」 が入っていました。Z80 や 6502 のエミュレーターを書いてきた身としては、見過ごせない話です。そこで、グリフを眺めるだけのビューアではなく、フォントの中の VM を覗くツール として作りました。

最初に書いておくと、このツールで一番難しい部分、つまりアウトラインの取り出しと文字の並べ方(シェーピング)は、すべて HarfBuzz がやっています。自分で書いたのは、フォントファイルの構造を読む部分と、命令の逆アセンブラです。どこまでが借り物で、どこからが自作かも、この記事で分けて説明します。

完成画面

Font Inspector — グリフ一覧と「@」の制御点

Font Inspector — グリフ一覧と「@」の制御点

Font Inspector — SHAPING — ffi 合字が 1 グリフになる

SHAPING — ffi 合字が 1 グリフになる

Font Inspector — TRUETYPE VM — fpgm の逆アセンブルと VM のスペック表

TRUETYPE VM — fpgm の逆アセンブルと VM のスペック表

Font Inspector — HEX — テーブルの生バイト

HEX — テーブルの生バイト

Font Inspector — 制御点のドラッグとライセンス警告

制御点のドラッグとライセンス警告

操作動画

操作動画

画面は、左がフォント情報とテーブル一覧、中央がタブ(GLYPHS / SHAPING / TRUETYPE VM / HEX)、右が選択中のグリフの詳細です。

できることは次のとおりです。

  • TTF / OTF / TTC / WOFF を開く(ファイルはサーバーに送らない)
  • フォント情報、テーブル一覧、テーブルごとの HEX ダンプ
  • 全グリフの一覧(240 個ずつのページ送り、文字 / U+XXXX / gid / グリフ名で検索)
  • グリフのアウトラインと制御点の表示、点のドラッグ(プレビューのみ)
  • シェーピング(OpenType feature の指定、縦書き、可変フォントの軸スライダー)
  • TrueType ヒンティング命令の逆アセンブル(fpgm / prep / cvt / グリフごとの命令)

構成

いつもどおり、ページは Astro、処理は public/ に置いた素の JS です。HarfBuzz は npm の harfbuzzjs から dist/ の3ファイルをそのまま持ってきています。

my-astro-project/
├─ src/pages/
│   └─ font-inspector.astro          UI(BaseLayout で包む)
└─ public/
    ├─ font-inspector/
    │   ├─ font-inspector.js         本体(UI・描画・シェーピング)
    │   ├─ font-inspector.css        スタイル(Astro を通さない)
    │   ├─ sfnt.js                   フォントファイル構造の自前パーサー
    │   └─ tt-disasm.js              TrueType 命令の逆アセンブラ
    └─ libs/
        └─ harfbuzz/
            ├─ index.mjs             harfbuzzjs 1.6.2(JS ラッパー)
            ├─ harfbuzz.js           Emscripten のローダー
            ├─ harfbuzz.wasm         HarfBuzz 14.5.0 本体(約 430KB)
            └─ LICENSE

役割分担はこうなっています。

担当やっていること
HarfBuzz(WASM)アウトラインを SVG パスに変換、シェーピング(合字・カーニング・縦書き)、name / cmap の読み取り、可変フォントの補間、グリフ名
sfnt.js(自前)テーブルディレクトリ、head / maxp / OS/2、glyf / loca からの命令列の取り出し、WOFF の展開、TTC の分解、CFF の CID 表
tt-disasm.js(自前)TrueType 命令のデコード、FDEF / CALL の関数番号の推定
font-inspector.js(自前)画面、グリフ一覧、制御点の描画とドラッグ、各タブ

フォントファイルの中身(sfnt)

まず、フォントファイルがどういう形をしているかです。TTF も OTF も、中身は sfnt という共通の入れ物です。先頭にテーブルの目次があって、その後ろに4文字のタグで名前の付いたテーブルが並んでいるだけです。

オフセット 0
┌──────────────────────────┐
│ sfntVersion (4B)         │  0x00010000 = TrueType / 'OTTO' = CFF
│ numTables   (2B)         │  テーブルの数
│ ...                      │
├──────────────────────────┤
│ テーブル目次 × numTables  │  1件16バイト: tag / checksum / offset / length
├──────────────────────────┤
│ 'cmap' 文字コード → グリフ番号 │
│ 'glyf' アウトライン(TrueType) │
│ 'CFF ' アウトライン(OTF)     │
│ 'maxp' 最大値の表              │
│ 'fpgm' 'prep' 'cvt ' ヒンティング │
│ ...                      │
└──────────────────────────┘

目次を読むコードは、DataView で16バイトずつ読むだけです。Z80 エミュレーターで ROM ヘッダーを読むのと同じ感覚です。

// sfnt.js
export function readDirectory(buf, faceOffset = 0) {
  const dv = new DataView(buf);
  const sfntVersion = dv.getUint32(faceOffset);
  const numTables = dv.getUint16(faceOffset + 4);
  const tables = [];
  for (let i = 0; i < numTables; i++) {
    const o = faceOffset + 12 + i * 16;
    tables.push({
      tag: tagOf(dv, o),              // 'glyf' などの4文字
      checksum: dv.getUint32(o + 4),
      offset: dv.getUint32(o + 8),
      length: dv.getUint32(o + 12),
    });
  }
  tables.sort((a, b) => a.offset - b.offset);
  return { sfntVersion, tables };
}

左パネルの TABLES は、この結果をそのまま表にしたものです。クリックすると、HEX タブにそのテーブルの生バイトが出ます。

TTF / OTF / TTC / WOFF の読み分け

先頭4バイトで見分けます。

先頭形式対応
00 01 00 00TTF(TrueType アウトライン、2次ベジェ)そのまま
OTTOOTF(CFF アウトライン、3次ベジェ)そのまま
ttcfTTC(複数フォントを1ファイルにまとめたもの)フォント番号を選ばせる
wOFFWOFF(Web フォント。各テーブルが zlib 圧縮)展開して sfnt に組み直す
wOF2WOFF2(Brotli 圧縮 + テーブル変換)v1 では未対応

WOFF は、テーブルごとに zlib で圧縮されているだけなので、ブラウザ標準の DecompressionStream('deflate') で展開できます。展開したテーブルを並べ直して、普通の sfnt として HarfBuzz に渡しています。

// sfnt.js(woffToSfnt の一部)
if (e.compLength < e.origLength) {
  const ds = new Blob([raw]).stream().pipeThrough(new DecompressionStream('deflate'));
  datas.push(new Uint8Array(await new Response(ds).arrayBuffer()));
} else {
  datas.push(raw.slice()); // 圧縮すると逆に大きくなるテーブルは無圧縮で入っている
}

WOFF2 は Brotli 圧縮に加えて、glyf テーブルを別の形に変換して詰めているので、展開はかなり大がかりになります。v1 では見送りました。

HarfBuzz(harfbuzzjs)の使い方

HarfBuzz は、Chrome、Firefox、Android、LibreOffice などで「文字をどう並べるか」を担当している、事実上の標準のシェーピングエンジンです。名前はペルシャ語で「open type」を意味する言葉の音写です。harfbuzzjs は、それを WASM にして npm で配っているプロジェクトです。

使い方は README のとおりで、とても素直です。

// font-inspector.js
const hb = await import('/libs/harfbuzz/index.mjs');

const face = new hb.Face(new hb.Blob(arrayBuffer), faceIndex); // TTC ならフォント番号
const font = new hb.Font(face);

font.glyphToPath(gid);    // → "M700,1294L426,551..." の SVG パス
font.glyphToJson(gid);    // → [{type:'M', values:[700,1294]}, ...] 制御点つき
font.glyphName(gid);      // → "A"
font.glyphHAdvance(gid);  // → 送り幅
face.collectUnicodes();   // → cmap にある全文字

index.mjs は、読み込まれると同時に、自分と同じ場所にある harfbuzz.wasm を import.meta.url 基準で取りに行きます。3ファイルを同じフォルダに置いておけば、パスの設定は要りません。

ひとつ注意があります。harfbuzzjs は HarfBuzz を HB_TINY という軽量設定でビルドしています。README にもあるとおり、一部の機能は削られています。今回の範囲では困りませんでしたが、足りない機能があるときは自分でビルドし直すことになります。

グリフを描く

Y 軸が逆

フォントの座標は、数学と同じく 上が +Y です。ベースラインが y=0 で、アセンダーは上、ディセンダーは下にあります。SVG は 下が +Y なので、そのまま描くと上下が逆さまになります。

// パスに scale(1,-1) をかけて上下を反転
`<svg viewBox="${-pad} ${-ascender - pad} ${w} ${h}">
   <path d="${font.glyphToPath(gid)}" transform="scale(1,-1)"/>
 </svg>`

viewBox の上端を -ascender にしておくと、ベースラインがちょうど y=0 の線になります。右パネルの水色の横線がベースライン、縦線が送り幅の開始位置と終了位置です。

制御点とベジェ曲線

glyphToJson() は、パスを M / L / Q / C / Z のコマンド列で返します。これを見れば、どの点が線の通る点(on-curve)で、どれが引っぱるだけの制御点(off-curve)かが分かります。

コマンド意味表示
M L線が通る点オレンジの塗りつぶし
Q2次ベジェ(制御点1つ)= TrueTypeピンクの白抜き1つ
C3次ベジェ(制御点2つ)= CFFピンクの白抜き2つ

DejaVu Sans(TTF)と源ノ角ゴシック(OTF)を並べて見ると、点の並び方が違って見えるのはこのためです。

点のドラッグ

右パネルの点は、掴んで動かせます。動かすたびにコマンド列を書き換えて、パスを描き直しているだけです。フォントファイルには一切書き戻しません。

そのフォントで最初に点を触ったときは、name テーブルのライセンス欄(ID 13 / 14)を見て警告を出します。SIL Open Font License のフォントなら「改変は可能だが、改変版を配るときは元の名前を使えない」、それ以外なら「ライセンス本文を確認してください」と表示します。

シェーピング

SHAPING タブでは、文字列を HarfBuzz に渡して、どのグリフがどこに並ぶかを見ます。

const buffer = new hb.Buffer();
buffer.addText('office ffi AVA Wave');
buffer.guessSegmentProperties();          // 言語・文字種・方向を推測
hb.shape(font, buffer, [new hb.Feature('liga', 1)]);
buffer.getGlyphInfosAndPositions();       // gid / cluster / advance / offset

結果の表を見ると、面白いことが分かります。

  • office の ffi の3文字が、uniFB03 という1つのグリフ になっている(合字)。cluster の番号が 1 から 4 へ飛ぶのは、3文字分がまとめて1グリフになったからです。
  • AVA の A の送り幅が、単体では 1401 なのに、V の前では 1270 に詰まっている(カーニング)。
  • 源ノ角ゴシックや Noto CJK で方向を TTB にすると、vert feature が効いて、句読点や「」が縦書き用のグリフに差し替わる。

feature は liga=0, kern, ss01 のように書けます。liga=0 にすると、合字がほどけて3グリフに戻ります。

TrueType ヒンティング VM

ここからが本題です。

ヒンティングとは

フォントのアウトラインは、1000 や 2048 といった大きな座標系(unitsPerEm)で描かれています。これを 12px のような小さいサイズに縮めると、線がピクセルの境目にかかって、にじんだり太さが揃わなかったりします。

そこで TrueType では、「このサイズのときは、この点をこのピクセルに合わせろ」という手順を、プログラムとしてフォントに埋め込めます。これがヒンティングです。そのプログラムを実行するのが、フォントのラスタライザ(FreeType など)に内蔵された小さなスタックマシンです。

プログラムは3か所に入っています。

場所いつ実行されるか中身
fpgmフォントを読み込んだとき1回関数定義(FDEF 〜 ENDF)
prep表示サイズが変わるたびCVT の調整などの前処理
glyf の各グリフそのグリフを描くたび点をグリッドに合わせる本体

maxp は「VM のスペック表」

maxp テーブル(バージョン 1.0)には、この VM が必要とする資源の最大値が書いてあります。CPU のデータシートのようなものなので、TRUETYPE VM タブではカードにして並べています。DejaVu Sans 2.33 の場合はこうなりました。

項目値意味
maxStackElements1045スタックの深さ
maxFunctionDefs8fpgm で定義する関数の数
maxStorage153読み書きできるストレージ領域
CVT255命令から参照する定数表(Control Value Table)
maxTwilightPoints16輪郭の外に置ける作業用の点
命令ありグリフ1123 / 5928命令を持つグリフの数
グリフ命令の合計74,413 B全グリフの命令バイト数
最長のグリフ命令#2983 / 534 B一番長いプログラム

CVT は「ステムの太さ」「x ハイト」のような、フォント全体で共通の寸法を並べた表です。命令はここを参照して、「縦線の太さはすべて cvt[12] に揃えろ」のように書きます。

命令のエンコード

命令は 1 バイトのオペコードです。Z80 のように、オペランドをオペコードの中のビットに埋め込んでいる命令が多いのが特徴です。

0xB0-0xB7  PUSHB[n]   n+1 バイトを積む(個数が下位3ビットに入っている)
0xB8-0xBF  PUSHW[n]   n+1 ワードを積む
0x40       NPUSHB     次の1バイトが個数、その後ろにデータ
0x00-0x01  SVTCA[a]   a=0 なら y 軸、a=1 なら x 軸
0xC0-0xDF  MDRP[abcde]  下位5ビットがフラグ
0xE0-0xFF  MIRP[abcde]  同上

MDRP / MIRP の5ビットは、「参照点 rp0 を更新するか」「最小距離を守るか」「丸めるか」「距離の種類(灰 / 黒 / 白)」を表しています。右パネルで MIRP[11100] ; rp0 min rnd 灰 のように出ているのは、このビットを読み下したものです。

デコーダーの中心部分はこれだけです。PUSH 系だけが可変長で、あとはすべて1バイトです。

// tt-disasm.js
if (op === 0x40 || op === 0x41) {               // NPUSHB / NPUSHW
  const n = bytes[pc + 1];
  const word = op === 0x41;
  len = 2 + n * (word ? 2 : 1);
  for (let i = 0; i < n; i++) ins.args.push(readVal(bytes, pc + 2 + i * (word ? 2 : 1), word));
} else if (op >= 0xb0 && op <= 0xbf) {          // PUSHB[n] / PUSHW[n]
  const word = op >= 0xb8;
  const n = (op & 7) + 1;                       // 個数はオペコードの下位3ビット
  len = 1 + n * (word ? 2 : 1);
  for (let i = 0; i < n; i++) ins.args.push(readVal(bytes, pc + 1 + i * (word ? 2 : 1), word));
}

関数番号の推定

FDEF も CALL も、関数番号を スタックから取ります。命令自体には番号が書いてありません。

PUSHB 7 6 5 4 3 2 1 0   ← 関数番号を8個まとめて積む
FDEF                    ← スタックの先頭 0 を取って「関数 0」の定義開始
  ...
ENDF
FDEF                    ← 次は 1 を取って「関数 1」

実行しないと本当の値は分かりませんが、ほとんどのフォントは「PUSH で積んだ直後に FDEF / CALL」という素直な書き方をしています。そこで、直前に PUSH したリテラルだけを追う簡易スタック を持たせて、番号を推定しています。PUSH 以外の命令が来たら、何が起きるか分からないので捨てます。FDEF の中身は定義時には実行されないので、FDEF に入るときに退避し、ENDF で元に戻します。

if (ins.args.length) {
  lits.push(...ins.args);                       // PUSH ならリテラルを積む
} else if (name === 'FDEF') {
  ins.fnId = lits.length ? lits.pop() : null;   // 関数番号
  saved.push(lits); lits = [];                  // 本体は定義時に実行されないので退避
} else if (name === 'ENDF') {
  lits = saved.pop() || [];
} else if (name === 'CALL' || name === 'LOOPCALL') {
  ins.callId = lits.length ? lits.pop() : null; // 呼び出し先
  lits = [];
} else {
  lits = [];                                    // それ以外はスタックが読めなくなる
}

推定できた CALL は、fpgm の関数へのリンクになります。たとえば DejaVu Sans の「Â」は、「A」とアクセント記号を組み合わせた複合グリフで、命令の途中で CALL → fn 7 を呼んでいます。リンクを押すと、TRUETYPE VM タブの関数 7 の定義に飛びます。

逆アセンブル結果は、Python の fontTools(ttx)が出す命令列と突き合わせて、一致することを確認しました。

読み方の例:「@」のプログラム

DejaVu Sans の「@」(149 バイト、71 命令)の先頭はこうなっています。

0000  NPUSHB 24 12 3 9 169 25 21 27 …(+42)   使う値をまとめて積む
0034  SRP0                                    基準点を決める
0035  MDRP[11100]  ; rp0 min rnd 灰           基準点から距離を測って点を置く
0036  MIRP[01100]  ; min rnd 灰               CVT の寸法で次の点を置く
...
      IUP[x]                                   動かさなかった点を補間
      IUP[y]

Z80 にたとえると、NPUSHB は即値のロード、SRP0 は基準レジスタのセット、MIRP は「基準点から CVT の寸法だけ離して点を置く」命令です。最後の IUP で、命令で触らなかった残りの点を、触った点に合わせて補間します。全体としては、「@」の線の太さを、どのサイズでもピクセルちょうどに揃えるための手順書になっています。

CFF フォントにはない

OTF(CFF)のヒントは、「ここにステムがある」という宣言だけで、実行できる命令はありません。源ノ角ゴシックを開くと TRUETYPE VM タブが空なのはそのためです。また、Google Fonts の可変フォントのように、ヒンティングを省いた TTF も増えています(Lora は prep が 7 バイトだけでした)。命令がびっしり入っているのは、DejaVu や、Windows 付属の昔からあるフォントです。

CID 方式の CFF フォント

源ノ角ゴシックを開いたとき、最初はグリフ名がすべて gid33 のようになっていました。CID 方式の CFF フォントには、post テーブルにグリフ名がないためです。

CFF テーブルの Top DICT に ROS(Registry-Ordering-Supplement)という項目があれば CID 方式です。その場合は charset を読んで、グリフ番号から CID 番号への表を作り、cid00033 のように表示しています。

// sfnt.js(readCidMap の一部)
const top = readDict(dv, topIdx.items[0].start, topIdx.items[0].end);
if (!top[1230]) return null;           // ROS(12 30)がなければ CID 方式ではない
const charsetOff = top[15]?.[0];       // charset(15)
// format 0 / 1 / 2 に応じて gid → CID を展開

CFF の DICT は、「オペランドを並べてから演算子」という後置記法のバイト列です。これも、小さな VM のようなものです。

ハマりどころ

1. Astro で CSS だけ古いまま配られる

点のドラッグ機能を足したとき、HTML は新しくなっているのに、新しく足したクラスにだけスタイルが当たりませんでした。DevTools で見ると、.fi-warn のルールそのものが存在しません。.astro に書いた <style is:global> を Astro が処理して配る部分で、古い CSS が残っていたようです。

CSS を public/font-inspector/font-inspector.css に出して、<link> で読み込むように変えたら解決しました。public/ のファイルは Astro が手を加えずにそのまま配るので、この問題が起きません。キャッシュ対策に ?v=4 のようなクエリを付けています。

<link rel="stylesheet" href="/font-inspector/font-inspector.css?v=4" />

BaseLayout に入れるときの上下 70px の余白や高さは、CSS ファイルを触らずに済むよう、要素に直接書く style 属性で上書きしています。

2. ドラッグすると点が何倍も飛ぶ

マウス座標を SVG 座標に直すには svg.getScreenCTM().inverse() を使います。ところが、点を動かすたびに SVG を丸ごと作り直していたので、ドラッグ開始時に掴んだ SVG は、次の瞬間には DOM から外れた古い要素になっていました。外れた要素の変換行列を使ったせいで、座標がずれていました。変換の直前に、毎回いまの SVG を取り直すことで直りました。

3. CFF ではなく CFF

テーブルのタグは必ず4文字です。CFF は 末尾に空白が1つ付いた 'CFF ' です('cvt ' も同じ)。tables.CFF で探して見つからず、アウトラインの種類が「-」になっていました。

4. hidden 属性が効かない

TTC のときだけ出すフォント番号の選択欄に hidden を付けていたのに、表示されたままでした。CSS で display: flex を指定していたため、hidden の既定の display: none が上書きされていたのが原因です。[hidden] { display: none !important; } を足しました。

クレジットとライセンス

  • HarfBuzz(Old MIT License)— Behdad Esfahbod、Khaled Hosny ほか HarfBuzz 開発者 github.com/harfbuzz/harfbuzz
  • harfbuzzjs(MIT License)— harfbuzzjs project authors github.com/harfbuzz/harfbuzzjs
  • 動作確認に使ったフォント:DejaVu Sans(Bitstream Vera License)、源ノ角ゴシック / Noto CJK(SIL Open Font License)、Latin Modern、Lora
  • 逆アセンブル結果の検証:fontTools

フォントはツールに同梱していません。手元のフォントを開いて使います。

おわりに

興味がないまま始めた題材でしたが、フォントを開けたらスタックマシンが出てきて、結局いつものエミュレーターの延長で楽しめました。フォントのラスタライザは、文字を描くたびに小さな VM を動かしていて、それが何十年も、世界中の画面の裏で回り続けているわけです。

一方で、アウトラインやシェーピングといった本当に難しい部分は、20 年近く積み上げられてきた HarfBuzz を npm install 一発で借りています。自分が書いたのは、その周りの数百行だけです。

調べている途中で、HarfBuzz 8.0 以降には、フォントの中に WASM のバイナリを埋め込んで、シェーピングをフォント自身にやらせる 実験的な機能(Wasm shaper)があることも知りました。TrueType 命令とは別の、2つ目の VM です。v2 では、フォントの Wasm テーブルを検出して、中身の import / export を覗けるようにしてみたいと思います。