BEMAロゴ

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

【Dart/Flutter】フォントバイナリ解体新書#1:オフセットテーブルとテーブルディレクトリ解析

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

はじめに

こんにちは、株式会社メンバーズ クロスアプリケーションカンパニーOpen in new tabの田原です。
私たちが普段スマートフォンやPCで何気なく読んでいる「文字」は、すべて「フォントファイル」というデータの塊をもとに画面に描画されています。
最近、これまで難易度が高く敬遠してきたニッチなバイナリ解析という課題に対し、生成AI(Claude Code)を用いて挑みました。その成果として、Dartでフォントを解析し、FlutterやSVGで利用できるアウトラインパスを生成するパッケージ「glyph_pathOpen in new tab」を開発・公開しました。
本連載では、この開発を通じて得た、フォントの内部処理やバイナリロジックをイチから解読するための知見を、全3回にわたって共有します。

連載のロードマップ

本シリーズは、以下の3構成でフォントの深淵に迫ります。

  • #1 オフセットテーブルとディレクトリ解析(本記事)

    • フォントの「外箱」の構造を理解し、目的のデータへアクセスする仕組みを学びます。

  • #2 二次・三次ベジェ曲線とPath変換

    • フォントが描く曲線の数学的背景と、Flutterで扱えるPath形式へ変換するロジックを解説します。

  • #3 【実践】TrueType(TTF)の解析

    • 実際のバイナリ値を追いながら、フラグバイトや差分座標から文字「c」を復元するパズルに挑みます。
      シリーズ第1回となる今回は、フォントの基礎知識から、実際のバイナリデータ(16進数)をどのように読み解き、Dartでパースしていくのかを順を追って解説します。

フォントとは何か?:デザインとデータの違い

まず前提として、「書体」と「フォント」という言葉の違いを整理しておきましょう。

項目

意味

具体例・役割

書体 (Typeface)

文字のデザインそのものの様式

「明朝体」「ゴシック体」「Times New Roman」など

フォント (Font)

デザインをコンピュータで扱うためのデータ/製品

「MS 明朝.ttc」「NotoSansJP-Regular.otf」などのファイル

コンピュータが文字を描画する方法には、大きく分けて2つのアプローチがあります。

表現方法

仕組み

特徴

ビットマップ

ドット(ピクセル)の集まりで文字を表現

拡大すると輪郭がガタガタ(ジャギー)になる。計算が軽く、レトロゲーム等で活躍。

アウトライン

輪郭線を数式(座標と曲線)で記憶して表現

どれだけ拡大しても滑らか。現在のOSやアプリの主流。

現在私たちが使っているフォントのほとんどは、拡大しても美しい「アウトラインフォント」です。代表的なファイル形式には以下の2つがあります。

形式 (拡張子)

開発元

特徴

TrueType (.ttf)

Apple / Microsoft

1980年代にAppleが開発し、後にMicrosoftがWindowsの標準として採用したことで世界中に普及した形式。

OpenType (.otf)

Adobe / Microsoft

TrueTypeのコンテナ構造をベースに、両社が共同開発した次世代形式。高度な字形制御や多言語対応に強い。

「TrueType」と「OpenType」の関係

一般的には上記のように「別のフォーマット」として認識されがちですが、バイナリ解析の世界に入ると、実はこれらが同じ「箱」を共有していることに気がつきます。

  • TrueTypeの誕生: まずAppleが、テーブルディレクトリ構造を持つ sfnt という拡張性の高いコンテナ(箱)形式を開発し、その中に glyf というアウトラインデータを入れたものを「TrueType」として発表しました。

  • OpenTypeへの進化: その後、MicrosoftとAdobeが共同で、この優れた sfnt コンテナ構造をベースにした新たな規格を策定しました。TrueTypeのアウトライン(glyf)だけでなく、AdobeのPostScript技術をベースにしたアウトラインデータも CFF テーブルとして同じ箱に格納できるように拡張したのです。
    つまり、「sfnt という共通の箱をベースにして、中身が glyf(TrueType方式)でも CFF(PostScript方式)でも、さらに高度な組版データでも入れていいよ」と定めた包括的なルール(仕様)の総称が「OpenType」なのです。 物理的な箱の正体はあくまで sfnt であり、OpenTypeはその箱の「使い方(規格)」に過ぎません。

