🌟

忙しい人のための CFF テーブル入門: PDF にOpenTypeフォントのサブセットを埋め込むには

2022/07/24に公開

フォントを削る?埋め込む?

Adobe が開発した文書交換フォーマットである Portable Document Format (PDF) では、フォントを埋め込むことで機種に依存することなく、文書の体裁を再現することが出来ます。

一方で、前回の記事にも記述したように、欧文フォントであればグラフは高々数百個程度ですが、CJK(Chinese/Japanese/Korean)フォントは数万字にも及ぶ膨大なグリフを有しているため、その全てを埋め込むと、PDF 一つあたりのファイルサイズが数十〜数百 MB 程度にまで達してしまいます。

もっとも、日本語は文中の 3 割程度をかなが占めるほか[1]、漢字の中でも常用漢字(2136 字)が 96% の登場頻度を占める[2]ことに鑑みると、数万字程度のグリフを全て埋め込むのは明らかにオーバーヘッドであると考えられます。ここで登場するのがフォントのサブセット化という概念です。画面上(文書上)に登場するグリフのみを抜粋する技術で、PDF や電子書籍、Web フォントの配信などで主に用いられています。

CFF (Compact File Format)

今回もフォントフォーマットに OpenType を想定して話を進めます。OpenType には B-スプライン曲線でパスを描画する TrueType ベースのものと、PostScript を由来とする OpenType/CFF (Comact File Format) と呼ばれる 2 種類が存在しますが、今回は一般的な商用フォントで採用されている後者を対象としています。

さて、この CFF は解読するのが非常に難しいです。OpenType/CFF は Apple が開発した TrueType をベースに、Adobe の CID-keyedPostScript といった要素を組み入れた歴史的経緯を持つため、OpenType の中に CFF という全く別のフォーマットが入り組んでいる、と解釈するのが妥当なように思えます。正直、ちぐはぐ感は否めません。

PDF に OpenType を埋め込む場合は、この CFF テーブルのバイナリをまるごと埋め込めば良いのですが、サブセット化に際しては、CFF をパースして不要な部分を削除したのち、適切な形で CFF を再構築する必要があります。まずは、サブセット作成に際して必要となる最小限の仕様を抑えながら、CFF のファイルフォーマットを概観したいと思います。

基本データ型

OpenType と同様に、ビッグエンディアンで表現します。

名称 範囲 詳細
Card 80–255 符号なし 16 ビット整数(uint16)
Card 160–65535 符号なし 2 バイト整数
Offset 不定 1―4 byte の可変長によるオフセット(サイズは OffSize によって規定)
OffSize 1–4 符号なし 8 ビット整数(uint8)
SID 0–64999 2 バイト文字列

DICT Data

CFF の基本的なデータ型の一つに DICT Data, INDEX Data(後述)が存在します。

DICT Data は連想配列や辞書型に該当するデータ型で、オペランド(値)とオペレータ(辞書でのキーに相当。整数の組み合わせで記述する)が連続した状態で記述されます。
オペランドとオペレータは、先頭の 1 バイトを読み込むことで区別します。またオペランドの種類も、オペランドの先頭 1 バイトを見て判断されます。(あくまでオペランド・オペレータの最初の値の判別にのみ使用できることに注意。)

1 バイト目 種類
0―11, 13―21 1 バイトのオペレータ
12 2 バイトで表されるオペレータの 1 バイト目
28, 29, 32―254 整数
30 実数
public class DictData : Dictionary<string, List<object?>>
{
    internal DictData() { }
    internal DictData(DictData dict) : base(dict) { }

    internal void Parse(byte[] bytes)
    {
        var stream = new MemoryStream(bytes);
        using var reader = new FontBinaryReader(stream);
        var values = new List<object?>();
        int pos = reader.Position;
        while (reader.Position < pos + bytes.Length)
        {
            byte b0 = reader.ReadByte();
            // Key
            if (b0 is 12)
            {
                byte b1 = reader.ReadByte();
                Add(b0 + " " + b1, values);
                values = new();
            }
            else if (b0 is >= 0 and <= 21)
            {
                Add(b0.ToString(), values);
                values = new();
            }
            // Integer
            if (b0 is 28 or 29 or (>= 32 and <= 254))
            {
                values.Add(ParseInteger(reader, b0));
            }
            // Real number
            if (b0 is 30)
            {
                var value = new List<byte>() { b0 };
                while ((b0 & 0x0f) is not 15 || (b0 >> 4 & 0x0f) is 15)
                {
                    b0 = reader.ReadByte();
                    value.Add(b0);
                }
                values.Add(value);
            }
        }
    }
}

