技術記事

DelphiからPDFにCID Type 2フォントを埋め込む

PDFium ComponentはAddCidType2TextによってグリフレベルでPDFページにテキストを書き込み、自分で用意したTrueTypeデータをCID Type 2フォントとして埋め込む。各グリフインスタンスに連番のCIDを割り当て、明示的なCID-to-GIDマップとToUnicode CMapを生成し、同じベースライン上のグリフを1つのネイティブテキストオブジェクトへまとめる。任意でサブセット化を行うことでファイルを小さく保ち、サブセット化が失敗した際の挙動には明確な方針がある

グリフレベルの書き込みは、テキストがすでにシェイピング済みである場合に必要となるものである。アラビア語、デーヴァナーガリー、そして文脈依存の字形を持つあらゆる文字体系は、もはや文字と一対一に対応しないグリフ識別子の列を生成するため、文字列とフォント名を受け取るAPIではその結果を表現できない。グリフと位置を直接渡すことだけが、正しくシェイピングされた複雑な文字体系のテキストをPDFへ配置する唯一の方法である

Delphi で AddCidType2Text が各整形グリフインスタンスに連続 CID を割り当て、CIDToGIDMap と ToUnicode CMap を書き、実行をきれいに抽出できる 1 つのページテキストオブジェクトへマージ
各グリフインスタンスが自身の CID を取り、フォント辞書が CID をグリフ id に、さらに Unicode へ対応付け、ランは 1 つのテキストオブジェクトとしてページに着地します。抽出すれば意図したテキストに戻ります

なぜすべてのグリフインスタンスが独自のCIDを持つのか

魅力的に見える最適化は重複排除である:異なるグリフ識別子ごとに1つのCIDを割り当て、そのグリフが現れるたびに再利用する。それはより小さいフォントを生成するが、テキスト抽出を破壊する。同じグリフが、場所によって正当に異なるUnicode内容に対応し得るからである

合字グリフが分かりやすい例である。同じ「ffi」というグリフが、ある単語では3文字を表し、別のシェイピング判断の結果、他の場所では別のものを表すことがある。ToUnicodeマップはCIDをキーとしているため、共有されたCIDは1つのマッピングしか持てず、その争いに敗れた方のテキストは抽出不能になる

そのため各グリフインスタンスは独自のCIDを持ち、各マッピングは複数のUTF-16コード単位を自由に持てる。意味のあるテキストを持たないグリフ——装飾要素や位置調整専用のグリフ——は、ゼロ幅スペースであるU+200Bへ明示的にマッピングされるため、どのCIDにも欠落ではなく観測可能な抽出結果が存在する

uses
  PDFium;

var
  Pdf: TPdf;
  Glyphs: TPdfCidGlyphs;
  Options: TPdfCidFontOptions;
  Report: TPdfCidFontReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'label.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;

    // One entry per shaped glyph: the glyph id, the text it represents,
    // and its advance and offsets in text space
    SetLength(Glyphs, 3);
    Glyphs[0].GlyphID := 402;  Glyphs[0].UnicodeText := 'ffi';
    Glyphs[0].Advance := 18.4;
    Glyphs[1].GlyphID := 71;  Glyphs[1].UnicodeText := 'c';
    Glyphs[1].Advance := 9.8;
    Glyphs[2].GlyphID := 74;   Glyphs[2].UnicodeText := 'e';
    Glyphs[2].Advance := 9.2;

    Options := TPdfCidFontOptions.Default;   // subset preferred
    Options.VerifyExtraction := True;

    if Pdf.AddCidType2Text(LoadFileBytes('NotoSans.ttf'), Glyphs,
         11, 72, 700, Options, Report) then
      Writeln(Format('%d glyphs, %d unique, font %d -> %d bytes, match=%s',
        [Report.GlyphCount, Report.UniqueGlyphCount,
         Report.OriginalFontBytes, Report.EmbeddedFontBytes,
         BoolToStr(Report.ExtractionMatches, True)]));
  finally
    Pdf.Free;
  end;
end;

サブセット化、そして戻り値だけでは分からない検証

実際に使われているグリフだけにフォントをサブセット化することは、300KBを埋め込むことと12KBを埋め込むことの違いを生み、複数のフォントを持つドキュメントでは、ファイルをメールで送れるかどうかを左右する。プラットフォームのサブセット化経路はグリフリストを直接受け取るため、呼び出し側がすでに使用中のすべてのグリフ識別子を把握しているこのAPIに完璧に適合する