フォントファイルの内部構造(箱と引き出し)

TrueType(.ttf)もOpenType(.otf)も、内部の基本的な構造は共通しています。ファイルの構造を理解するには、「大きな箱の中に、役割ごとの引き出しがある」とイメージするとわかりやすいです。

  1. sfntコンテナ(外箱): ファイル全体を包む共通の規格です。ファイルの先頭には、箱の目録である「オフセットテーブル」があります。

  2. テーブル(引き出し): 箱の中には複数のデータ領域があり、それぞれに4文字のアルファベット(タグ名)が割り当てられています。
    代表的なテーブル(引き出し)には、以下のようなものがあります。

テーブル名

役割(中に入っているデータ)

head

フォント全体の設定書。基本となるサイズ(デザインユニット)などの情報。

maxp

フォントに含まれるグリフ(フォントにおける一文字一文字の具体的な形状データ)の総数。他の多くのテーブルを解析する際の基準値になります。

cmap

文字コード表。「あ」という文字コードを、内部の文字番号(グリフID)に変換する辞書。

glyf / CFF

文字の形(アウトライン)のデータ。点と線の座標が詰まっています。(TrueTypeはglyf、OpenTypeはCFFを使います)

hmtx

文字ごとの横幅のデータ。文字と文字の間隔をどれくらい空けるかを決めます。

テーブルのタグ名は仕様上「必ず4バイト(4文字)」である必要があります。そのため、3文字の CFF は、末尾に半角スペースを入れて「CFF 」として記録されています。

バイナリ解体新書:実際のデータを見てみよう

フォントファイルの「データ構造」と言われてもピンとこないかもしれません。ここでは、バイナリエディタでフォントファイル(.ttf)を開いたときに見える、実際の16進数データを読み解いてみましょう。

① 外箱のラベル(sfntコンテナヘッダ)

ファイルの最初の12バイトはオフセットテーブルと呼ばれ、「このファイルの種類」と「引き出しの数」が書かれています。

00 01 00 00 00 0F 00 80 00 03 00 70
  1. 00 01 00 00 (先頭4バイト): フォントの種別を表す識別子(sfVersion)です。この値は標準的なTrueTypeフォントであることを示しています。OpenType(.otf)の場合は、ここが 4F 54 54 4F(ASCII変換で "OTTO")になります。

  2. 00 0F (次の2バイト): 16進数の 0F は、10進数で「15」です。つまり、このファイルには15個のテーブル(引き出し)が入っていることがわかります。

  3. 00 80 / 00 03 / 00 70(残り6バイト): 検索最適化用パラメータ。

  • 多くのテーブルから目的のものを高速に探す(バイナリサーチする)ために、コンピュータがあらかじめ計算しておいた値です。

  • searchRange (00 80): (numTables以下の最大の2のべき乗) × 16

  • entrySelector (00 03): numTables以下の最大の2のべき乗の「指数」(例:8なら2の3乗なので「3」)

  • rangeShift (00 70): (numTables × 16) - searchRange

    実は目次(テーブルディレクトリ)のタグ名は、仕様上必ずアルファベット順に並べられています。先ほどの searchRange などの値は、このソートされた目次から、目的のタグを「二分探索(バイナリサーチ)」で高速に見つけ出すために使われる計算済みの値です(※本記事の実装ではシンプルに全件ループしてMapに格納しています)。

Dartでのバイナリ読み込みアプローチ