整数

整数は 1―4 バイトの可変長で、上位バイトから b0, b1... とするとき、下記の通り求められます。

サイズ b0 の範囲 値の範囲 値の演算手法
1 32–246 -107 – +107 b0–139
2 247–250 +108 – +1131 (b0–247)*256+b1+108
2 251–254 –1131 ― –108 –(b0–251)*256–b1–108
3 28 –32768–+32767 b1<<8|b2
5 29 –(2^31)–+(2^31–1) b1<<24|b2<<16|b3<<8|b4
public int ParseInteger(FontBinaryReader reader, int b0)
{
    if (b0 is >= 32 and <= 246)
    {
        return b0 - 139;
    }

    byte b1 = reader.ReadByte();
    if (b0 is >= 247 and <= 250)
    {
        return (b0 - 247) * 256 + b1 + 108;
    }
    if (b0 is >= 251 and <= 254)
    {
        return -(b0 - 251) * 256 - b1 - 108;
    }

    byte b2 = reader.ReadByte();
    if (b0 is 28)
    {
        return b1 << 8 | b2;
    }

    byte b3 = reader.ReadByte();
    byte b4 = reader.ReadByte();
    // b0 is 29
    return b1 << 24 | b2 << 16 | b3 << 8 | b4;
}

この他に可変長実数が存在しますが、サブセット化に際してはクリティカルではないため、具体的なパースは省略します。先頭のバイトは常に 30 で、上位 or 下位 4 ビットが 15 であるときに値が終了します。

public int ParseDouble(FontBinaryReader reader, int b0)
{
    if (b0 is 30)
    {
        while ((b0 & 0x0f) != 15 || (b0 >> 4 & 0x0f) != 15)
        {
            b0 = reader.ReadByte();
        }
    }
}

INDEX Data

INDEX Data は配列に相当するデータ型です。offset[i]data[i]1 から始まるオフセットを指します。offset[count+1] という奇妙な要素は、オブジェクトデータの最後のオフセットを取得するために用意されたものだったのです。各データのサイズは、offset[i+1] - offset[i] で求められます。

名称 詳細
Card16 count Number of objects stored in INDEX
OffSize offSize Offset array element size
Offset offset[count+1] Offset array (from byte preceding object data)
Card8 data[<varies>] Object data
public class IndexData
{
    internal ushort Count { get; }
    internal uint[] Offset { get; }

    internal static IndexData Parse(FontBinaryReader reader)
    {
        ushort count = reader.ReadUInt16();
        byte offsize = reader.ReadUInt8();
        var offset = new uint[count + 1];
        var indexData = new IndexData(offsize);
        for (int i = 0; i < count + 1; i++)
        {
            offset[i] =
                offsize == 1 ? (uint)reader.ReadUInt8() :
                offsize == 2 ? reader.ReadUInt16() :
                offsize == 3 ? reader.ReadUInt24() : reader.ReadUInt32();
        }
        for (int i = 0; i < count; i++)
        {
            indexData.Data.Add(reader.ReadBytes((int)(offset[i + 1] - offset[i])));
        }
        return indexData;
    }
}

CFF を読んでみる

基本的なデータ型を抑えたところで、早速フォントを読み進めていきたいと思います。実装言語は C# です。

CFF テーブルは、下記の順序でサブテーブルが格納されています。サブセットといえども、この全てのサブテーブルを適切な順序で埋め込む必要があります。

  • Header
  • Name INDEX
  • Top DICT INDEX
  • String INDEXs
  • Global Subr INDEX
  • Encodings(OpenType では存在せず)
  • Charsets
  • FDSelect
  • CharStrings INDEX
  • Font DICT INDEX
  • Private DICT
  • Local Subr INDEX
  • Copyright and Trademark Notices

CFF テーブルの先頭には Header が存在します。サブセット化では必要ないので、ひとまず読み飛ばすことにします。

reader.ReadUInt8(); // major
reader.ReadUInt8(); // minor
reader.ReadUInt8(); // hdrSize
reader.ReadUInt8(); // offSize

Name INDEX

