BEMAロゴ

エンジニアの
成長を支援する
技術メディア

【Dart/Flutter】フォントバイナリ解体新書#3:【実践】バイナリから文字「c」を復元する

リンクをコピー
リンクをコピーしました
Xでシェアするfacebookでシェアする

はじめに

こんにちは、株式会社メンバーズ Cross ApplicationOpen in new tabカンパニーの田原です。

第1部では sfnt コンテナの「外箱」の構造を、第2部では TrueType と CFF、それぞれが採用するベジェ曲線の数学的な違いと Path への変換ロジックを解説してきました。理論編はここまでです。

最終回となる本記事では、これまで解説してきた知識を武器に、実在するフォントファイルのバイナリを実際に1バイトずつ追いかけ、小文字の「c」というたった1文字のアウトラインを手作業で復元してみます。題材には、macOS に標準搭載されている Arial.ttf を使い、ターミナルの xxd コマンドだけを頼りに読み進めます。

本記事は Arial.ttf というファイルが内部でどのようなバイト列を持っているかを解析・解説する教育目的の記事です。フォントデータそのものを複製・再配布するものではなく、あくまで自分の手元にあるファイルのバイト値を読み解くプロセスを追体験していただくことが目的です。

本記事のゴール

  1. xxd で実際のフォントバイナリをダンプし、Table Directory から cmap / loca / glyf の3テーブルの所在を特定する

  2. cmap Format 4 のセグメント構造を手計算で辿り、文字コード「c」(U+0063) をグリフID(70番)に変換する

  3. loca テーブルを経由して、グリフ70番の実データが glyf テーブルのどこにあるかを特定する

  4. Flags と座標データを実際にバイトレベルで復元し、自作パッケージ(glyph_pathOpen in new tab) が生成する PathCommand 列と完全に一致することを確認する

  5. 復元した座標を Flutter 標準の Path API に落とし込み、フォントファイルなしで文字「c」を描画再現する

理論編で学んだ「フラグバイト」「デルタ座標」「暗黙の点」が、実際のバイト列の中でどう機能しているのかを最後まで見届けましょう。

1. Table Directoryの解析:3つのテーブルの住所を特定する

まずはターミナルから xxd コマンドを実行し、ファイルの先頭 512 バイトを確認します。