Dartでこのデータを扱う場合、生データは Uint8List としてメモリに読み込まれます。これを効率よく解析するために、ビッグエンディアン(上位バイトから順に配置される形式で、フォントファイルの標準仕様)で数値を読み進めるBinaryReader クラスを用意します。

class BinaryReader {
  final Uint8List _data;
  final ByteData _view;
  int position = 0; // 現在の読み込み位置(カーソル)

  BinaryReader(this._data) : _view = ByteData.sublistView(_data);

  // 8ビット(1バイト)の符号なし整数を読み込み、カーソルを進める
  int readUint8() {
    final v = _data[position];
    position += 1;
    return v;
  }

  // 16ビット(2バイト)の符号なし整数を読み込み、カーソルを進める
  int readUint16() {
    final v = _view.getUint16(position, Endian.big);
    position += 2;
    return v;
  }

  // 32ビット(4バイト)の符号なし整数を読み込み、カーソルを進める
  int readUint32() {
    final v = _view.getUint32(position, Endian.big);
    position += 4;
    return v;
  }

  // カーソルを任意の位置にジャンプさせる
  void seek(int offset) {
    position = offset;
  }
}

② 引き出しの目次(テーブルディレクトリ)

12バイトのヘッダの直後からは、「どの引き出しが、どこにあるか」という目次が15個分ズラリと並んでいます。1つ目の目次は必ず16バイト固定です。例として、cmap テーブルの目次を見てみましょう。

63 6D 61 70 12 34 56 78 00 00 01 50 00 00 04 20

これを4バイト(8文字)ずつに区切って読み解きます。

  1. 63 6D 61 70 (Tag): ASCIIコードに変換すると "cmap" になります。(63=c, 6D=m, 61=a, 70=p)

  2. 12 34 56 78 (Checksum): データが壊れていないかを確認するためのチェック用の数値です。

  3. 00 00 01 50 (Offset): 「ファイルの先頭から何バイト目にデータがあるか」。16進数の 01 50 は10進数で「336」。先頭から336バイト目に cmap の実データがあることがわかります。

  4. 00 00 04 20 (Length): そのデータのサイズ(バイト数)です。04 20は10進数で「1056」であり、データサイズが1056バイトあることがわかります。

    プログラム(フォントパーサー)は、この目次を読み込んで「cmap テーブルは 336 バイト目にあるな」と把握し、その位置まで一気にジャンプしてデータを読み出します。

4.バイト境界(アライメント)というルール

フォントバイナリのもう一つの重要な仕様として「4バイト境界(アライメント)」があります。各テーブルの実際のデータサイズ(Length)が4の倍数でない場合、後ろに 00 がパディングとして追加され、次のテーブルが必ず4の倍数の位置から始まるよう調整されています。目次に書かれた offset にジャンプすれば自動的に正しい位置に飛べるのは、この設計のおかげです。

パーサーの構築:バイナリとコードの「答え合わせ」

プログラムがバイナリをどのように解釈して構造体に変換していくのか。実際のDartコードと、先ほど確認した16進数データを隣り合わせにして、そのプロセスを紐解いてみましょう。

00 01 00 00 // sfVersion
00 0F // numTables
00 80 00 03 00 70 // 検索最適化用の値
63 6D 61 70 // Tag
12 34 56 78 // Checksum
00 00 01 50 // Offset
00 00 04 20 // Length

① オフセットテーブルのパース(最初の12バイト)

ファイルの先頭にある「外箱のラベル」を読み取る処理です。

/// ファイルの先頭12バイトを読み込み、フォントの基本情報を取得
OffsetTable parseOffsetTable(BinaryReader reader) {
  // 1. 先頭4バイト: 00 01 00 00 (sfVersion)
  final sfVersion = reader.readUint32();

  // 2. 次の2バイト: 00 0F (numTables = 15)
  final numTables = reader.readUint16();

  // 3. 残り6バイト: 検索最適化用の値(バイナリサーチで使用)
  final searchRange = reader.readUint16();
  final entrySelector = reader.readUint16();
  final rangeShift = reader.readUint16();
  return OffsetTable(
    sfVersion: sfVersion,
    numTables: numTables,
    // ...略
  );
}