Header に後続するのは、Name INDEX と呼ばれるデータです。詳細は下記のとおりです。

  • FontSet に含まれる全フォントの PostScript 名 (FontName or CIDFontName) が格納される。
  • フォント名は ASCII 順にソートされており、FontSet 内でのバイナリサーチが可能。
  • ソート順は、8 ビット符号なし整数として扱われる文字コードに基づく。
  • フォント名は 127 文字以下で、以下の文字列を含まない。
    • [ ] ( ) { } < > / % null (NUL), スペース, タブ, carriage return, line feed, form feed
  • フォント名は、印刷可能な ASCII 文字、33―126 の範囲 に制限することが推奨される。

ただ、OpenType では一つのフォント名しか含まず、それほど重視もされていません。INDEX Data であるため、先述したクラスを利用して読み進めることが出来ます。

// Name INDEX
var nameIndexData = IndexData.Parse(reader);
NameIndex = new string[nameIndexData.Data.Count];
for (int i = 0; i < nameIndexData.Data.Count; i++)
{
    var sb = new StringBuilder();
    foreach (byte item in nameIndexData.Data[i])
    {
        sb.Append(Convert.ToChar(item));
    }
    NameIndex[i] = sb.ToString();
}

Top DICT INDEX

Name INDEX に連続して存在します。実態は、FontSet に含まれる全フォント(OpenType/CFF の場合は 1 フォント)に対して、トップレベルとなる DICT Data をデータとして内包した INDEX Data です。

  • Name INDEX に含まれる PostScript 名と、個数及び順番及びが対応する。
  • フォントは Name INDEX を用いて識別され、Top DICT を通じてデータにアクセス可能となる。
// Top DICT Index
TopDICTIndexOffset = reader.Position;
TopDictIndex = IndexData.Parse(_reader);
foreach (Byte[] bytes in TopDictIndex.Data)
{
    TopDict = new DictData();
    TopDict.Parse(bytes);
}

Top DICT Index まではデータが連続して存在していましたが、今後は Top DICT 内に存在するオフセットテーブルを利用して、それぞれのサブテーブルに移動しながら処理を進めていくことになります。
サブセット化に必要な、今後出現する Charsets, CharStrings, FDArray, FDSelect の 4 テーブルのオフセットを、下記オペレータを通じて取得します(DICT Index 構造におけるオペレータと役割の関係に関しては、仕様書内に定義があります)。

FdArrayOffset = (int)TopDict["12 36"][0]!;
FdSelectOffset = (int)TopDict["12 37"][0]!;
CharsetsOffset = (int)TopDict["15"][0]!;
CharStringsOffset = (int)TopDict["17"][0]!;

CFF Specification の 18 ページには、CFF を扱うには、Encodings, Charsets, Glyphs の三者を並列した配列と捉えて処理することが望ましいと記述があります。ただし、OpenType/CFF においては、Encodings テーブルは省略されているようです。この辺りに関しては後ほど見ていくとして、先に String Index の方を読み込みます。

String Index

String Index は、Name INDEX 中での FontName / CIDFontName を除いた、CFF テーブル内で仕様されるすべての文字列を集約したテーブルになります。文字列は文字列識別子または SID(uint16, 0―64999 の範囲を取る)を通じて参照されます。

サブセットには関係ないので、オフセットのみを記憶してスキップします。

StringIndexOffset = _reader.Position;

CharStrings INDEX

CharStrings は、フォントのパスデータを Type 2 形式で記述したバイナリデータです。フォントのデータの大半を占める部分であり、ここで不要なグリフの箇所を削除することでサブセット化が実現されます。CharString の内容を解釈する必要はありません。
変数 nGlyphs は、続いて CharSets を読み込む際に必要となります。

// CharStrings: GID to CharString
reader.Position = CharStringsOffset;
CharStrings = IndexData.Parse(reader);
int nGlyphs = CharStrings.Data.Count;

Charsets

Charsets は、GID から CID への変換テーブルとして機能します。

往々にして同一視されている[3]ような気もしますが、本来 CID と GID は異なる概念です。GID はフォントの内部において、グリフに対して連番にインデックスを割り振ったもの、CID は Adobe-Japan1-x に代表される文字コレクションにおいて、文字に固有のインデックスを割り当てたものです。
例として、Adobe-Japan 1-7 では「令和」の縦書き用、横書き用グリフの 2 文字が、CID23058―23059 に追加されました。㋿ は Pr6 のフォントでなくても含まれていると思いますが、この際、CID は飛び飛びに、GID は 0 から連番で割り振られます。