それが与えてくれないのは確信である。サブセット化の呼び出しは成功を報告しつつ使い物にならない出力を返すことがあるため、このコンポーネントは結果を受け入れる前に3つの性質を検証する:出力は元より小さくなければならない、有効なsfntとして再パースでき読み取り可能なmaxpテーブルを持たなければならない、そして要求された最大の元の識別子までのグリフを保持していなければならない。いずれかに失敗すればそのサブセットは拒否される

その後どうなるかは呼び出し側の方針次第である。既定であるpcfemSubsetPreferredの下では、拒否されたサブセットはフォント全体の埋め込みへフォールバックするため、ページは正しいまま単に大きくなるだけである。pcfemSubsetRequiredの下では、ページが変更される前に操作自体が失敗し、これはサイズに制約のあるパイプラインが望む挙動である。pcfemFullはサブセット化を完全にスキップする。どの経路が取られたかは、レポートのSubsetAttemptedSubsetAppliedUsedFullFontFallbackSubsetErrorCodeから分かる

Delphi における CID Type 2 フォントサブセット化は、受理前にサイズ、sfnt 完全性、グリフカバレッジの 3 検証チェックを通過。拒否されたサブセットは、完全フォントフォールバックかハード失敗の pcfem ポリシーに従う
サブセットが受け入れられるのは、サイズ、sfnt、グリフカバレッジの各チェックを通った後だけです。拒否されたサブセットはその後、呼び出し側ポリシーに従います。完全フォントへフォールバックするか、ページが変更される前に失敗するかです

偽陽性を生み得ない検証

VerifyExtractionを有効にすると、このコンポーネントは自身が書き込んだものが読み戻せることを確認する。それを行う素朴な方法はページ全体を抽出して期待する文字列を検索することだが、それは誤りである:新しいテキストが誤って書き込まれていても、すでにその文字列を含んでいたページはチェックに通ってしまう

代わりにテキストページが再構築され、この呼び出しによって挿入されたオブジェクトハンドルが挿入順に個別に読み取られ、連結される。結果は期待するテキストと比較され、レポートはブール値とともに両方の文字列を公開するため、単に検出するだけでなく不一致を診断できる

開発中、および抽出可能性が要件となるあらゆるパイプライン——検索可能なアーカイブ、アクセシビリティ準拠、下流のテキストマイニング——ではこれを有効にすること。コストは呼び出しごとに1回のテキストページ再構築であり、それがタイトなループでデフォルトで有効になっていない理由である

予算とアトミックな失敗

フォントのバイト数、グリフインスタンス数、Unicodeコード単位数はそれぞれ確保前に上限が設けられ、フォント形式、TTCインデックス、グリフ識別子の範囲、ジオメトリの値は何かが書き込まれる前に検証される。ジオメトリは有限でなければならないが、これは当たり前に聞こえるものの、不正な形式のフォントからシェイピングエンジンがNaNの送り幅を渡してくるまではそう感じられないものである

失敗はページレベルでアトミックである。フォントのロード、オブジェクトの書き込み、コンテンツ生成、抽出検証のいずれかが失敗すると、その呼び出しで挿入されたすべてのオブジェクトが逆順で削除され、ページのコンテンツが再生成される。失敗した呼び出しは、テキストランが半分だけ書き込まれた状態ではなく、元のままのページを残す

これがテキストパイプラインのどこに位置づけられるか

役割分担は明確に述べておく価値がある。シェイピング——文字を位置付きグリフへ変換すること——はこのAPIの仕事ではなく、シェイピングエンジンに属する仕事であり、このコンポーネント自身の双方向テキストと複雑な文字体系のサポートについては絵文字、CJK、サロゲートペアの扱いで扱われている。このAPIは、シェイパーが生成したグリフランを受け取った後に呼び出すものである

文脈依存のシェイピングを持たない通常のラテン文字テキストには、より単純な文字列ベースのテキストAPIが適切なツールであり、より小さいコードで済む。シェイピング済みの出力を持つ場合、グリフ識別子の正確な制御が必要な場合、あるいは名前で解決するのではなく自分が保持するバイト列からフォントを埋め込む必要がある場合には、CID Type 2書き込みに手を伸ばすとよい——フォント解決の代替手段はPDFフォント置換の制御で説明されているプロバイダー機構である

デプロイに関する注意点を1つ。フォントの埋め込みは技術的な問題であると同時にライセンスの問題でもある。フォントによって、埋め込みがそもそも許可されているか、閲覧専用でのみ許可されているか、編集用にも許可されているかが異なる。ライブラリは渡されたバイト列をそのまま埋め込むため、ライセンスの確認はファイルフォーマットの責任ではなく自分自身の責任である

グリフレベルのテキスト書き込み、フォント提供、テキスト抽出は、Delphi、C++Builder、Lazarusにおいて同じページモデルを共有している。完全なAPIはDelphi向けPDFium Componentページに記載されている