バイナリとの対応

BinaryReader が以下のように順番にデータを消費していきます。

  • readUint32(): 最初の4バイト(sfVersion)を読み取る

  • readUint16(): 次の2バイト(numTables)を読み取る

  • readUint16() × 3回: 残りの6バイト(検索最適化用の値)を読み取る

    これで合計 12バイト をピッタリ読み終え、カーソルは次の「テーブルディレクトリ(目次)」の先頭にセットされます。

バリデーションの重要性

実際のプログラムでは、読み込んだ直後にデータの妥当性をチェックします。

  • sfVersionは「既知のフォント種別(箱の規格)かどうか」を検証するための値です。glyph_pathの実装では、以下の4種類の値を許可しています。

    • 0x00010000: 標準的なTrueType(および、TrueTypeアウトラインを持つOpenType)

    • 0x4F54544F(ASCIIで "OTTO"): CFFアウトラインを持つOpenType

    • 0x74727565("true")/ 0x74797031("typ1"): Appleが独自に使っていたレガシー形式

これ以外の値が返ってきた場合は、そのファイルがサポート外の形式か、あるいは壊れている可能性があるため、エラーとして処理を中断するガード節を設けます。

ここで注意したいのは、sfVersionはあくまで「箱の規格」を検証するだけだという点です。実際にTrueType(glyf)とCFFのどちらとして中身を解析するかは、次に説明するテーブルディレクトリに CFF というタグが存在するかどうかで決まります。

実は、拡張子が .otf(OpenType)であっても中身がTrueType形式のグリフデータ(glyf)を持つファイルは存在し、その場合sfVersionは0x00010000のままです。つまり「sfVersionを見ればTrueTypeかOpenTypeかが一意に分かる」という理解は正確ではありません。

また、numTablesについても「大きすぎる値ではないか」という上限チェックを設けます。ファイルに書かれた数値をそのまま信じて巨大なループを回してしまうと、壊れた(あるいは悪意を持って加工された)データによって不必要に長い処理を強いられる可能性があるためです。

② テーブルディレクトリのパース(引き出しの目次)

次に、各データ(引き出し)がどこにあるかを示す「目次」を読み取ります。

/// numTables(15回)ループを回し、各テーブルの所在をMap化する
Map<String, TableEntry> parseTableDirectory(BinaryReader reader, int numTables) {
  final result = <String, TableEntry>{};

  for (var i = 0; i < numTables; i++) {
    // 1回のループで16バイト分を読み込む
    //: 63 6D 61 70 | 12 34 56 78 | 00 00 01 50 | 00 00 04 20

    // Tag (4バイト): 63 6D 61 70 -> "cmap"
    final tag = String.fromCharCodes([
      reader.readUint8(), reader.readUint8(),
      reader.readUint8(), reader.readUint8(),
    ]);

    final checksum = reader.readUint32(); // 12 34 56 78
    final offset = reader.readUint32();   // 00 00 01 50 (336バイト目)
    final length = reader.readUint32();   // 00 00 04 20 (1056バイト分)

    result[tag] = TableEntry(tag: tag, offset: offset, length: length);
  }
  return result;
}

なお、String.fromCharCodes で1バイトずつの整数からそのまま文字列を組み立てていますが、これはフォントのタグ名(head、cmapなど)が仕様上必ずASCII文字(0x20〜0x7E)の範囲に収まると決まっているため、この方法でも問題なく文字列化できます。

ここで全てのテーブルの offset(開始位置)が判明します。以降の処理では、この値を元に reader.seek(offset) を行い、必要なデータへ一気にジャンプできるようになります。