Charsets には 3 種類のフォーマットが存在し、Format 0 は CID を個々に指定します。
一方の、Format 1, 2 は連番の範囲を指定して GID と CID の対応を示すものです。Range 1 と nLeft の型が異なるのみで、残りは共通です。
複数の日本語フォントを確認してみましたが、Format 2 が広く使用されており、また CID と GID はほぼ一致するものが殆どでした。

Format 0

名称 詳細
Card8 format 0
SID glyph[nGlyphs–1] グリフの名称を示す配列

Format 1

名称 詳細
Card8 format 1
struct Range1[<varies>] Range1 の配列(下記参照)

Range 1 Format (Charset)

名称 詳細
SID first 範囲内の先頭グリフ
Card8 nLeft 最初を除いた残りのグリフ数

Format 2

名称 詳細
Card8 format =2
struct Range2[<varies>] Range2 の配列(下記参照)

Range 2 Format

名称 詳細
SID first 範囲内の先頭グリフ
Card16 nLeft 先頭を除いた残りのグリフ数

Range1/2[<varies>] は可変長配列で、明示的な長さは指定されません。このフォーマットは綺麗に整列された CID を想定するため、必要なグリフの範囲までを処理すれば大丈夫です。

public class Charsets
{
    internal List<ushort> Glyph { get; } = new();

    internal void Parse(FontBinaryReader reader, int nGlyphs, int fdSelectOffset)
    {
        byte format = reader.ReadUInt8();

        // Format 0
        if (format is 0)
        {
            for (int i = 0; i < nGlyphs - 1; i++)
            {
                Glyph[i] = reader.ReadUInt16();
            }
        }
        // Format 1, 2
        if (format is 1 or 2)
        {
            while (reader.Position < fdSelectOffset)
            {
                ushort first = reader.ReadUInt16();
                ushort nLeft = format == 1 ? (ushort)reader.ReadUInt8() : reader.ReadUInt16();
                for (int i = 0; i <= nLeft; i++)
                {
                    Glyph.Add((ushort)(first + i));
                }
            }
        }
    }
}
// Charsets: GID to SID/CID
_reader.Position = CharsetsOffset;
Charsets = new();
Charsets.Parse(_reader, nGlyphs, FdSelectOffset);

FDSelect

FDSelect では、GIDと FDArray の対応が定義されます。FD は Font Dictionary の略称で、フォントの各種情報を一定の範囲毎に定義しています。
こちらも Format 0 は個々に、Format 3 は一定の範囲を連番で指定します。

Format 0

名称 詳細
Card8 format =0
Card8 fds[nGlyphs] FD selector の配列

Format 3

Type Name Description
Card8 format =3
Card16 nRanges Number of ranges
struct Range3[nRanges] Range3 の配列
Card16 sentinel Sentinel GID 末尾の GID + 1
public class FDSelect
{
    internal List<byte> Fds { get; } = new();

    internal void Parse(FontBinaryReader reader, int nGlyphs)
    {
        byte format = reader.ReadUInt8();
        // Format 0
        if (format == 0)
        {
            for (int i = 0; i < nGlyphs; i++)
            {
                Fds.Add(reader.ReadUInt8());
            }
        }
        // Format 3
        if (format == 3)
        {
            ushort nRanges = reader.ReadUInt16();
            ushort first = reader.ReadUInt16();
            for (int i = 0; i < nRanges; i++)
            {
                byte fd = reader.ReadUInt8();
                ushort firstOrSentinel = reader.ReadUInt16();
                for (int gid = first; gid < firstOrSentinel; gid++)
                {
                    Fds.Add(fd);
                }
                first = firstOrSentinel;
            }
        }
    }
}
// FDSelect: GID to FDIndex
_reader.Position = FdSelectOffset;
FdSelect = new();
FdSelect.Parse(_reader, nGlyphs);

似た名称が出現してこんがらがってきました。ここで一旦整理します。
Charsets のみ CID0(.notdef)の扱いが異なりますので注意が必要です(詳細は後述します)。

種類 説明
Charsets GIDCID の対応。CID0 を含まない。
CharStrings GIDグリフ の対応。CID0 を含む。
FDSelect GIDFont DICT(FD) の対応。CID0 を含まない。

Font DICT Index

最後に、Font DICT Index を読み込みます。後々オフセットの調整が必要となります。

