📑

ヒラギノ角ゴシックの CMap を読む

2022/07/24に公開

macOSに同梱されるヒラギノ角ゴシックの内部構造を覗いたところ、CMap に Platform ID 1 / Format 2 が使用されていました。

少し丁寧な解説

フォントをレンダリングする場合、CMap と呼ばれるテーブルを利用して、Unicode 等の文字コードからフォント内部のグリフインデックス(CID / GID)を取得する必要があります。日本語フォントの CMap には、一般に Unicode に対応した下記サブテーブルが内包されており、これらをパースすることで容易に CID が得られます。

  • Fotmat 4: 基本多言語面である U+0000―FFFF に対応
  • Format 12: U+10000 以降に対応
  • Format 14: IVS 等の異体字セレクタに対応

しかしながら、ヒラギノフォントに至っては、そうは問屋が卸さないようです。現行の macOS に同梱されているヒラギノフォントでは、上記フォーマットが含まれないものが存在し、代替として CMap Format 2 という謎のフォーマットが指定されていました。
Format 2 の仕様を読みながら、解析を試みます。

Format 2 の概要

マイクロソフトが公開している OpenType® Specification [1] を参照したところ、Format 2 はマルチバイト文字への対応の中で生じた、歴史的経緯によって残されているような印象を受けました。資料には下記の記述があります。

This subtable format was created for “double-byte” encodings following the national character code standards used for Japanese, Chinese, and Korean characters. These code standards use a mixed 8-/16-bit encoding. This format is not commonly used today.

  • CJK(日本語・中国語・韓国語)で使用されている各国の文字コード規格に準拠した 2 バイトエンコーディング用のサブテーブル。
  • 8 ビットと 16 ビットが混在したエンコーディングが使用される。
  • 現在ではあまり使われていない。

フォーマット

同出典より、引き続きフォーマットを見ていきます。Format 2 のサブテーブルは、下記のデータ構造に従います。

Format 2

Type Name Description
uint16 format Format 2 のとき 2
uint16 length サブテーブルのバイト長
uint16 language 利用言語
uint16 subHeaderKeys 上位バイトと subHeaders のマップ。subHeader のインデックスを8倍した値が含まれる。
SubHeader subHeaders[] SubHeader の可変長配列
uint16 glyphIdArray[] 2 バイト文字において、下位バイトへのマッピングに用いられる可変長配列
  • subHeaderKeys は、上位バイトと SubHeader レコードのマッピングに用いる。
  • 2 バイト文字の場合、SubHeader は部分配列(後述)を介して 2 バイト目の値を対応付けるために使用される。
  • 1 バイトの文字コードの場合は subHeaders[0] を読む。1 バイトの値が部分配列を介してマッピングされる。

SubHeader

Type Name Description
uint16 firstCode SubHeader において有効な最初の下位バイト
uint16 entryCount SubHeader において有効な下位バイトの数
int16 idDelta 下記参照
uint16 idRangeOffset 下記参照
  • firstCode から下位バイトが始まり、entryCount の長さを持つ範囲(0―255 以内)が指定される。
  • 下位バイトが部分範囲外のとき、グリフインデックス 0(グリフが見当たらない)にマッピングされる。
  • 下位バイトが部分範囲内のとき、部分配列 glyphIdArray(長さ entryCount)のインデックスとなる。
  • idRangeOffset は、firstCode の現れる glyphIdArray 要素が idRangeOffset の位置から何バイト先かを表す。
  • 最終的に、部分配列から得られる値が 0 でない(グリフが存在しない)ときは、idDelta を加えると glyphIndex を得られる。

解説と実践:ヒラギノフォントを読む

訳がわからないですね、以下、噛み砕きながら詳しく見ていきましょう。

まず、文字コードの話からです。ASCII 文字列は 0-255(1 バイト)の範囲で表すことが出来ますが、膨大な量の漢字を有する日本語は、到底 256 文字では表現できません。そこで連続する 2 バイトを消費することで、 255 × 255 = 65025 個のグリフが表現可能になります。これがマルチバイトの考え方です。
例えば「あ」は Shift_JIS で表すと 0x82A0 に位置するため、上位バイトが 0x82、下位バイトが 0xA0 となります。

今回問題となる Format 2 は、Apple がかつて Macintosh 時代に使用していた文字コードに対応した CMap と見做すことが出来ます。
旧来の MacOS では、Shift_JIS をベースとした MacJapanese なるエンコーディングが利用されていました。従って日本語フォントで Format 2 の CMap が内包される際は、Shift_JIS に概ね則った文字コードを想定する必要があります。

「あ」のCIDを取得する

Format 2 では、subHeaderKey -> subHeaders -> glyphIdArray の順に探索を進め、最終的に得られた値がフォントのグリフインデックス(CID ないし GID) になります。

subHeaderKey は文字の上位 1 バイト(i とする) と対応し、subHeaderKey[i] / 8 が取得すべき subHeaders のインデックスとなります。
1 バイト文字を読み込む場合は、subHeaderKey[i] / 8 == 0 となり、下位バイトは存在しません。SubHeader テーブルを参照するまでもなく、idRangeOffset[i] が直接 CID となります。

今回は、ヒラギノ角ゴシック Pr6N W6(PSName: HiraKakuProN-W6)上で「あ」の CID を読み出す手順を考えます。
先述の通り、「あ」の上位バイトは i=0x82 (=130) ですので、まず subHeaderKey[130] を読み出します。ヒラギノの場合、subHeaderKey[130] = 16 が設定されていました。
続いて、16 / 2 = 8 より subHeaders[2] を取得します。下記の SubHeader が得られました。

