【Dart/Flutter】フォントバイナリ解体新書#2:二次・三次ベジェ曲線とPath変換
はじめに
こんにちは、株式会社メンバーズ Cross Applicationカンパニーの田原です。
この記事は、フォントの内部処理やバイナリロジックを解剖する連載の第二部です。
連載第1部では、フォントファイルの「外箱」にあたる sfnt コンテナの構造を解剖し、目的のデータがどのテーブルに格納されているかを特定する方法を解説しました。
続く第2部となる本記事では、特定したテーブルの中に眠るバイナリデータから、文字の「輪郭線(アウトラインパス)」を抽出し、画面上に描画可能な形へと変換するまでのプロセスに迫ります。
「文字を描く」と一口に言っても、TrueType 形式と OpenType(CFF)形式では、内部で採用されている数学的アルゴリズムが大きく異なります。今回はそれぞれの座標計算の仕組み——二次ベジェ曲線と三次ベジェ曲線の違い——から、それらを Flutter や SVG で扱える共通の Path コマンドへと変換していく技術的な裏側を解説します。
記事の元となった開発パッケージはこちら→ glyph_path
本記事のゴール
TrueType と OpenType が採用するベジェ曲線の違いを理解する
フォント特有の「座標の持ち方」を知る
抽出したデータを Flutter の Path オブジェクトに変換する設計思想を学ぶ
ここでの幾何学的な理解は、第3部で扱う具体的なバイナリデータの「解読」を追ううえで欠かせません。それでは、フォントが描く曲線の仕組みを見ていきましょう。
1.2つの異なる描画アルゴリズム(TrueType vs CFF)
フォントから輪郭を取り出す際、形式によって「曲線の種類」と「データの保持方法」が異なります。
形式 | データ格納テーブル | 曲線タイプ | 生成されるコマンド |
|---|---|---|---|
TrueType | glyf | 二次ベジェ曲線 (Quadratic) | quadTo |
OpenType (CFF) | CFF | 三次ベジェ曲線 (Cubic) | cubicTo |
TrueTypeは制御点が1つの「二次ベジェ曲線」を、OpenTypeは制御点が2つの「三次ベジェ曲線」を採用しています。これらを最終的に、プログラムで扱いやすい共通のコマンド(moveTo, lineTo, quadTo, cubicTo)へ変換していくのがパーサーの役割です。
なお、quadTo・cubicToという名前自体はTrueType/CFF仕様が定義する用語ではありません。FlutterのPath APIに倣って、glyph_pathが両フォーマットの出力を統一的に扱うために採用している共通コマンド名です。
2.TrueTypeの解析:徹底的なデータ圧縮と「暗黙の点」
TrueType(glyf テーブル)が数MBのファイルに数千文字ものアウトラインを収められている理由の1つが、各点に付随する「1バイトの指示書(フラグバイト)」です。
フラグバイト(Flag Byte)の解読
TrueTypeの各点は、座標値の前に1バイト(8ビット)のフラグを持っています。この1バイトが「次に続くバイナリデータをどう読み解くか」を決定します。
ビット | 名称 | 1(オン)時の意味 | 0(オフ)時の意味 |
|---|---|---|---|
Bit 0 | On-Curve | 輪郭上の点(必ず通る) | 制御点(ベジェ曲線の重り) |
Bit 1 | X-Short | Xデータは1バイト(節約) | Xデータは2バイト(通常) |
Bit 2 | Y-Short | Yデータは1バイト(節約) | Yデータは2バイト(通常) |
Bit 3 | Repeat | 次の1バイト分、このフラグを繰り返す | 繰り返しなし |
Bit 4 | X-Dual | X-Shortの値に応じて「符号(正)」または「前回と同じ」を表す(詳細は表の下) | X-Shortの値に応じて「符号(負)」または「新規データあり」を表す(詳細は表の下) |
Bit 5 | Y-Dual | Y-Shortの値に応じて「符号(正)」または「前回と同じ」を表す(詳細は表の下) | Y-Shortの値に応じて「符号(負)」または「新規データあり」を表す(詳細は表の下) |
Bit 6 | Overlap | 輪郭が重なっている可能性あり | 指定なし |
Bit 4とBit 5は、Bit 1やBit 2の状態によって役割が切り替わる特殊なスイッチです。
Short(1バイト)時: 「符号」として機能(1=正 / 0=負)。
Long(2バイト)時: 「差分の有無」として機能(1=差分0のためデータ省略 / 0=新規データあり)。
なお、Bit 6(Overlap Simple)は「輪郭同士が重なっている可能性がある」ことを最初の点にだけ立てるヒント用のフラグで、レンダラ側の描画最適化に使われるものです。glyph_pathはこのフラグを輪郭復元には使わないため、実装上は読み飛ばしています。
データ圧縮の4つのパターン
TrueTypeのバイナリデータは、点ごとに [フラグ][X][Y] を交互に並べるのではなく、「全点のフラグ」→「全点のX座標」→「全点のY座標」 と、種類ごとにまとめて記録されるという特殊な構造を持っています。
座標はすべて「直前の点からの差分(デルタ)」で記録されます。現在の座標が (100, 100) の場合を例に、圧縮効率を見てみましょう。
① 垂直・水平移動(片方のデータ消去)
垂直に大きく移動し、X座標は変化しない場合(X差分0、Y差分 -300)。
16進数: 0x11
2進数: 0001 0001
スイッチの状態:
Bit 1 (X-Short) = 0 : 「Xは2バイトで読む」
Bit 4 (X-Same) = 1 : 「Xは前回と同じ(差分 0)」→ Xのデータ不要
Bit 2 (Y-Short) = 0 : 「Yは2バイトで読む」
Bit 5 (Y-Same) = 0 : 「Yは新データあり」
バイナリ: [0x11] [0xFED4](-300)
結果: 3バイト
X座標のデータが省略されるため、フラグ(1バイト)とY座標(2バイト)の合計3バイトで済みます。水平方向にのみ移動する場合も考え方は同じで、Bit 1/Bit 4(X側)とBit 2/Bit 5(Y側)の役割がそのまま入れ替わり、今度はY座標の2バイトを省略できます。
② 微小な斜め移動(1バイト化と符号の集約)
移動量が小さく、データが1バイト(0〜255)に収まる場合。
16進数: 0x17
2進数: 0001 0111
スイッチの状態:
Bit 1 (X-Short) = 1 : 「Xは1バイトで読む」
Bit 4 (X-Pos) = 1 : 「Xは 正(プラス)」
Bit 2 (Y-Short) = 1 : 「Yは1バイトで読む」
Bit 5 (Y-Pos) = 0 : 「Yは 負(マイナス)」
バイナリ: [0x17] [0x0A] [0x0A](10 と -10)
結果: 3バイト
微小な移動では、Bit 4/5を正負の符号として使い回すことで、バイナリサイズを大幅に節約します。
③ 重複する点(データの完全省略)
座標を全く変えない(点が重なっている)とき。
16進数: 0x31
2進数: 0011 0001
スイッチの状態:
Bit 4 (X-Same) = 1 : 「Xは前回と同じ」
Bit 5 (Y-Same) = 1 : 「Yは前回と同じ」
バイナリ: [0x31]
結果: わずか1バイト
重複する点はフラグのみの1バイトで表現でき、座標データを完全に省略できます。
④ フラグの連続(フラグ自体の圧縮)
ここまでは座標データを省略する仕組みでしたが、一番最初のブロックにまとめられている「フラグ(指示書)」自体のデータ量も圧縮するのが Bit 3 (Repeat) です。まったく同じフラグを持つ点が連続する場合に威力を発揮します。
例えば、6個の点がすべて同じフラグ 0x01(On-Curveで、X/Yは通常通り2バイトで読む)を持つ場合を考えます。
16進数: 0x09 (0x01 に Bit 3 を立てた値)
2進数: 0000 1001
スイッチの状態:
Bit 0 (On-Curve) = 1 : 「輪郭上の点」
Bit 3 (Repeat) = 1 : 「次に続く1バイトの回数分、このフラグを繰り返す」
バイナリ: [0x09] [0x05] (フラグ 0x09 + 残り5回繰り返すという指示)
結果: 2バイト
通常であればフラグのブロックだけで6バイト消費するところが、たった2バイトに圧縮されます。
ここで重要なのは、プログラムの「解凍」プロセスです。プログラムは [0x09] [0x05] を読み取ると、圧縮用の合図である Bit 3 をオフに戻し、メモリ上に [0x01] [0x01] [0x01] [0x01] [0x01] [0x01] と6個の純粋な指示書を復元します。 全点分のフラグを解凍し終えた後で、完成した指示書を見ながら、後ろに続くX座標ブロックとY座標ブロックを順番に読み解いていくのです。
暗黙の点(Implicit On-Curve Point)
TrueTypeの仕様で最も特徴的なものが、「連続する2つの制御点(Off-Curve)の中間には、輪郭上の点(On-Curve)が暗黙的に存在する」というルールです。
1.基本的なルール
まず、用語を整理しましょう。
On-Curve(オンカーブ点): 輪郭が必ず通る点。
Off-Curve(オフカーブ点): 曲線を制御するための点(制御点)。
通常の二次ベジェ曲線は 始点(On) — 制御点(Off) — 終点(On) の3点で構成されます。
2. 「暗黙の点」が発生するパターン
TrueTypeでは、データ削減のために「Off-Curve(制御点)を2つ連続させる」ことが許容されています。このとき、制御点と制御点のちょうど真ん中に、仮想的な「On-Curve(輪郭上の点)」が存在するものとして扱います。
具体例:
以下のような4つのデータが並んでいた場合を考えます。
点A (On-Curve): (0, 0)
点B (Off-Curve): (10, 10)
点C (Off-Curve): (20, 0)
点D (On-Curve): (30, -10)
このとき、点Bと点Cが連続する「Off-Curve」であるため、プログラムはその中点である 点M (15, 5) を暗黙の「On-Curve」として自動生成します。
描画プロセス:
データ上の4点から、プログラムは以下の2つの曲線(quadTo)を生成します。
点Aから点Mへ、点Bを制御点として描画
点Mから点Dへ、点Cを制御点として描画
本来、2つの曲線を描くには「A-B-M-C-D」の5点が必要です。しかし、この暗黙のルールのおかげで、ファイル上には4点(A, B, C, D)を記録するだけで済みます。結果として、ファイルサイズの節約に大きく貢献しているのです。
3. 実装コードのイメージ
glyph_path パッケージでは、この「点と点の関係性」をループで判定しながら、必要に応じて中間点を生成しています。実際の実装を簡略化すると以下のようになります。
// glyph_path の実装を簡略化したもの
if (!curr.onCurve && !next.onCurve) {
// 制御点が連続した場合、暗黙の中間点(on-curve)を生成する
result.add(GlyphPoint(
(curr.x + next.x) / 2,
(curr.y + next.y) / 2,
true, // これは輪郭上の点として扱う
));
}この仕組みにより、TrueTypeは少ないデータ量で滑らかな曲線を表現できます。
コンポジットグリフ:輪郭データそのものを再利用する仕組み
ここまで見てきた「フラグ→X座標→Y座標」という構造は、単独の輪郭を持つ「シンプルグリフ(Simple Glyph)」の話です。TrueTypeにはもう一つ、「コンポジットグリフ(Composite Glyph)」という、輪郭データそのものを使い回すための仕組みが用意されています。
例えば「Ä」のようなダイアクリティカルマーク付き文字を考えてみましょう。「A」と「¨」(ウムラウト)をそれぞれ独立したグリフとして持つフォントであれば、「Ä」グリフに新しい輪郭データを一切持たせず、「Aを原点に、¨を(dx, dy)だけずらして重ねる」という参照情報だけを記録できます。
glyfテーブルのグリフヘッダには numberOfContours というフィールドがあり、この値が負数の場合、そのグリフはシンプルグリフではなく、コンポジットグリフとして解釈されるというルールになっています。この判定を行っているglyph_pathの実際のコードが以下です(抜粋)。
GlyphData parseGlyph(BinaryReader reader) {
final int numberOfContours = reader.readInt16();
// ...xMin/yMin/xMax/yMaxの読み込み(略)
if (numberOfContours < 0) {
return _parseCompositeGlyph(reader);
}
return _parseSimpleGlyph(reader, numberOfContours);
}コンポジットグリフのデータは、「参照先のグリフID」「移動量 (dx, dy)」、そして必要に応じて「2×2のアフィン行列(拡大・縮小・回転)」の組み合わせを1つ以上並べたリストです。この変換式はOpenType仕様で定められた標準のアフィン変換であり、glyph_pathも参照先グリフの輪郭を取得したあとに、仕様通り以下の式をすべての座標へ適用しています。
x' = a・x + c・y + dx
y' = b・x + d・y + dyこの仕組みにより、フォント制作者は同じ輪郭データ(例えば「A」の形)を書き直すことなく、様々なアクセント付き文字を安価に定義できます。
ただし、この参照構造には実装上の注意点があります。破損したフォントや不正なデータでは、グリフAがグリフBを参照し、さらにグリフBがグリフAを参照し返す「循環参照」が発生するおそれがあります。検知せずに再帰処理を行うと、無限ループを引き起こしアプリがフリーズします。glyph_pathの実装では、現在展開中のグリフID集合を追跡し、同じグリフの取得が再入した時点で例外を投げることでこの循環を検出しています。また、ネストの深さ自体にも上限を設け、想定外に深い参照チェーンを早期に打ち切るようにしています。
3.OpenType (CFF) の解析:フォントの中に潜む「プログラム」
TrueTypeが「フラグによるパズル」だとしたら、OpenType(CFF テーブル)の輪郭データは「小さなプログラム」です。
そもそもCFF方式のアウトラインは、Adobe社が開発した「PostScript」というページ記述(プリンター向け)言語の技術をベースにしています。プログラミング言語の設計を受け継いでいるため、CFFのデータ(Type 2 CharStrings)は単なる座標の羅列ではなく、「スタックマシン」という仮想マシン上で実行される命令セットとして設計されています。
バイトコードの解読ルール(命令と数値の境界)
プログラムがCFFのバイナリをパース(解析)する際、「これが命令なのか、数値なのか」を判定するための厳密なルールがあります。それは「最初の1バイト目の値(0〜255)」によって完全に決定されます。
1バイト目の値 (10進数) | 解釈 | 実際の数値の求め方(パースの仕様) |
|---|---|---|
0〜31 | 命令(オペレータ) | 命令を実行する。(※値が 28 の場合のみ例外で、続く2バイトを読む「16ビット数値」の合図) |
32〜246 | 1バイトの数値 | バイト値 − 139(この計算により、−107〜+107のよく使う微小な移動量を1バイトで表現できます) |
247〜250 | 2バイトの正の数値 | (バイト値 − 247) × 256 + 次の1バイト + 108(108〜1131の表現) |
251〜254 | 2バイトの負の数値 | −((バイト値 − 251) × 256 + 次の1バイト + 108)(−1131〜−108の表現) |
255 | 5バイトの数値 | 続く4バイトを16.16形式の固定小数点数として読む |
また、処理には「引数(数値)」を先に用意して、後から「命令(オペレータ)」を実行する逆ポーランド記法が採用されています。
例えば、「現在の位置から、三次ベジェ曲線を描きながら移動する」という命令を仮想マシンに実行させる場合を見てみましょう。
描画したい内容:
10 20 30 40 50 60 rrcurveto(相対座標で曲線を描く)CFFのバイナリ上の記録(16進数):
[0x95] [0x9F] [0xA9] [0xB3] [0xBD] [0xC7] [0x08]
プログラム(仮想マシン)は、このデータ列を左から順に読んでいき、以下のように処理します。
1.数値をスタックに積む(Push)
マシンは最初の 0x95(10進数で149)を読み込みます。これは「32〜246」の範囲なので1バイトの数値です。 計算式 149 − 139 = 10 により、本来の数値が「10」であると復元し、「スタック」と呼ばれる作業机(配列)に積みます。同じように 0xC7(199)までをパースし、6つの数値を復元します。
スタックの状態: [10, 20, 30, 40, 50, 60]
2.命令を実行する(Pop)
次に 0x08(10進数で8)が出現します。これは「0〜31」の範囲なので命令です。0x08 は rrcurveto(曲線描画)の命令IDであるため、マシンは「これには6つの数値が必要だ」と判断します。
3. 描画の実行
スタックに積まれていた6つの数値を一気に消費します。rrcurveto は三次ベジェ曲線を描く命令なので、マシンはこれら6つの数値を前から順に「X, Y」のペア(3つの座標の塊)として解釈します。
10, 20 → 制御点1 (直前の位置からの相対移動量)
30, 40 → 制御点2 (制御点1からの相対移動量)
50, 60 → 終点 (制御点2からの相対移動量)
この座標の塊をもとに、内部で cubicTo コマンドを生成して実際の曲線を描画します。
スタックの状態: [] (空になる)
このように、「ルールに従って数値を積む」→「命令で一気に消費して座標ペアに割り当てる」を繰り返すことで、文字の輪郭を描いていきます。
サブルーチンによる圧縮
さらにCFFを面白くしているのが、プログラミング言語のような「サブルーチン(関数の呼び出し)」機能です。ここで、前項で登場した「相対座標(直前の点からの移動量)」という仕様が活きてきます。
明朝体のようなフォントを想像してみてください。文字の端々には「セリフ(うろこ)」と呼ばれる同じような飾りの曲線が何度も登場します。TrueTypeでは、同じ形であっても描画する場所(絶対的な座標)が違えば、毎回異なる数値を記録しなければなりません。
しかしCFFの命令は、すべて「今いる場所からどれくらい移動するか」という相対座標で記述されています。そのため、「頻出するセリフを描くための相対的な移動命令群」を一度だけ別領域に定義しておけば、文字のどの位置に到達したときでも、それを呼び出すだけで全く同じ飾りをスタンプのように描けるのです。
実行したい内容:
107 callsubr(107番のローカルサブルーチンを呼び出す)CFFのバイナリ上の記録(16進数):
[0xF6] [0x0A]
内訳:
0xF6: 数値の107(32〜246の範囲なので 246 − 139 = 107)
0x0A: callsubr(ローカルサブルーチン呼び出しの命令ID。10進数で10)
ただし、ここでスタックに積まれた「107」は、そのままサブルーチン配列の添字になるわけではありません。Type 2 CharStringsの仕様では、実際に呼び出すサブルーチンの添字は、スタックの値に「バイアス値」を加算して求めます。バイアス値はフォントが持つサブルーチンの総数によって以下のように変化します。この計算式自体は、Type 2 CharStrings仕様が定めているものです。glyph_pathの実装は次の通りです。
int _subrBias(int count) {
if (count < 1240) return 107;
if (count < 33900) return 1131;
return 32768;
}1バイトの数値がちょうど「32〜246」=「−107〜+107」の範囲しか表現できないことを思い出してください。バイアス値107は、この1バイトで表現できる符号付きの範囲の中央(サブルーチン配列の0番目)を指し示せるように選ばれた値です。
例えばこのフォントのローカルサブルーチン数が1240未満であれば、バイアス値は107。つまりスタック上の「107」に107を足した「214番」のサブルーチンが実際に呼び出されることになります(グローバルサブルーチンを呼び出す callgsubr 命令でも、対象がグローバルサブルーチンの総数に変わるだけで、同じバイアス計算が使われます)。一見すると複雑な仕組みに見えますが、メリットは大きいです。頻出するサブルーチン呼び出しを常に1バイトの数値で表現できるため、バイナリサイズを極限まで削れます。
文字の輪郭上の特定の場所に到達したとき、このわずか2バイトのバイナリを置くだけで、仮想マシンは「バイアス込みで214番の関数(セリフを描く命令群)を実行しろ」と解釈し、現在の位置から複雑な輪郭を自動的に描き足してくれます。
フォントファイルの中に、関数呼び出しを行いながら輪郭を描画する「小さなコンピュータ」が内包されていると考えると、CFFの設計の巧妙さが見えてきます。
データ圧縮への2つの異なるアプローチのまとめ(TrueType vs CFF)
ここまで見てきたように、TrueType方式とCFF方式は、どちらも「何千もの精緻な文字データを、いかに小さなファイルサイズに収めるか」という同じ目的に対して、全く異なるアプローチをとっています。
アプローチ | フォーマット(テーブル) | 圧縮のコア技術 | 特徴的な仕様 |
|---|---|---|---|
データ省略型 | TrueType (glyf) | フラグ(指示書)の高度な使い回し、輪郭データそのものの再利用(コンポジットグリフ) | 暗黙の点(オンカーブの自動生成) |
プログラム実行型 | CFF(テーブルタグは末尾に半角スペースを含む CFF ) | サブルーチン(相対座標による共通図形の関数化) | スタックマシンと逆ポーランド記法、相対座標(デルタ表記) |
TrueType方式は、フラグという「スイッチ」を駆使して座標の差分データを極限まで省略し、さらには「暗黙の点」というルールで点そのものの数を減らし、「コンポジットグリフ」で輪郭データそのものの重複を避ける、まるでパズルのような緻密なデータ設計です。
一方CFF方式は、数値をスタックに積み、オペレータで処理する「小さな仮想マシン」を内包しています。相対座標とサブルーチンを組み合わせることで、共通の図形(セリフなど)を関数として再利用するプログラマティックな設計です。
4.最終変換:PathCommandへの統合とリガチャ(合字)
TrueTypeの座標データ、またはCFFのスタックマシンから得られた結果は、最終的に glyph_path パッケージの共通コマンド群へと変換されます。
// 共通化されたPathコマンド群
sealed class PathCommand {}
class MoveTo extends PathCommand { ... }
class LineTo extends PathCommand { ... }
class QuadTo extends PathCommand { ... }
class CubicTo extends PathCommand { ... }
class ClosePath extends PathCommand {}
TrueTypeが生成する quadTo と、CFFが生成する cubicTo は、この共通の PathCommand として同じ土俵に乗るため、呼び出し側はフォント形式の違いをいっさい意識することなく輪郭を描画できます。
quadToとcubicToの統一:normalizeToCubic
さらに glyph_path では、この統合をもう一歩進める normalizeToCubic オプションを用意しています(generateGlyphPaths / generateRawGlyphPaths 共通で、デフォルト値は true)。有効な場合、TrueType由来の quadTo は 2/3 補間の公式を用いて幾何学的に等価な cubicTo へと変換されるため、通常の利用ではフォント形式にかかわらず出力される PathCommand は cubicTo に統一され、呼び出し側が quadTo を意識する場面はほぼありません。
これは単に型を揃えるためだけの機能ではなく、「TrueTypeフォントの文字とCFFフォントの文字を同じ次数の曲線として補間し、モーフィングアニメーションを作る」といった、フォント形式をまたいだグリフ操作を見据えた設計です。元の二次ベジェ曲線をそのまま扱いたい場合は normalizeToCubic: false を指定すれば、TrueTypeのquadToを保持できます。
リガチャ(合字)の対応
加えて glyph_path では、GSUBテーブルを解析し、リガチャ(合字)にも対応しました。例えば「f」と「i」が連続した際、それぞれを別々に描画するのではなく、フォント側に用意された「fi」が繋がった美しい1つの文字データ(リガチャグリフ)に置き換えてからパスを生成します。
なお、GSUBテーブルには文脈依存の字形置換など様々なルールを定義できますが、glyph_pathでは実務上もっとも利用頻度の高い「標準合字(liga フィーチャー)」による置換のみをサポートしています。
最後に
フォントファイルから文字のパスを抽出する処理を、TrueTypeとCFFの両方の視点から見てきました。「文字を表示する」という当たり前の動作の裏側には、TrueTypeの圧縮ロジックや、CFFのスタックマシンによる演算、字形を保つためのリガチャ処理など、思った以上に多くの工夫が詰め込まれています。
今回開発したDart用パッケージ glyph_path は、これらの処理を隠蔽し、簡単にFlutterやSVG用のパスを取得できるように設計しています。
// 使用例
final font = Font.parse(fontBytes);
// normalizeToCubic: true(デフォルト)により、TrueType/CFFどちらの曲線も
// cubicTo に統一された状態で取得できる
final result = font.generateGlyphPaths('Hello', fontSize: 24.0);カスタムフォントを用いたリッチなUI表現や、グラフィックツールなどをDart/Flutterで開発する際は、ぜひ活用してみてください!
次回予告
第3部:『【実践】TrueType(TTF)の解析:バイナリから文字「c」を復元する』
次回はいよいよ、実際のバイナリ値を1バイトずつ追いながら、本記事で解説したフラグバイトのデコードと差分座標の復元ロジックを使って、具体的な文字「c」のアウトラインをハンドトレースで再現していきます。理論編で学んだ知識が、実際のバイト列の中でどのように機能するのかを体感していただける回になる予定です。
お楽しみに!
この記事を書いた人
What is BEMA!?
Be Engineer, More Agile