// Font DICT Index
_reader.Position = FdArrayOffset;
FdArray = IndexData.Parse(_reader);
for (int i = 0; i < FdArray.Data.Count; i++)
{
    FdArrayDict.Add(new DictData());
    FdArrayDict.Last().Parse(FdArray.Data[i]);
}
LocalSubrIndexOffset = _reader.Position;

埋め込みサブセットを生成する

CFF テーブルの読み込みは以上で終わりです。続いて、このテーブルを再構築していく作業が必要となります。
Unicode を入力として与えた際の CFF サブセットの生成は、下記の手順を踏むことで実現されます。このうち、1. は CFF 外の処理(OpenType フォントにおいては cmap テーブルで実装されます)であるため、本稿では省略します。

  1. cmap テーブルを通じて Unicode を CID に変換
  2. 新たなバイト列 b[] を用意し、Header, Name INDEX をコピー
  3. Top DICT Index を読み込み、後続するテーブルへのオフセットを取得
  4. Charsets より GID -> CID の変換テーブルを取得
  5. Charsets, CharStrings, FDSelect の更新(以下 6―8. は同時に行う)
  6. Charsets の更新。GID -> CID の変換テーブルを、.notdef を除いた GID1 から連番に再度割り当て
  7. CharStrings の中から、描画に必要な(CID に対応する)GID + GID0 (=CID0, .notdef) 以外のグリフデータを削除
  8. FDSelect を 7. と同様に更新する
  9. Top DICT 内に存在するオフセット値を更新し、b[] に書き込み
  10. b[] に String Index, Global Subr INDEX をコピー
  11. b[] に 更新済みの Charsets, FDSelect, CharStrings INDEX を順に書き込み
  12. Font DICT 内のオフセットを更新し、b[] に書き込み
  13. b[]に CFF テーブル末尾までの残りのデータをすべてコピー

なお、CID-keyed Font に関しては、CFF Specificationの 27 ページに詳しいです。和訳して抜粋します。

  • CIDFontName を Name INDEX に有する
  • Top DICT は ROS[4] オペレータから始まり、これが CID フォントであることをパーサに明示する。
  • FDArray は Font DICT INDEX へのオフセットを指定する[5]
    • グリフは幾つかのグループに所属し、所属する Font DICT を参照する。
  • FDSelect 内の Private オペレータを用いて FontArray を参照する。

実装

前章で述べた CFF テーブルのパースも併せて、600 行程度になります。比較的長いので GitHub Gist 上に公開しました。ご自由にお使い下さい。

https://gist.github.com/inaniwaudon/70fe903bacf358e5934053f45971ffd0

以下に、注意すべき点をコードを抜粋しながら解説します。

Top DICT のオフセットを更新すると、後続するサブテーブルのオフセットが変化する

これは、DICT Data において、整数値が可変長数値表現として扱われることに起因します。
例えば Charsets のオフセット(Top DICT["15"] に相当)が、CFFテーブルの先頭から 0x046d (1133) に存在すると仮定します。
このとき、サブセット化によって Charsets が 0x046a (1130) に移動すると、Top DICT["15"] の整数値の長さは 3 -> 2 バイトに減少し、最終的に Charsets のオフセットは 0x0469 になります。

同じ Adobe の規格でも、PDF の場合はオフセットの指定がある程度適当でも許容されるようですが、CFFは 1 バイト異なるとエラーが発生するようです(独自検証)。妖怪 1 足りないには気を付けましょう。
コード中では、GetDeltaIntegerLength という関数を定義し、更新後のオフセットと Top DICT 内の整数値での差分を加えることで、エラーを解消しています。

int deltaTopDictLength =
    DictData.GetDeltaIntegerLength((int)_table.TopDict["15"][0]!, charsetOffset) +
    DictData.GetDeltaIntegerLength((int)_table.TopDict["17"][0]!, charStringsOffset) +
    DictData.GetDeltaIntegerLength((int)_table.TopDict["12 36"][0]!, fdArrayOffset) +
    DictData.GetDeltaIntegerLength((int)_table.TopDict["12 37"][0]!, fdSelectOffset);

// Create new Top DICT INDEX.
var topDictIndex = new IndexData(_table.TopDictIndex.Offsize);
var topDict = new DictData(_table.TopDict);
topDict["15"] = new() { charsetOffset + deltaTopDictLength };
topDict["17"] = new() { charStringsOffset + deltaTopDictLength };
topDict["12 36"] = new() { fdArrayOffset + deltaTopDictLength };
topDict["12 37"] = new() { fdSelectOffset + deltaTopDictLength };
var topDictBytes = topDict.ToArray();
topDictIndex.Data.Add(topDictBytes);

