技術記事

フォントサブセットのクロージャ:DelphiのPDFで消えるシェイプ済みグリフ

フォントサブセッターが出力されたコードポイントから到達可能なグリフだけを保持していると、シェイプ済みのグリフは.notdefの四角として描画されてしまう。DelphiとC++Builder向けのネイティブVCL PDFコンポーネントであるHotPDFは、バージョン2.435.0までまさにその不具合を抱えていた:OpenTypeのGSUB出力は、サブセッターが尊重すると宣言しておきながら実際には一度も読み取らない内部の使用状況ビットマップに記録されていた

これはフォントサブセット化を静かに無効化していたEndDocのバグとは異なる失敗である。あちらのバグはシリアライズに対してサブセット化がいつ実行されるかという話であり、サブセット化そのものを丸ごと無効にしていた。今回のものは、サブセット化がスケジュールどおり完璧に実行されたときに、そのサブセットが何を含むかという話である。パイプラインは正しいタイミングで発火し、ISO 32000-1 §9.6.4が要求するとおり6文字のサブセット接頭辞が/BaseFontに正確に現れ、ファイルは小さくなり、ラテン文字のページはすべて綺麗に校正できる。それなのにアラビア語のページは空の四角の列として出てくる。順序に関するバグは、一度見れば大声で存在を主張する。クロージャのバグは永遠に静かなままだ。サブセットは構造的には妥当であり、自分自身のメンバーリストについてだけ間違っているからである

なぜシェイプ済みのグリフは.notdefとして描画されるのか

文書が出力するコードポイントの集合は、文書が描画するグリフの集合とは異なり、両者を混同するサブセッターはシェイピングによって生成されたグリフをすべて落としてしまうからだ。テキストシェイピングは論理的な文字列を位置決めされたグリフ列へと変換するものであり、その目的全体は、単一の入力文字がマッピングされることのないグリフを生成することにある:アラビア文字の語中形のhehfi合字、デーヴァナーガリー文字の連字、rcltフィーチャーによって選ばれる文脈依存の異体字などだ。これらのそれぞれはGSUBのルックアップが作り出したグリフIDであって、文字列中のどの文字に対してもcmapテーブルが渡してくれるものではない。したがってcmapだけに駆動されるサブセッターは、そもそも間違ったインデックスを辿っていることになる。それはシェイピングより前にテキストが使いえたすべてのグリフを忠実に保持し、シェイピングの後にテキストが実際に使うグリフをまさに正確に捨ててしまう。すると、レンダラーは埋め込みフォントにGID 1847を求めるが、サブセットはlocaのそのエントリをゼロにしており、代わりにグリフインデックス0が返ってくる。グリフインデックス0はOpenTypeの定義上.notdefであり、だからこそこの失敗の徴候は、間違った文字やクラッシュではなく空の四角なのである。PDFの中に不正な部分は何もない。フォントが単にコンテンツストリームが求めたグリフを含んでいないだけなのだ

コードポイントはグリフではない:サブセットの3つの源

正しいサブセットのクロージャは、それぞれ独自の集計器を持つ3つの独立した源を合流させなければならない。1つ目はコードポイント由来の集合である:HotPDFはBMP文字が出力されるたびにFUnicodeUsedCpsを、サロゲートペアを通じて到達する補助面文字についてはFUnicodeSmpUsedを集計し、それぞれをFUnicodeCpToGidを通じてグリフIDにマッピングする。2つ目はシェイピング由来の集合であり、GSUBの置換が生成したグリフIDで、MarkUnicodeGlyphUsedEnableShapingFeatureForSubsetを通じてFUnicodeExtraUsedGlyphsに記録される。3つ目は合成グリフのクロージャである:glyf内でnumberOfContoursが-1であるグリフは構成要素となるグリフIDから組み立てられ、合成グリフを保持しながらその構成要素を落としてしまうと、.notdefではなく空の輪郭になってしまう。これはある意味さらに悪い。字間の不具合として読めてしまうからだ