ただし、ここで手に入れた offset と length は、あくまで「ファイルの中にそう書かれていた」という数値に過ぎません。ファイルが壊れていたり、悪意を持って加工されていたりすると、実際のファイルサイズを超える offset や length が記録されている可能性があります。それを鵜呑みにして reader.seek(offset) してしまうと、意図しない領域を読みに行ってしまう危険があります。 そのため実際の実装では、テーブルディレクトリを読み終えた直後に、全テーブルについて「offset + length がファイル全体のサイズに収まっているか」を検証してから使用します。

// offset/lengthが正しい範囲に収まっているかを検証する
void validateTableRange(String tag, int offset, int length, int bufferLength) {
  if (offset < 0 ||
      length < 0 ||
      offset > bufferLength ||
      offset + length > bufferLength) {
    throw FontParseException(
      'Table "$tag": offset $offset + length $length exceeds font buffer size $bufferLength.',
    );
  }
}

目次に書かれた住所を鵜呑みにしない、この一手間があるからこそ、外部から渡された(=信頼できるとは限らない)バイナリデータでも安全に扱えます。

③ 統合プロセス:FontParser の全体像

これらを組み合わせた、解析のメインフローがこちらです。

class FontParser {
  static FontParser parse(Uint8List fontBytes) {
    final reader = BinaryReader(fontBytes);

    // 1. 目次の解析
    final offsetTable = parseOffsetTable(reader);
    final tableDir = parseTableDirectory(reader, offsetTable.numTables);

    // 2. 必要なテーブルへ「ジャンプ」してデータを抽出
    // 例:'head'テーブルから基本サイズ情報を取得
    final headEntry = tableDir['head']!;
    reader.seek(headEntry.offset); 
    final head = parseHead(reader); 

    // 3. TrueType か OpenType(CFF) かを判定してパーサーを分岐
    if (tableDir.containsKey('CFF ')) {
      return _parseCffFont(reader, tableDir, head);
    } else {
      return _parseTrueTypeFont(reader, tableDir, head);

    }
  }
}

なお、実際の実装ではTrueType分岐に入る前に、glyf に加えて loca(グリフごとのデータ位置を示す索引テーブル)も存在するかを確認し、どちらか一方でも欠けていればサポート外の形式としてエラーにしています。glyf 単体ではアウトラインの区切り位置が分からないためです。

このように、BinaryReader が先頭から順番に「カーソルを進めながら」指定バイト数ずつデータを読み取っていくことで、ただの数値の並びだったバイナリデータが、Dartプログラム上で扱える構造体に変わっていきます。

最後に

フォントファイルは、単なる画像の集まりではありません。
その実態は、ファイルの先頭にある目次(オフセットテーブルとディレクトリ)から始まり、文字コードの翻訳辞書(cmap)、そして曲線を描くための数学的な座標データ(glyf / CFF )まで、きちんと整理された1つのデータベースです。
BinaryReaderで数バイトずつ読み解いていくと、普段何気なく見ている「文字」がどう表現されているのかが少しずつ見えてきます。 今回解説したコードの全容や、実際に動作するロジックは、公開しているDartパッケージ glyph_pathOpen in new tab で確認できます。実装の詳細に興味がある方は、ぜひソースコードも覗いてみてください。

次回予告

第2部:『二次・三次ベジェ曲線とPath変換:数式から輪郭を生成するロジック』
次回は、今回特定したテーブルの中に格納されている「座標データ」を使い、どのようにして滑らかな曲線を生成するのか。TrueType(二次ベジェ曲線)とOpenType・CFF(三次ベジェ曲線)、それぞれが採用する数学的アプローチの違いを紐解きながら、両者をFlutterやSVGで共通して扱える Path コマンド(moveTo, lineTo, quadTo, cubicTo)へと統合していく設計の裏側を詳しく解説します。

お楽しみに!

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

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

この記事を書いた人

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