Charsets は .notdef を含まない

先述の通り、GID -> CID の対応は、仕様として GID0 = CID0 = .notdef と定義されているためです。一方で、.notdef にもグリフは存在しており[6]、また FDArray とも関連付けられる必要があるため、CharStrings および FDSelect では .notdef を必ず含む必要があります。

例えば、CID = GID で定義されるフォントが存在する場合、「あ」(CID 843)の一文字をサブセットとして含むフォントを制作するには下記の通りに指定します。

新しい GID 元々の GID CID Charsets CharStrings FDArray
0 0 0 (.notdef) 定義しない CharStrings[0] FDarray[0]
1 843 843 Charsets[842] CharStrings[843] FDarray[843]
// Create new Charsets, CharStrings, and FDSelect
var charsets = new Charsets();
var charstrings = new IndexData(_table.CharStrings.Offsize);
var fdSelect = new FDSelect();

foreach ((int newGid, int oldGid) in newGidToOldGid)
{
    if (oldGid > 0)
    {
        charsets.Glyph.Add((ushort)oldGidToCid[oldGid]);
    }
    fdSelect.Fds.Add(_table.FdSelect.Fds[oldGid]);
    charstrings.Data.Add(_table.CharStrings.Data[oldGid]);
}

PDF に埋め込む

PDF に CID-keyed Font を埋め込む場合、以下の Indirect Object を定義し、依存関係を持たせます。ページが参照する /Resources 辞書の /Fonts エントリから、/SubType /Type0 の Indirect Object を参照することで、コンテンツストリーム内で Tf オペレータを通じてフォントが使用可能となります。

Type SubType
Font CIDFontType0
Font Type0
FontDecriptor 定義しない

例えば PostScript Name (PSName) が RodinNTLG-Pro-EB であるフォントを埋め込む場合は、下記の記述が想定されます。フォントをサブセットとして埋め込む場合、PSName の前に タグ+ を接頭辞として付与する必要があります[7]。タグはアルファベット大文字6文字からなる任意の文字列です。

フォントのサブセットにおいて、フォントの PostScript 名(Font における BaseFont, FontDescriptor における FontName の値)は、TAG+FontNameの形でに続いてタグが記述される。タグには任意の文字を利用できるが、同一ファイル中の異なるサブセットには、異なるタグを付す必要がある。(PDF Reference Sixth edition 5.5.3 節より和訳)

// Resource 辞書にて
/Font <<
/QAWSED+RodinNTLGPro-EB-Identity-H 7 0 R
>>
6 0 obj
<<
/Type /Font
/BaseFont /RodinNTLGPro-EB
/FontDescriptor 8 0 R
/Subtype /CIDFontType0
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Japan1)
/Supplement 6
>>
7 0 obj
<<
/Type /Font
/Subtype /Type0
/Encoding /Identity-H
/DescendantFonts [ 6 0 R ]
/BaseFont /QAWSED+RodinNTLGPro-EB
>>
endobj
8 0 obj
<<
/Type /FontDescriptor
/FontName /RodinNTLGPro-EB
/Flags 4
/FontBBox [ -250 -250 1100 1100 ]
/ItalicAngle 0.0000000000
/Ascent 880
/Descent -120
/CapHeight 0
/StemV 0
/FontFile3 12 0 R
>>
endobj
12 0 obj
<< /Length 198899 >>
stream
...
endstream
endobj

/Encoding として /Identity-H を指定していますが、これは CMap を経由せずストリーム中に直接 CID を記述することを明示しています。縦書きの場合は、/Identity-V を指定します。
テキストを描画する際は <0395> Tj といった具合に、4桁ゼロパディングの16進数でCIDを指定し、山括弧で囲って表記します。

Type0 ないし FontDescriptor で要求されるエントリのうち、ROS (Registry, Order, Supplement), FontBBox, CapHeight, StemV に関しては CFF テーブル内の値を参照して指定します。その他の Ascender 等に関しては Hmtx テーブル等に内包されているため、そちら側で処理することも可能です。

/FontFile3 にはバイナリを指定します。サブセット化された CFF テーブルのバイナリをそのまま記述します[1:1]

以上の手順を踏んで生成した PDF を実際に開いてみると……

image.png