HotPDFは1つ目と3つ目については常に正しく扱ってきた。シリアライズの前にEndDocが呼び出すサブセット化のエントリポイントであるBuildAndApplyUnicodeFontSubsetは、使用済みグリフ配列にGID 0を種として設定し、BMPコードポイントを走査し、SMPの使用リストを走査し、その配列を合成グリフの構成要素を内部で解決するサブセットビルダーへ渡す。2つ目の源は書き込まれていたのに一度も消費されていなかった。そしてこの3つの源はそれぞれ異なるコンテンツに対して失敗するため、この隙間はほとんどラテン文字だけの回帰用コーパスを持つコードベースの中で何年も隠れ続けることができる

書き込まれても決して読まれなかった配列

この契約は3か所で文書化されており、どこでも守られていなかった。FUnicodeExtraUsedGlyphsの宣言には、EndDocのサブセッターがこれをコードポイント由来の使用状況と合流させると書かれていた。ApplyArabicGSUBRefinementのヘッダーコメントには、出力されるすべての代替グリフIDがMarkUnicodeGlyphUsedを通過するため、サブセッターがそのグリフを埋め込みフォントに取り込むと約束されていた。まったく同じ約束がrclt経路に対するApplyArabicGSUBContextualRefinementにも一字一句そのまま現れていた。両方の呼び出し元は自分の半分を守っていた。このフィールドへのあらゆる参照をgrepすると、約90秒でもう半分の実態がわかった:宣言が1つ、RegisterUnicodeTTF内でのSetLengthによる確保が1つ、そして2つのマーキングルーチンでの書き込み。読み取りは1つもない。これは他所にも十分に一般化できる、身に付けておく価値のある診断法だ。あるフィールドが複数の呼び出し元から書き込まれているのに誰からも読まれていないなら、それが表しているはずの機能はどれだけ丁寧にコメントされていても存在しない。サブセッターのステップ1は画面1つに収まるほど小さく、探し方を知ってしまえばその隙間は一目瞭然である

// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
  UsedGlyphs[I] := False;
UsedGlyphs[0] := True;                       // .notdef is always present

for Cp := 0 to $FFFF do                      // source 1a: BMP code points
  if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
     and (Cp < Length(FUnicodeCpToGid)) then
  begin
    GID := FUnicodeCpToGid[Cp];
    if (GID > 0) and (GID < FUnicodeNumGlyphs) then
      UsedGlyphs[GID] := True;
  end;

for I := 0 to High(FUnicodeSmpUsed) do       // source 1b: SMP code points
begin
  GID := FUnicodeSmpUsed[I].GID;
  if (GID > 0) and (GID < FUnicodeNumGlyphs) then
    UsedGlyphs[GID] := True;
end;

// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs

たった1つのループの修正、そして自分でグリフをマークする

修正は合流(union)であり、その安全性の根拠はこの操作の向きから来ている:これはビットを立てるだけで決してクリアしないため、これまでサブセットを生き延びていたどんなグリフも落とされ始めることはない

// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
  if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
    UsedGlyphs[I] := True;

3つの性質のおかげで、これはフォントエンジンの書き直しではなく低リスクな変更になっている。上記のとおり単調である。何もシェイピングしなかったフォントに対しては何もしない。FUnicodeExtraUsedGlyphsはすべてFalseのままであり、ラテン文字だけの文書に対するバイト出力は変わらないからだ。そしてこれはステップ2より前に入るため、両方のサブセットビルダーがこれを引き継ぐ:元のGID番号付けを保持するスパースなビルダーと、PDF/Aのもとで保持したグリフを密な範囲へと振り直し、maxp.numGlyphsを縮小し、ISO 32000-1 §9.7.4.2が要求する/CIDToGIDMapストリームとして新旧のマッピングを出力する、HotPDFが選ぶコンパクトなビルダー_BuildCompactSubsetTTFである。どちらも内部で_TTFWalkCompositeClosureを呼び出すため、たまたま合成グリフでもあるシェイプ済みグリフは今やその構成要素も引き連れてくる。合成グリフのクロージャは一度も壊れていなかった。単に、これらのグリフIDに対して一度も到達していなかっただけである。それらのグリフIDが、クロージャが走査する集合の中に入っていなかったからだ。組み込みの精緻化パスに頼るのではなくGSUBエンジンを直接駆動するなら、クロージャはあなた自身の責任になる。出力するすべての代替グリフIDは、EndDocが使用済みグリフ集合を確定させる前にマークされなければならない