firstCode: 79
entryCount: 163
idDelta: 0
idRangeOffset: 1244

このとき、subHeader[2] は上位バイト 0x82、下位バイトは 10 進法で 79―241、すなわち 0x4f―0xf1 を表すことになります。
Shift_JIS の文字コード表[2]と照合すると、0x824F-0x82F1 は全角数字の0に始まり、全角アルファベット・ひらがな(ぁ―ん)までをカバーする範囲に該当します。

idRangeOffset は 1244 であるため、0x824F の CID は、idRangeOffset を起点にして 1244 バイト先に存在します。ヒラギノフォントの場合、idRangeOffset は CMap テーブル自体の先頭を 0 として 29210 バイト目に存在するため、29210 + 1244 = 30454 バイト目を参照します。

参照先の値を p とするとき、p=781 でした。p≠0 の場合は idDelta を加える必要がありますが、今回は idDelta =0 であるため、CID780 が 0x824f の CID になります。今回は「あ」を読み出したいため、30454 に さらに (0x82A0 - 0x824F) * 2 = 162 を加えた 30616 バイト目を読むと、843 の値が得られました。
Adobe Japan1-6 の文字コレクション表[3]を参照すると、843 は確かに「あ」のグリフが存在しています。従って、無事正しい CID が取得できたようです。

image.png

ポインタ風に書くとこんな感じになるはずです。

cid = *(&idRangeOffset + idRangeOffset + (codepoint - firstCode)) + idDelta

なお、変換したいコードポイントが firstCodefirstCode + entryCount - 1 の範囲外である場合は、CID=0(欠落グリフ)となります。

実装

以上を踏まえて C# の実装に落とし込むと、以下のソースコードになりました。フォントの操作の部分に関しては割愛しています[4]

フォントのパース

public class SubTableFormat2 : CMapSubTable
{
    public ushort Language { get; }
    public ushort[] SubHeaderKeys { get; }
    public List<SubHeader> SubHeaders { get; } = new();

    internal SubTableFormat2(FontBinaryReader reader)
    {
        reader.Position = 4;
        Language = _reader.ReadUInt16();
        SubHeaderKeys = new ushort[256];
        for (int i = 0; i < 256; i++)
        {
            SubHeaderKeys[i] = _reader.ReadUInt16();
        }
        int maxSubHeader = SubHeaderKeys.Max();
        for (int i = 0; i <= maxSubHeader; i++)
        {
            SubHeaders.Add(new SubHeader(_reader));
        }
    }
}

public class SubHeader
{
    public ushort FirstCode { get; }
    public ushort EntryCount { get; }
    public short IdDelta { get; }
    public ushort IdRangeOffset { get; }
    public ushort[] GlyphIdArray { get; }

    internal SubHeader(FontBinaryReader reader)
    {
        FirstCode = reader.ReadUInt16();
        EntryCount = reader.ReadUInt16();
        IdDelta = reader.ReadInt16();
        IdRangeOffset = reader.ReadUInt16();
        int pos = reader.Position;
        GlyphIdArray = new ushort[EntryCount];
        for (int i = 0; i < EntryCount; i++)
        {
            if (pos + IdRangeOffset + i * 2 < reader.Length)
            {
                reader.Position = pos + IdRangeOffset + (i - 1) * 2;
                GlyphIdArray[i] = reader.ReadUInt16();
                reader.Position = pos;
            }
        }
    }
}

CIDへの変換

int UnicodeToCID(CMapTable table, int unicode)
{
    foreach (SubTable table in table.macintoshSubTables)
    {
        if (table is SubTableFormat2 format2)
        {
            # Unicode to Shift_JIS
            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
            Encoding shiftJis = Encoding.GetEncoding("Shift_JIS");
            byte[] bytes = Encoding.Unicode.GetBytes(Convert.ToChar(unicode).ToString());
            byte[] shiftJisBytes = Encoding.Convert(Encoding.Unicode, shiftJis, bytes);

            int subHeaderKey = format2.SubHeaderKeys[shiftJisBytes[0]];
            SubHeader subHeader = format2.SubHeaders[subHeaderKey / 8];
            int deltaIndex = shiftJisBytes.Last() - subHeader.FirstCode;
            if (0 <= deltaIndex && deltaIndex < subHeader.GlyphIdArray.Length)
            {
                int cid = subHeader.GlyphIdArray[deltaIndex];
                return cid + (cid != 0 ? subHeader.IdDelta : 0);
            }
        }
    }
    return 0;
}

雑感

実は Apple[5] のドキュメントの方が読みやすかった。

脚注
  1. cmap - Character To Glyph Index Mapping Table (OpenType 1.9) - Typography | Microsoft Docs - https://docs.microsoft.com/en-us/typography/opentype/spec/cmap#format-4-segment-mapping-to-delta-values ↩︎

  2. Shift_JIS 文字コード表 - https://seiai.ed.jp/sys/text/java/shiftjis_table.html ↩︎

  3. Adobe Technical Note #5078: The Adobe-Japan1-6 Character Collection - https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5078.Adobe-Japan1-6.pdf ↩︎

  4. C# を含めた .NET 上では、バイト列はリトルエンディアンで扱われることに留意する必要があります。OpenType/TrueType のデータ構造は、全てビッグエンディアンで表されます。 ↩︎

  5. https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6cmap.html ↩︎

Discussion