技術記事

HotPDF EndDoc におけるフォントサブセット化と終了処理の順序

レポートを生成し、TrueType フォントを埋め込み、出力は試したすべてのビュアーで正しく開きます。グリフは正しく、テキストは選択可能、ファイルは妥当です。ただ 1 つ、おかしいのはサイズです。ラテン文字を数十個使っただけの文書が、350 KB 丸ごとのフォントを抱えています。中国語の段落を 1 段印刷した文書が、本来必要なはずの半メガバイトのスライスではなく、14 MB の CJK フォントを抱えています。例外は上がらず、警告はログに残らず、ファイルは検証を通ります。順序を誤った終了処理ステップが外から見るとどう見えるか、これがその姿です。何も失敗せず、証拠は大きすぎる 1 つの数字だけです

これを教訓として書き残す価値があります。間違いの形が一般的だからです。どんな文書エンジンにも、書き出す直前にオブジェクトを変更する終了処理ステージがあり、そのステージの正しさは、シリアライズとの相対的なステップ順序だけに挂かっています。1 つのステップでも書き出しの逆側に置けば、何もせず、静かに過ぎていきます

フォントサブセット化が果たすべき役割

サブセットフォントとは、文書が実際に使う TrueType ファイルの部分だけを切り出したものです。ISO 32000-1 の §9.9 は、埋め込まれたフォントプログラムがフォントディスクリプタから参照されるストリームに乗ることを述べており、TrueType プログラムでは、そのストリームが非圧縮バイト数を示す /Length1 付きの /FontFile2 です。サブセット化は glyf と loca テーブルを書き換えて文書が参照するグリフだけを含め、グリフ識別子を付け直し、仕様が求めるとおり、ABCDEF+ のような 6 文字のタグを /BaseFont 名の先頭に付けてサブセットである印を付けます。10 から 15 KB に収まるラテン書体は、引き締まった PDF と、1 つの見出しのために書体一式を同梱する PDF との違いになります

これが起こる時点が重要です。サブセット化は、ディスク上のバイトに後から適用する変換ではありません。インメモリのオブジェクトグラフを編集します。/FontFile2 ストリームの内容を縮め、/Length1 を修正し、/BaseFont 文字列を書き換えます。これらはすべて、シリアライザーがグラフを歩いてバイトを吐くときに揃っていなければなりません。編集がバイト書き出しの後に着地すれば、誰にも読まれないオブジェクトを更新するだけです

HotPDF のフォントサブセット化を示す図。TrueType 書体全体を使用グリフの閉包へ通し、メモリ上の FontFile2、Length1、BaseFont オブジェクトを更新する前に glyf と loca を書き換えます
サブセット化は、文書が実際に使うグリフを中心に glyf と loca を組み立て直し、その後でメモリ上の /FontFile2、/Length1、/BaseFont の接頭辞を更新します。1 バイトが書かれる前に、そのすべてが揃っていなければなりません

症状と、何も文句を言わない理由

サブセット化が書き出しの逆側に着地すると、症状は、診断なしのフルフォントです。Unicode TrueType フォントを登録し、普通の文書を生成すると、埋め込みフォントオブジェクトがソースの .ttf ファイルと同じ長さで出てきます。/BaseFont 名に 6 文字のサブセット接頭辞はありません。グリフを 10 個使う実行と 1 万個使う実行の間で、出力サイズはちっとも縮みません

エラーが何もないことこそ、この種の間違いを高くつける部分です。誤った時点で走るサブセット化ルーチンは、それでも走ります。蓄積されたコードポイントの使用状況を歩き、完全に正しいサブセットを組み立て、メモリ上のオブジェクトグラフへ適用します。内部的には仕事は完了し、呼び出しはきれいに戻ります。間違っているのはただ 1 点、編集したオブジェクトグラフが、もう書き出される対象ではないことです。ライターはすでに終わっています。呼び出し側から見れば、文書は何事もなく生成され、保存されたのです。それこそ、静かな失敗が与える印象です