var
  Pdf: THotPDF;
  GIDs: array[0..1] of Word;
  LigGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'shaped.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
    Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
                            sfContextualAlternates];

    GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644);   // lam
    GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627);   // alef
    if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
      Pdf.MarkUnicodeGlyphUsed(LigGID);   // omit this and you get .notdef

    Pdf.EnableShapingFeatureForSubset('rclt');
    Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

EnableShapingFeatureForSubsetは単一GID呼び出しに対するバッチ版であり、意図的に保守的に作られている。現在選択されているスクリプトと言語の経路のもとで1つの4バイトフィーチャータグに結び付けられたルックアップを求めてGSUBルックアップリストを走査し、それらのルックアップが生成しうる代替グリフIDをマークする。フォントがGSUBテーブルを持たない場合や、その経路にそのフィーチャーが存在しない場合には防御的に何もしないため、無条件に呼び出しても安全である。またこれは設計上、過剰な近似でもある:ある文書が一度も描画しないグリフを保持してしまうこともある。サブセット化にとって、過剰包含はバイト数を犠牲にし、過小包含は正しさを犠牲にする。したがってこのトレードオフを選ぶのは容易い。これらのルックアップの構造、そしてどのグリフが関与するかを決める被覆(coverage)テーブルについては、純粋なDelphiでのGSUBスタイル的異体字の解説で扱っている

グリフが実際にサブセットに含まれていることをどう証明するか

裏であなたの知らないうちにシステムフォントに置き換えているかもしれないビューアでページを目視するのではなく、出力されたフォント自体を読むことによってである。この種のバグ全体を捕まえるチェックは機械的だ:出力PDFから/FontFile2ストリームを抽出し、locaをパースし、期待するグリフIDが空でないエントリ——開始オフセットと終了オフセットが異なる——を持っていることを確認すればよい。空のエントリは、サブセッターがそのグリフを未使用と判断したということである。その後2つの習慣が、この失敗を再び出荷してしまうことをずっと難しくする。シェイプされたスクリプトのページを手動での校正セットだけでなく自動化されたスモークコーパスにも残しておくこと。アラビア語、デーヴァナーガリー、クメール語は、どれだけラテン文字のカバレッジを積み上げても触れられることのないクロージャ経路を運動させるからだ。そして集計器が存在するなら、何かがそれを消費していることを常にアサートすること。書き込み専用のフィールドは、コンパイルは通り、間違ったコーパスに対するテストは緑になり、何もしない機能だからである

この修正が止まる場所

サブセットのクロージャはシェイプ済みグリフが描画されるために必要だが、それだけでは十分ではない。グリフはコンテンツストリームからアドレス可能でもなければならず、これはそれ自身の境界を持つ別の問題である。HotPDF組み込みのアラビア語精緻化パスは、代替グリフIDのすべてが、U+FB50からU+FDFF、U+FE70からU+FEFFにわたるおよそ690個のコードポイントに対する逆cmap走査を通じて、Unicodeの提示形コードポイント経由で到達可能である場合にのみ置換を確定させる。代替が範囲外のグリフIDに着地する場合、入力窓は何かをリーダーがアドレスできないものとして出力するのではなく、変更されないままそのまま通過する。任意のグリフIDに対するフォント固有の異体字は、出力経路にそれを運ぶためにU+E000からU+F8FFの範囲に割り当てられた合成的な私用領域コードポイントを必要とする。したがって正直な要約は、2.435.0の修正は物語を完結させたのではなく、固い障壁を1つ取り除いたにすぎないということである。それ以前は、グリフが正しくシェイプされ、正しく出力されても、サブセット化の時点で消えてしまうことがあった。それはつまり、ルックアップがどれほど優れていようとも、シェイピングエンジンを最初から最後まで信頼できなかったということを意味する。残っているのはアドレス可能性の問題であり、この制約は少なくとも、見ていたものすべての後で走るビルドステップの中で静かに失敗するのではなく、出力の時点で目に見える形で失敗する。同じパイプラインの出力側については、DelphiのPDFにおけるアラビア語・RTLテキストシェイピングのガイドを参照のこと

ここで説明したフォントサブセット化、GSUBエンジン、複雑スクリプトのシェイピングは、DelphiとC++Builder向けの標準HotPDF Componentに同梱されている。製品ページには上記で名前を挙げたUnicodeフォントとシェイピング呼び出しの完全なAPIリファレンスが掲載されている