無事に埋め込まれたフォントが表示されました!
3 種類のフォントを使用した文書では、フォントを全て埋め込んだ際に 20.4 MB, サブセット化した場合は 428 KB でしたので、大幅なファイルサイズの圧縮に寄与していると考えられます。

追記:2022/11/29

更に実装を進めることで誤りがあった/気が付いた点がいくつか存在したため、追記します。

1. OpenType は GID を直接指定する

本文中で Unicode → CID → GID と値を参照する必要があるため、Charsets テーブルを変換表として用いる必要があると解説しました。しかしながら、OpenType フォントにおいては、cmap, GSUB, GPOS 等の各テーブルにて GID が直接指定されるため、Charsets を用いて GID を逆引きする必要はありません。多くの和文フォントでは CID = GID と定義されていたため気が付かなかったのですが、源ノ角ゴシック(Source Han Sans)のサブセットが CID != GID で定義されており、この誤りを発見するに至りました。

CID・GIDの棲み分けに関しては、Adobe CJK Type Blog の CID vs GID に詳細な記載があり、「フォント作成時には CID を用いて指定し、makeotf 等のツールを用いて GID へと変換することが好ましい」と記載されています。

2. Top DICT のオフセット更新の改良

上述のプログラムを実行したところ、Acrobat 上でエラーとなるフォント[8]が幾つか見受けられました。原因を調査したところ、Top DICT のオフセットを更新する際のアルゴリズムに誤りがありました[9]テーブルの長さが同定されるまで while ループを回しながら、オフセットを都度更新することでエラーは解消されます。この手法は Font DICT INDEXes のオフセットの更新に際しても適用することができます。

IndexData topDictIndex;
byte[]? topDictIndexBytes = null;
int topDictLength;

do
{
    topDictLength = topDictIndexBytes is object ? topDictIndexBytes.Length : 0;
    topDictIndex = new IndexData(_table.TopDictIndex.Offsize);
    var topDict = new DictData(_table.TopDict);
    topDict["15"] = new() { charsetOffset + topDictLength };
    topDict["17"] = new() { charStringsOffset + topDictLength };
    topDict["12 36"] = new() { fdArrayOffset + topDictLength };
    topDict["12 37"] = new() { fdSelectOffset + topDictLength };
    byte[] topDictBytes = topDict.ToArray();
    topDictIndex.Data.Add(topDictBytes);
    topDictIndexBytes = topDictIndex.ToArray();
} while (topDictIndexBytes.Length != topDictLength);

参考文献

脚注
  1. https://www.jstage.jst.go.jp/article/jmet/24/2/24_KJ00003905442/_pdf/-char/ja ↩︎ ↩︎

  2. https://www.bunka.go.jp/seisaku/bunkashingikai/kokugo/nihongokyoiku_hyojun_wg/04/pdf/91934501_08.pdf ↩︎

  3. 例えば、モリサワのサイト では、CID と GID を同一視した記述があります。もっとも、和文フォントにおいてはこの解釈はほぼ正しいとも考えられますが…… ↩︎

  4. Registry/Ordering/Supplement の略。Registry: Adobe / Ordering: Japan1 / Supplement: 6 といった具合に使用されます。 ↩︎

  5. FDArray 内にもサブセットテーブルが存在しますが、これは FDArray からの相対位置(CFF における FDArray 以外のオフセットはすべて CFFテーブルの先頭から指定されます)になるため、特段に再指定する必要はありません。 ↩︎

  6. .notdef は当該フォントに未定義の CID が参照された際に表示されます。いわゆる「豆腐」で、多くの場合は四角形にバツ印で表されます。 ↩︎

  7. タグが付されるとき、アプリケーションは + 以降文字列をフォント名として認識します。従って、ABCDEF+PsName, GHIJKL+PsName が存在するとき、この 2 つのフォントは同一フォント PsName として扱われます。サブセットとして扱われているかは、Adobe Acrobat 上で「文書のプロパティ」->「フォント」と進んだ際に(埋め込みサブセット)と表示があるかどうかで確認可能です。 ↩︎

  8. mac OS に同梱の「凸版文久見出しゴシック」「筑紫A丸ゴシック」等。モリサワやフォントワークス、ヒラギノフォント等では再現せず。 ↩︎

  9. 当初は全くエラーの見当が付かなかったのですが、Charstring を綿密に分析していくことでバグの発見に繋がりました。 ↩︎

Discussion