終了処理の順序でサブセット化はどこに位置するか

HotPDF では、締め処理は EndDoc の内側で行われます。サブセット化ステップは BuildAndApplyUnicodeFontSubset という名前の内部ルーチンです。文書ごとの使用コードポイントの集合を読み(これは、テキスト出力経路がグリフを描くたびに埋めていくビットマップに保持されています)、使用された各コードポイントをキャッシュ済みのコードポイントからグリフへの対応表で実際のグリフ識別子へ写像し、その閉包を中心にフォントプログラムを書き直します。Unicode TrueType フォントが登録されていると、出力経路は描く文字ごとに使用コードポイント集合へビットを立てるため、文書を閉じる時点で、エンジンはサブセットがどのグリフを保持すべきかを正確に知っています

trap は、その呼び出しの位置です。BuildAndApplyUnicodeFontSubset が、SaveToStream や SaveToFile がすでに文書をシリアライズし終えた後に呼ばれると、サブセット化による /FontFile2 への編集、修正された /Length1、6 文字の /BaseFont 接頭辞は、すべてすでにバイト化されたオブジェクトグラフに対して計算されます。正しい順序は、サブセット化を先に走らせ、シリアライズを後に行うことです。ライターはサブセット化済みフォントを吐きます。これを正すのは、1 つの呼び出しの位置の問題であり、それだけです

HotPDF のタイムライン比較図。BuildAndApplyUnicodeFontSubset が SaveToStream の後に走る EndDoc の順序と、サブセット化がシリアライズに先行する正しい順序
1 つの呼び出しを書き出しの前に動かすだけで、呼び出し側のコードは何も変わらないのに、保存されるバイトがフルフォントなのか真のサブセットなのかが決まります。シリアライズの逆側に置かれたステップは、きれいに戻り、何もしません
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
    Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
    Pdf.EndDoc;                 // サブセット化はここで走る。書き出しの前に
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

どれも呼び出し側のコードには現れません。Unicode TrueType フォントを登録すれば、サブセット化は既定で有効です。フォントを登録し、文書を始め、描画し、締めれば、サブセットは使われたグリフから組み立てられます。バイトがメモリを離れる前に

1 つの配置ミスが 1 つのカテゴリーである理由

脚注ではなく教訓として書く価値があるのは、EndDoc が締めステップのリストを吐き、その 1 つ 1 つが書き出しとの相対位置に敏感だからです。フォントのサブセット化はその 1 つです。PDF/A 出力には、サブセットに存在するグリフ識別子を正確に列挙する /CIDSet ストリームが必要です。ISO 19005 が課す制約で、バリデーターが埋め込みプログラムとフォントディスクリプタの主張の一致を確認できるようにするためのものです。このストリームも同じ終了処理ウィンドウで吐かれ、サブセットが先に組み立てられていることに依存します。PDF/UA-1 は、ISO 14289-1 の §7.18.3 により、注釈を持つすべてのページが値 /S の /Tabs を宣言することを求めており、EnsurePDFUATabsOnAnnotatedPages という名前の内部ルーチンが同じステージでこのキーを押し込みます。出力インテントのチェックもここで走ります

サブセット化を無効化するのと同じ順序の過ちは、注釈付きページの PDF/UA タブ順序キーも落とします。そのステップも同じウィンドウに座っているからです。veraPDF と PAC は、欠けた /Tabs /S を Matterhorn プロトコルのチェックポイント 21-001 違反として報告します。つまり 1 つの配置ミスは、ファイルサイズを膨らませるだけではなく、同時にアクセシビリティ適合要件を静かに壊します。エラーは同じように何もありません。これが終了処理ステージの危険です。ステップたちは前提条件を共有しており、順序の 1 つの間違いが、すべての呼び出しが成功を報告したまま、そのうちのいくつかを一気に止められます