User@MyMac ~ % xxd -l 512 /System/Library/Fonts/Supplemental/Arial.ttf
00000000: 0001 0000 0018 0100 0004 0080 4453 4947  ............DSIG
00000010: 7232 a231 000b a844 0000 2430 4744 4546  r2.1...D..$0GDEF
00000020: 89d5 8d49 000a b704 0000 02c2 4750 4f53  ...I........GPOS
00000030: 0f13 6adf 000a d580 0000 b122 4753 5542  ..j........"GSUB
00000040: dd8f 6051 000a b9e8 0000 1b98 4a53 5446  ..`Q........JSTF
00000050: 6d2a 6906 000a b9c8 0000 001e 4c54 5348  m*i.........LTSH
00000060: e493 6b53 0000 373c 0000 0d39 4f53 2f32  ..kS..7<...9OS/2
00000070: 1032 5d73 0000 0208 0000 0060 5043 4c54  .2]s.......`PCLT
00000080: fd7b 3e43 000a b6cc 0000 0036 5644 4d58  .{>C.......6VDMX
00000090: 5092 6af5 0000 4478 0000 1194 636d 6170  P.j...Dx....cmap
000000a0: 82e3 ce2f 000b 86a4 0000 219e 6376 7420  .../......!.cvt 
000000b0: a11c d7eb 0001 a504 0000 0654 6670 676d  ...........Tfpgm
000000c0: c83d 982f 0001 9354 0000 05ee 6761 7370  .=./...T....gasp
000000d0: 0018 0009 000a b6bc 0000 0010 676c 7966  ............glyf
000000e0: 0702 2dca 0001 e030 0008 22cc 6864 6d78  ..-....0..".hdmx
000000f0: 1110 25d5 0000 560c 0001 3d48 6865 6164  ..%...V...=Hhead
00000100: d7c8 e4bb 0000 018c 0000 0036 6868 6561  ...........6hhea
00000110: 1233 15fe 0000 01c4 0000 0024 686d 7478  .3.........$hmtx
00000120: f65e fffb 0000 0268 0000 34d4 6b65 726e  .^.....h..4.kern
00000130: 3761 3936 000a 02fc 0000 1560 6c6f 6361  7a96.......`loca
00000140: 361c d720 0001 ab58 0000 34d8 6d61 7870  6.. ...X..4.maxp
00000150: 124c 05df 0000 01e8 0000 0020 6e61 6d65  .L......... name
00000160: abc6 049c 000a 185c 0000 1114 706f 7374  .......\....post
00000170: 53bb ce0b 000a 2970 0000 8d49 7072 6570  S.....)p...Iprep
00000180: 25d6 4dbf 0001 9944 0000 0bbe 0001 0000  %.M....D........

Offset Tableの読み解き

先頭12バイトは第1部で解説した Offset Table です。なお、TrueType/OpenTypeのバイナリはすべて「ビッグエンディアン(ネットワークバイトオーダー)」で格納されています。つまり、上位バイトから順にそのまま左から右へ読んで数値に変換すればよく、本記事で以降登場する16進数の変換もすべてこの前提で計算しています。

  • 0001 0000(sfVersion): 標準的な TrueType 形式であることを示す値です。

  • 0018(numTables): 16進数の 18 は10進数で 24。このファイルには24個のテーブルが存在します。

  • 0100 0004 0080(検索最適化パラメータ): 目的のテーブルをテーブルディレクトリの中から二分探索で高速に見つけ出すためのパラメータです。24以下の最大の2のべき乗は16なので、searchRange = 16 × 16 = 256(0x0100)entrySelector = log2(16) = 4(0x0004)rangeShift = 24 × 16 − 256 = 128(0x0080)。第1部で解説した計算式どおりの値が実際に書き込まれていることが確認できるでしょう。

Table Directory のタグ名は仕様上アルファベット順(ASCIIコード順)に並んでいるため、大文字のタグ(DSIG GDEF GPOS…)が先に、小文字のタグ(cmap cvt fpgm…)が後に来ています。

必要な3テーブルの所在

小文字「c」を描画するために必要な cmap / loca / glyf の3テーブルを、このダンプから特定します。1エントリーは16バイト固定で、タグ(4バイト) チェックサム(4バイト) オフセット(4バイト) 長さ(4バイト) の順に並んでいます。

テーブルタグ

役割

オフセット

(16進数)

ファイル位

(10進数バイト)

データ長

(Bytes)

cmap

文字コード → グリフID 変換辞書

0x000B86A4

755,364

8,606

loca

グリフデータの所在地リスト

0x0001AB58

109,400

13,528

glyf

輪郭(アウトライン)実データ

0x0001E030

122,928

533,196

これで、目的地への「地図」が揃いました。ここから先は、この3テーブルを順番に辿っていきます。

2. cmapテーブルの解析:文字コードをグリフIDに変換する

小文字「c」の Unicode 番号は 0x63(10進数で99)です。この Unicode 番号 0x63 を、フォント内部の整理番号である「グリフID」に変換します。

先ほど特定した cmap テーブルの開始位置(755,364バイト目)をダンプします。

User@MyMac ~ % xxd -s 755364 -l 64 /System/Library/Fonts/Supplemental/Arial.ttf
000b86a4: 0000 0003 0000 0003 0000 001c 0001 0000  ................
000b86b4: 0000 105a 0003 0001 0000 1160 0004 103e  ...Z.......`...>
000b86c4: 0000 0134 0100 0007 0034 007e 01ff 0220  ...4.....4.~... 
000b86d4: 0259 02ad 0323 034f 036f 0375 037e 038a  .Y...#.O.o.u.~..

cmapヘッダとインデックステーブル

最初の4バイト 0000 0003 は、version = 0numTables = 3(3つのサブテーブルを持つ)を示しています。続く4バイト×3の「インデックステーブル」には、プラットフォームごとのサブテーブルの所在が記録されています。1エントリーは Platform ID(2バイト) Encoding ID(2バイト) Offset(4バイト) の8バイト構成です。

辞書

バイナリ

意味

辞書1

0000 0003 0000 001C

Platform 0(Unicode)/ Encoding 3 / +0x001C

辞書2

0001 0000 0000 105A

Platform 1(Macintosh)/ Encoding 0 / +0x105A

辞書3

0003 0001 0000 1160

Platform 3(Windows)/ Encoding 1(Unicode BMP)/ +0x1160

今回の検証環境は macOS ですが、選択するのは 「辞書3(Windows Unicode)」 です。

テーブルの選択基準は「実行中のOS」ではなく「Unicodeコードポイントでグリフを引けるか」で決まるためです。

  • Platform 1 (Macintosh):旧Mac OS(Mac Roman等)向けの互換用で、Unicodeで検索できません。

  • Platform 3 (Windows) / Platform 0 (Unicode):Unicodeを直接キーにできるため、現代のレンダラーが優先的に使用します。

実際、glyph_pathparseCmapTable も Platform 1 を無視し、以下の優先度で探索します。

  1. Platform 3 / Encoding 10 / Format 12

  2. Platform 0 / Format 12

  3. Platform 3 / Encoding 1 / Format 4 (← 今回はこれに該当)

  4. Platform 0 / Format 4

OSを問わずこの優先ルールに従うため、macOS上であっても「辞書3」を採用します。

それでは、辞書3の実データが格納されている絶対オフセットを計算します。

絶対オフセット = cmap の開始位置 (0xB86A4) + 辞書3の相対オフセット (0x1160)
            = 0xB9804759,812

Format 4サブテーブルの解読

アドレス 0xB9804 をダンプすると、Windows環境で標準的な「Format 4」形式のサブテーブルが見えます。

Format 4 は、その名のとおり文字コードを「セグメント(連続するコードポイントのブロック)」単位でまとめて管理する構造になっています。Unicodeの全コードポイントについて 文字コード → グリフID の対応を1件ずつ配列で持ってしまうとファイルサイズが膨大になるため、「32番〜126番はまとめてこのグループ」のように連続する範囲をひとつのセグメントに圧縮し、各セグメントに1つの変換ルール(idDelta など)を割り当てることでデータ量を抑えているのです。

User@MyMac ~ % xxd -s 759812 -l 64 /System/Library/Fonts/Supplemental/Arial.ttf
000b9804: 0004 103e 0000 0134 0100 0007 0034 007e  ...>...4.....4.~
000b9814: 01ff 0220 0259 02ad 0323 034f 036f 0375  ... .Y...#.O.o.u
000b9824: 037e 038a 038c 03a1 03ce 03f6 040d 044f  .~.............O
000b9834: 0486 0513 05c7 05ea 05f4 0603 0615 061b  ................

先頭14バイトのヘッダを解読すると以下のとおりです。

パラメータ

バイナリ

意味

format

0004

Format 4(Segment mapping)

length

103E

全長 4,158 バイト

language

0000

言語非依存

segCountX2

0134

308(セグメント数は154個)

searchRange

0100

2 × 2^7 = 256

entrySelector

0007

log2(128) = 7

rangeShift

0034

308 − 256 = 52

ヘッダの直後から、endCode[segCount]reservedPad(2バイト) → startCode[segCount]idDelta[segCount]idRangeOffset[segCount] glyphIdArray の順に配列が並びます。

セグメントの特定とidDeltaの取得

ヘッダ直後(15バイト目)から始まる endCode 配列の先頭は 007e(10進数で126)です。小文字「c」の Unicode 番号 99 はこの範囲(〜126)に収まるため、インデックス0のセグメントに属することがわかります。

セグメント数154個から各配列のサイズは 154 × 2 = 308 バイトとわかるので、startCodeidDelta の絶対アドレスを計算できます。(308 にはそれぞれ「直前の配列自体のサイズ」という意味を明示するため、以下では「〜配列」と表記します)。

endCode の開始地点       : 0xB9804 + 14(ヘッダ)                      = 0xB9812(759,826startCode の開始地点     : 0xB9812 + 308(endCode配列) + 2(パディング)  = 0xB9948(760,136idDelta の開始地点       : 0xB9948 + 308(startCode配列)               = 0xB9A7C(760,444idRangeOffset の開始地点 : 0xB9A7C + 308(idDelta配列)                 = 0xB9BB0(760,752

それぞれのアドレスをダンプします(xxd -s には上記の10進数の値をそのまま渡します)。

User@MyMac ~ % xxd -s 759826 -l 16 /System/Library/Fonts/Supplemental/Arial.ttf
000b9812: 007e 01ff 0220 0259 02ad 0323 034f 036f  .~... .Y...#.O.o
User@MyMac ~ % xxd -s 760136 -l 16 /System/Library/Fonts/Supplemental/Arial.ttf
000b9948: 0020 00a0 0200 0221 025a 02ae 0324 0350  . .....!.Z...$.P
User@MyMac ~ % xxd -s 760444 -l 16 /System/Library/Fonts/Supplemental/Arial.ttf
000b9a7c: ffe3 0000 04ef 0000 04d2 0000 053d 0000  .............=..
User@MyMac ~ % xxd -s 760752 -l 16 /System/Library/Fonts/Supplemental/Arial.ttf
000b9bb0: 0000 0132 0000 03ee 0000 045c 0000 0544  ...2.......\...D
  • endCode[0] = 007e(10進数で126): 先ほど特定したとおりのセグメント終端です。

  • startCode[0] = 0020(10進数で32): endCode[0] = 007e(126)と合わせて、このセグメントは「32番(スペース)〜126番(~)」をカバーしていることが確定しました。

  • idDelta[0] = ffe3: idDelta は16ビット符号付き整数(2の補数表現)として解釈されるフィールドです。0xFFE3(10進数で 65507)は最上位ビットが立っているためマイナス値と判定され、ここから 65536 を引いて 65507 − 65536 = −29 と計算できます。

  • idRangeOffset[0] = 0000: 値が0のため、今回は glyphIdArray という別配列を経由する必要がなく、idDelta を直接足し合わせる単純な計算式だけで済みます(※Format 4の仕様には、文字コードとグリフIDが不規則にしか対応しない場合のために glyphIdArray 経由の間接参照も用意されていますが、本記事のセグメントでは使用されません)。なお idRangeOffset 自体は本記事の計算では使いませんが、後述の実装コードで idRangeOffset[i] として参照されるため、値の根拠としてここでダンプ結果を示しています。

グリフIDの決定

グリフID = 文字コード + idDelta
        = 99 + (−29)
        = 70

Arial フォントにおいて、小文字「c」のデザインデータはグリフID 70番 に格納されていることが確定しました。

実装コードとの答え合わせ

glyph_pathparseCmapFormat4 は、この手順を1セグメントずつではなく、全セグメント分を一括で Map<int, int> に展開します(該当箇所を抜粋)。

// lib/src/tables/cmap.dart(抜粋)
for (int i = 0; i < segCount; i++) {
  final int start = startCodes[i];
  final int end = endCodes[i];
  if (start == 0xFFFF) break;

  for (int c = start; c <= end; c++) {
    int glyphId;
    if (idRangeOffsets[i] == 0) {
      glyphId = (c + idDeltas[i]) & 0xFFFF;
    } else {
      // idRangeOffset経由でglyphIdArrayを参照する分岐(本記事のセグメントでは未使用)
      // ...
    }
    if (glyphId != 0) {
      _addCmapEntry(result, c, glyphId, base);
    }
  }
}

つまり実装上は「文字コード99が来たときにセグメントを検索する」のではなく、フォントを開いた時点で全セグメント・全コードポイント分の変換結果を先に計算し尽くし、charToGlyph[99] として引ければ済む単純な Map に変換しています。なお、壊れたフォントデータで segCount や範囲が異常に大きい場合に無限に近いループを回されないよう、実装ではあらかじめ全セグメントの合計イテレーション回数を ResourceLimits.maxCmapIterations と比較し、超過時には例外を投げてから初めて実際の展開ループに入るガードを設けています。

コード中の & 0xFFFF は、手計算では 99 + (−29) = 70 と素直な足し算で済んでいた部分の一般化です。TrueTypeの仕様上、文字コード + idDelta は「65536(modulo 65536)」で丸めることになっており、計算結果がマイナスになったり65535を超えたりするケースでも正しい16ビットのグリフIDに収まるよう、このビット演算で丸め込んでいます。

3. locaテーブルの解析:グリフ70番の「住所」を掘り当てる

グリフID: 70 の実データが glyf テーブルの何バイト目にあるかを特定するには、loca テーブルを参照します。

indexToLocFormatの確認

loca の1エントリーが2バイト(Short)か4バイト(Long)かは、head テーブルの indexToLocFormat フィールドで決まります。値を推測することに頼らず、実際に head テーブル(オフセット 0x18C)をダンプして確認しましょう。

User@MyMac ~ % xxd -s 396 -l 54 /System/Library/Fonts/Supplemental/Arial.ttf
0000018c: 0001 0000 0005 028f 92f2 8cf6 5f0f 3cf5  ............_.<.
0000019c: 081b 0800 0000 0000 a2e3 272a 0000 0000  ..........'*....
000001ac: c304 7090 faaf fd67 1000 080c 0000 0009  ..p....g........
000001bc: 0001 0001 0000                           ......

head テーブル先頭から50バイト目(indexToLocFormat フィールド)の値は 0001。つまり Long(4バイト)形式 であることが実データから確認できました。

70番目の住所を狙い撃つ

loca の開始位置は 109,400(0x0001AB58)と特定済みです。4バイト形式なので、70番目のエントリーは 109,400 + 70 × 4 = 109,680 バイト目にあります。

User@MyMac ~ % xxd -s 109680 -l 8 /System/Library/Fonts/Supplemental/Arial.ttf
0001ac70: 0000 52e8 0000 549c                      ..R...T.
  • 0000 52e8: グリフID 70 の開始位置(glyf テーブル先頭からの相対オフセット)

  • 0000 549c: グリフID 71 の開始位置(=グリフ70の終了位置)

2つの値の差分から、小文字「c」のデータサイズも計算できます。

開始位置     : 0x52E8 (21,224)
終了位置     : 0x549C (21,660)
データサイズ : 21,660 − 21,224 = 436 バイト

glyf テーブルの開始地点(122,928)と合算すれば、ファイル全体における小文字「c」の真のデータ位置が確定します。

122,928 (glyf開始) + 21,224 (相対オフセット) = 144,1520x00023318)

glyph_path の parseLoca は、この一連のオフセット値をそのまま信用せず、必ず単調非減少(offsets[i] <= offsets[i+1])になっているかを検証してから返します。

// lib/src/tables/loca.dart(抜粋)
for (int i = 0; i < offsets.length - 1; i++) {
  if (offsets[i] > offsets[i + 1]) {
    throw FontParseException(
      'loca table offsets are not monotonically non-decreasing: '
      'offsets[$i]=${offsets[i]} > offsets[${i + 1}]=${offsets[i + 1]}',
    );
  }
}

壊れたフォントで offsets[70] > offsets[71] のような逆転が起きていた場合、start > end の範囲を silent に読みに行くのではなく、ここで確実にエラーとして弾かれる仕組みです。

4. glyfテーブルの解析:グリフヘッダとFlags/Coordinatesの実物

いよいよ、文字の輪郭・座標・曲線のデータが眠るアドレス 144,152(0x00023318) の中身を覗きます。

User@MyMac ~ % xxd -s 144152 -l 64 /System/Library/Fonts/Supplemental/Arial.ttf
00023318: 0001 0050 ffe8 03ed 043e 001a 015a b102  ...P.....>...Z..
00023328: 0243 5458 4034 0e7f 0f01 0f0b 0140 0050  .CTX@4.......@.P
00023338: 0070 0003 0004 121c 0b07 181c 040b 010e  .p..............
00023348: 1507 080e 0e02 5507 0c0d 0d02 5507 0c0c  ......U.....U...

グリフヘッダの解読(先頭10バイト)

  • 0001(numberOfContours): 1。小文字の「c」は1つの連続した輪郭(コンター)で描かれています。

  • 0050 ffe8 03ed 043e(Bounding Box): xMin=80 yMin=−24 xMax=1005 yMax=1086

続く2バイトが endPtsOfContours[0]、その次の2バイトが instructionLength です。

  • 001a(EndPoints): 10進数で26。つまり「0番から26番まで、合計27個の点」でこの「c」が描かれています。

  • 015a(Instruction Length): 10進数で346。この後に346バイト分のヒンティング命令が続きます(本記事の輪郭復元には不要なため読み飛ばします)。

Flagsの実物:27点分のバイト列

ヘッダ(10バイト)+ endPtsOfContours(2バイト)+ instructionLength フィールド(2バイト)+ 命令データ(346バイト)を読み飛ばした先、ファイル先頭から 144,512(0x00023480) バイト目から、いよいよ27点分の Flags が始まります。

User@MyMac ~ % xxd -s 144512 -l 32 /System/Library/Fonts/Supplemental/Arial.ttf
00023480: 0117 0606 2322 0011 3412 3633 3216 1707  ................
00023490: 2626 2322 0615 1416 3332 3603 3cb1 1def  &&#"....326.<...

先頭から1バイトずつ、0x01 0x17 0x06 0x06 0x23 0x22 0x00 0x11 0x34 0x12 0x36 0x33 0x32 0x16 0x17 0x07 0x26 0x26 0x23 0x22 0x06 0x15 0x14 0x16 0x33 0x32 0x36 の27バイトを取り出すと、いずれも Bit 3(Repeat)が立っていないため、そのまま27点分のフラグとして使えます(繰り返し展開は不要)。この直後の 0x03 から X座標データが始まります。

各バイトを第2部で解説したビット定義(Bit 0: On-Curve、Bit 1: X-Short、Bit 2: Y-Short、Bit 4: X-Same/符号、Bit 5: Y-Same/符号)に従って読み解き、直前の点からの差分を積算していくと、以下の27点の絶対座標が得られます。

#

Flag

On/Off

X

Y

0

0x01

On

828

389

1

0x17

On

1005

366

2

0x06

Off

976

183

3

0x06

Off

737

−24

4

0x23

On

563

−24

5

0x22

Off

345

−24

6

0x00

Off

80

261

7

0x11

On

80

527

8

0x34

Off

80

699

9

0x12

Off

194

957

10

0x36

Off

427

1086

11

0x33

On

564

1086

12

0x32

Off

737

1086

13

0x16

Off

957

911

14

0x17

On

988

750

15

0x07

On

813

723

16

0x26

Off

788

830

17

0x26

Off

661

938

18

0x23

On

571

938

19

0x22

Off

435

938

20

0x06

Off

265

743

21

0x15

On

265

532

22

0x14

Off

265

318

23

0x16

Off

429

124

24

0x33

On

561

124

25

0x32

Off

667

124

26

0x36

Off

809

254

X座標は 80〜1005、Y座標は −24〜1086 の範囲に収まっており、先ほどグリフヘッダで読み取った Bounding Box(xMin=80 yMin=−24 xMax=1005 yMax=1086)とぴったり一致します。デルタの積算が正しく行えていることの、何よりの裏付けです。

5. 暗黙の点を経て、Pathへ:実データでの組み立て

上の表を見ると、Off-Curve(制御点)が2つ以上連続している箇所が複数あります。

  • 2番・3番(Off→Off)

  • 5番・6番(Off→Off)

  • 8番・9番・10番(Off→Off→Off の3連続)

  • 12番・13番(Off→Off)

  • 16番・17番(Off→Off)

  • 19番・20番(Off→Off)

  • 22番・23番(Off→Off)

  • 25番・26番(Off→Off)

第2部では「制御点が2つ連続した場合」を例に暗黙の点のルールを説明しましたが、このルールは連続する制御点のペアすべてに繰り返し適用されます。実際にこの「c」には、8番・9番・10番という3つの制御点が連続する箇所が存在し、その間に2つの暗黙の点が生成されます。

実データ上は7・8・9・10・11の5点しか記録されていませんが、8と9の中点M1、9と10の中点M2という2つの暗黙のOn-Curve点が補われることで、quadTo(8→M1) → quadTo(9→M2) → quadTo(10→11) という3本の二次ベジェ曲線として解釈されます。glyph_path の実装では、この判定を連続するペアごとに1回ずつ愚直に繰り返すだけで、3連続・4連続といった任意の長さの制御点の連なりに対応できています。

// lib/src/font_parser.dart(抜粋・_expandImplicitOnCurve)
for (int i = 0; i < contour.length; i++) {
  final GlyphPoint curr = contour[i];
  final GlyphPoint next = contour[(i + 1) % contour.length];
  result.add(curr);
  if (!curr.onCurve && !next.onCurve) {
    result.add(
      GlyphPoint((curr.x + next.x) / 2, (curr.y + next.y) / 2, true),
    );
  }
}

最終確認:復元されたPathCommand列

27点すべてに同様の展開を適用し、On-Curve点は lineTo、On-Curve点への遷移を挟む制御点は quadTo として組み立てると、以下の21個のPathCommandが得られます。これは、実際に glyph_path を使って Font.parse した Arial.ttf から generateRawGlyphPaths('c', normalizeToCubic: false) を呼び出した結果と、1つの数値も違わず完全に一致します。

MoveTo(828.0, 389.0)
LineTo(1005.0, 366.0)
QuadTo(976.0, 183.0, 856.5, 79.5)
QuadTo(737.0, -24.0, 563.0, -24.0)
QuadTo(345.0, -24.0, 212.5, 118.5)
QuadTo(80.0, 261.0, 80.0, 527.0)
QuadTo(80.0, 699.0, 137.0, 828.0)
QuadTo(194.0, 957.0, 310.5, 1021.5)
QuadTo(427.0, 1086.0, 564.0, 1086.0)
QuadTo(737.0, 1086.0, 847.0, 998.5)
QuadTo(957.0, 911.0, 988.0, 750.0)
LineTo(813.0, 723.0)
QuadTo(788.0, 830.0, 724.5, 884.0)
QuadTo(661.0, 938.0, 571.0, 938.0)
QuadTo(435.0, 938.0, 350.0, 840.5)
QuadTo(265.0, 743.0, 265.0, 532.0)
QuadTo(265.0, 318.0, 347.0, 221.0)
QuadTo(429.0, 124.0, 561.0, 124.0)
QuadTo(667.0, 124.0, 738.0, 189.0)
QuadTo(809.0, 254.0, 828.0, 389.0)
ClosePath()

始点 MoveTo(828, 389) から出発し、17回の quadTo と2回の lineTo で輪郭をなぞり、最後は QuadTo(809, 254, 828, 389) でぴったり始点に戻って ClosePath する——これで、フォントファイルの奥深くに眠っていた436バイトのバイナリが、1本の閉じた輪郭線として完全に姿を現しました。

実際に描画してみる

数値の羅列だけでは実感が湧きにくいので、実際に描画してみます。以下は結果の画像です。

見慣れた Arial の「c」の形そのものです。さらに、27個の実データ点(青丸=On-Curve、灰色四角=Off-Curve)と、そこから自動生成された9個の暗黙の点(黄色破線丸)を重ねて描画すると、手作業で追ってきた座標が輪郭のどこに対応しているのかが一目でわかります。

灰色の四角(Off-Curve制御点)はどれも輪郭の外側に位置し、曲線を外へ引っ張っていること、そして黄色の点(暗黙の点)は3連続・2連続の制御点の間できちんと輪郭上に乗っていることが視覚的に確認できます。436バイトのバイナリから、フラグとデルタ座標を1つずつ積み上げてきた結果が、この滑らかな「c」の曲線1本に集約されているわけです。

自分の手元でも再現してみる

画像だけでなく、実際に手を動かして確認したい方のために、ここまで復元した21個のPathCommandを、そのままFlutterの Path オブジェクトに置き換えたコードを用意しました。moveTo lineTo quadraticBezierTo close は、それぞれ本記事で組み立ててきた MoveTo LineTo QuadTo ClosePath にそのまま対応する Flutter 標準の Path API です。

DartPadOpen in new tab を開き、下記コードをまるごとコピーして貼り付け、実行ボタン(▶)を押すだけで、フォントファイルを一切読み込まずに、この記事で手計算した座標だけから同じ「c」の輪郭が描画されます。

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(body: Center(child: SizedBox(
        width: 300,
        height: 300,
        child: CustomPaint(painter: GlyphCPainter()),
      ))),
    ),
  );
}

/// 本記事で手計算した21個のPathCommandを、そのままFlutterのPath APIに置き換えたもの。
class GlyphCPainter extends CustomPainter {
  const GlyphCPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final Path path = Path()
      ..moveTo(828, 389)
      ..lineTo(1005, 366)
      ..quadraticBezierTo(976, 183, 856.5, 79.5)
      ..quadraticBezierTo(737, -24, 563, -24)
      ..quadraticBezierTo(345, -24, 212.5, 118.5)
      ..quadraticBezierTo(80, 261, 80, 527)
      ..quadraticBezierTo(80, 699, 137, 828)
      ..quadraticBezierTo(194, 957, 310.5, 1021.5)
      ..quadraticBezierTo(427, 1086, 564, 1086)
      ..quadraticBezierTo(737, 1086, 847, 998.5)
      ..quadraticBezierTo(957, 911, 988, 750)
      ..lineTo(813, 723)
      ..quadraticBezierTo(788, 830, 724.5, 884)
      ..quadraticBezierTo(661, 938, 571, 938)
      ..quadraticBezierTo(435, 938, 350, 840.5)
      ..quadraticBezierTo(265, 743, 265, 532)
      ..quadraticBezierTo(265, 318, 347, 221)
      ..quadraticBezierTo(429, 124, 561, 124)
      ..quadraticBezierTo(667, 124, 738, 189)
      ..quadraticBezierTo(809, 254, 828, 389)
      ..close();

    // フォントの座標はY軸が上向きだが、Flutterの Canvas はY軸が下向きなので、
    // translate + scale(sy に負の値)でY軸を反転させてから描画する。
    canvas.save();
    canvas.translate(30, 265);
    canvas.scale(0.22, -0.22);
    canvas.drawPath(path, Paint()..color = const Color(0xFF212529));
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

ポイントは、このコードが Arial.ttf を一切読み込んでいないという点です。ここまでの節で xxd を使って1バイトずつ手作業で導き出した27個の座標を、そのまま Path に書き写しただけで、誰の環境でも(フォントファイルなしで)同じ「c」の形が再現できます。実際に glyph_path パッケージを使う場合は、この Path オブジェクトの組み立てを Font.parse(fontBytes).generateGlyphPaths('c', fontSize: 24.0) の呼び出し1つが自動で行ってくれる、というわけです。

normalizeToCubic: true(デフォルト)を指定すれば、第2部で解説した2/3補間の公式により、この17個の quadTo はすべて幾何学的に等価な cubicTo へと変換されます。TrueTypeの二次ベジェ曲線とCFFの三次ベジェ曲線が、最終的に同じ土俵で扱えるようになる仕組みを、実データで裏付けられたことになります。

最後に

全3回にわたって、フォントファイルという1つのバイナリの塊を、外側から内側へと解体してきました。

  • 第1部Open in new tabでは、sfnt コンテナという「外箱」の構造と、目的のテーブルへ安全にたどり着くための検証ロジックを解説しました。

  • 第2部Open in new tabでは、TrueTypeとCFFという全く異なる2つの圧縮・実行戦略と、それらを共通のPathCommandへ統合する設計思想を解説しました。

  • そして本記事では、その理論を実際のバイナリに当てはめ、cmaplocaglyf という3つのテーブルを実際に手で辿り、たった1文字の「c」を復元しました。

「文字を表示する」という、普段は意識すらしない当たり前の動作の裏側には、これだけ緻密なバイナリ設計と、それを安全に読み解くための検証ロジックが積み重なっています。今回開発した Dart 用パッケージ glyph_pathOpen in new tab は、これらすべてを隠蔽し、Flutter や SVG からシンプルなAPIで利用できるように設計しています。本記事で追いかけた復元プロセスの全体、そしてリガチャやカーニング、SVG/PDF出力といった今回触れられなかった機能についても、ぜひソースコードを覗いてみてください。

ここまでお付き合いいただき、ありがとうございました。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピー
リンクをコピーしました
Xでシェアするfacebookでシェアする

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
詳しく見る
ページトップへ戻る