HotPDF の 4 つの終了処理ステップが EndDoc の内側に並ぶ図。フォントのサブセット化、CIDSet の出力、PDF/UA の Tabs の押し込みが含まれ、いずれも 1 バイトが存在する前にシリアライザーへ流れ込みます
サブセット化、/CIDSet の出力、/Tabs /S の押し込みは、シリアライズに先立つ 1 つの終了処理スロットを共有します。1 つの配置ミスで、全ルーチンが成功を報告したまま、フォントサブセットとアクセシビリティ要件の両方が落ちます

静かな出力失敗を実際にどう捕まえるか

例外を上げない失敗は、プログラムを実行しても捕まりません。出力を検査し、入力が生むはずだったものと比較して、初めて捕まります。フォントのサブセット化なら、チェックは具体的です。出力ファイルサイズを大まかな期待値と比べる。ほんの数グリフに触れた文書が、書体一式のサイズを持っているはずがありません。埋め込みフォントオブジェクトを開いてバイト長を読む。ラテン書体のサブセット化された /FontFile2 は、ソースファイルの小さな断片です。/BaseFont 名を読み、6 文字の接頭辞があることを確認します。接頭辞がなければ、サブセットが適用されなかったことの直接的なサインです

var
  Pdf: THotPDF;
  Output: TMemoryStream;
begin
  Output := TMemoryStream.Create;
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
      Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
      Pdf.EndDoc;
      Pdf.SaveToStream(Output);
    finally
      Pdf.Free;
    end;
    // 約 700 KB の書体から数グリフ取っただけで、数百 KB のストリームになってはならない
    if Output.Size > 100 * 1024 then
      raise Exception.Create('Font subset did not shrink the output');
  finally
    Output.Free;
  end;
end;

PDF/A 出力なら、チェックはさらに鋭くなります。バリデーターが仕事をしてくれるからです。適合レベルを設定し、結果を veraPDF に通します。欠けた /CIDSet や、ディスクリプタと合わないサブセットは、自分の目で気づくための課題として残されるのではなく、失敗した条項として報告されます。この終了処理を駆動する適合スイッチは、文書のプロパティです。PDFACompliance は PDF/A-2 レベル B なら '2B' のような文字列を受け取り、PDFUACompliance はタグ付き PDF とタブ順序の要件を入れる boolean です

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 レベル B。/CIDSet の出力を駆動する
  Pdf.PDFUACompliance := True;    // 注釈付きページへ /Tabs /S を押し込む
  Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
  Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
  Pdf.EndDoc;
  Pdf.SaveToFile('Report_PDFA.pdf');
finally
  Pdf.Free;
end;

工学の教訓

ここからは 2 つの規則が落ちてきます。第 1 に、オブジェクトを変更する終了処理ステップは、それらのオブジェクトがシリアライズされるより前に走らなければなりません。文書エンジンの締めステージは、シリアライズが最後の動作である順序付きパイプラインとして読むべきであり、いくつかの動作のうちの 1 つではありません。第 2 は、最も忘れやすいものです。出力ステップにとって、エラーの不在は成功の証拠ではありません。正しいサブセットを組み立て、間違った、すでに書かれたグラフへ適用するルーチンは、何も問題を報告しません。それ自身の視点では、問題は無かったからです。検証は、戻り値ではなく、成果物を見なければなりません。出力サイズを確認し、埋め込みフォントのバイト長と /BaseFont 接頭辞を読み、PDF/A 出力は veraPDF に判定させます。欠けた /CIDSet が、静かな不足を名前のある失敗へ変えてくれるのです

フォント処理の生産側、つまりレポート出力のために書体を登録し埋め込む方法は、レポート出力におけるフォントと画像の記事で扱っています。検証側、つまりこれらの終了処理ステップを規格に対してチェックする側面は、PDF/A と PDF/UA 検証の解説で扱っています。どちらも、ここで述べたサブセット化と適合作業と対になり、このブログの別の記事で扱うロード、編集、暗号化、署名の API と並んで、Delphi と C++Builder 向けの HotPDF Delphi Component の一部として出荷されます