Technical Article

フォントのサブセット化を警告なしに無効化していたEndDocのバグ

レポートを生成し、TrueTypeフォントを埋め込むと、出力ファイルはどのビューアでも正しく開きます。グリフは正しく、テキストは選択可能で、ファイルも有効です。唯一の問題はそのサイズです。数十文字のラテン文字しか使用していないドキュメントに、350 KBのフォント全体が格納されてしまいます。中国語の段落を1つ印刷したドキュメントには、本来必要な0.5メガバイト程度のスライスではなく、14 MBのCJKフォントが丸ごと保持されます。例外は発生せず、警告もログに記録されず、ファイルの検証も通過します。これが、順序の誤ったファイナライズステップが外部からどのように見えるかです。何も失敗せず、唯一の証拠は大きすぎるファイルサイズという数値だけです。

この問題の原因となったバグは、HotPDFの特定のリリースラインに存在し、その後修正されました。今回の件は、不具合の告知としてではなく、教訓として記録する価値があります。なぜなら、このミスの形は普遍的だからです。どのようなドキュメントエンジンでも、書き込みの直前に対象オブジェクトを変更するファイナライズステージが存在し、そのステージの正しさは、シリアライズに対する各ステップの実行順序に完全に依存します。書き込み処理の後に1つでもステップが実行されてしまうと、警告なしに何も行われなくなります。

フォントのサブセット化が本来行うべきこと

サブセットフォントとは、ドキュメントで実際に使用されるTrueTypeファイルの一部です。ISO 32000-1 §9.9には、埋め込みフォントプログラムがフォント記述子によって参照されるストリーム内にどのように格納されるかが記述されており、TrueTypeプログラムの場合、そのストリームは未圧縮のバイト数を示す/Length1を持つ/FontFile2になります。サブセット化は、glyfおよびlocaテーブルを書き換えてドキュメントが参照するグリフのみを含め、グリフ識別子を再割り当てし、スペックの要求通りにフォントをサブセットとしてマークするために/BaseFont名の前にABCDEF+のような6文字のタグを付加します。ラテン文字の書体を10〜15キロバイトにサブセット化することは、スリムなPDFと、たった1つの見出しのために書体全体を同梱するPDFとの境界線になります。

これが実行されるタイミングが重要です。サブセット化は、ディスク上の既存のバイトデータに適用する変換ではありません。インメモリのオブジェクトグラフを編集します。すなわち、/FontFile2のストリームコンテンツを縮小し、/Length1を修正し、/BaseFontの文字列を書き換えます。シリアライザがグラフをたどってバイトを出力する時点で、これらすべてが整っていなければれません。バイトデータが書き込まれた後に編集が行われても、誰も読み取ることのないオブジェクトを更新することになってしまいます。

症状と、エラーが検出されなかった理由

報告された挙動は、エラー診断なしに出力ファイルにフルフォントが含まれるというものでした。UnicodeのTrueTypeフォントを登録して通常のドキュメントを作成したユーザーは、埋め込まれたフォントオブジェクトが元の.ttfファイルと同じ長さであり、/BaseFont名に6文字のサブセットプレフィックスが含まれていないことに気づきました。出力サイズは、10個のグリフを使用した実行と10,000個のグリフを使用した実行の間でまったく縮小されませんでした。

いかなるエラーも発生しないことこそが、この種のバグの対応コストを高くする要因です。誤ったタイミングで実行されるサブセット化ルーチン自体は動作し続けます。蓄積されたコードポイントの使用状況をたどり、完全に正しいサブセットを構築し、メモリ内のオブジェクトグラフに適用します。内部的には処理が完了し、呼び出しは正常に返されます。唯一の誤りは、編集されたオブジェクトグラフが書き込み対象から外れていることです。なぜなら、ライターはすでに処理を完了しているからです。呼び出し元の視点からは、ドキュメントは何のトラブルもなく生成され保存されたように見え、これこそが警告なしの失敗が与える印象そのものです。

根本原因はファイナライズの順序

HotPDFでは、クローズ処理はEndDocの内部で発生します。サブセット化ステップは、BuildAndApplyUnicodeFontSubsetという内部ルーチンです。これは、テキスト出力パスがグリフの表示時に埋めていくビットマップに保持された、ドキュメントごとの使用済みコードポイントセットを読み取り、キャッシュされたコードポイント対グリフテーブルを介して、使用された各コードポイントを実際のグリフ識別子にマッピングし、そのクローザに沿ってフォントプログラムを書き換えます。Unicode TrueTypeフォントが登録されると、出力パスは描画するすべての文字について使用済みコードポイントセットのビットを設定するため、ドキュメントがクローズする頃には、エンジンはサブセットが保持すべきグリフを正確に把握しています。

欠陥は、SaveToStreamまたはSaveToFileがドキュメントをすでにシリアライズした後に、BuildAndApplyUnicodeFontSubsetが呼び出されていたことでした。サブセッターによる/FontFile2の編集、修正された/Length1、および6文字の/BaseFontプレフィックスはすべて、すでにバイトデータに変換されたオブジェクトグラフに対して計算されていました。修正は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;                 // subsetting runs here, before the write
    Pdf.SaveToFile('Report.pdf');
  finally
    Pdf.Free;
  end;
end;

順序を修正したことで、呼び出し側のコードを変更する必要はありません。Unicode TrueTypeフォントが登録されると、サブセット化はデフォルトで有効になります。フォントを登録し、ドキュメントを開始し、描画して終了するだけで、バイトデータがメモリから出力される前に、使用したグリフからサブセットが構築されます。

位置の誤った1つのステップがカテゴリ全体のバグとなる理由

これが単なる補足ではなく教訓として価値がある理由は、EndDocが一連のクローズステップを出力し、そのすべてのステップが書き込み処理に対する位置関係に非常に敏感だからです。フォントのサブセット化はその一例です。PDF/A出力には、サブセット内に存在するグリフ識別子を正確に列挙した/CIDSetストリームが必要です。これは、埋め込まれたプログラムがフォント記述子の主張と一致していることをバリデータが確認できるようにISO 19005が課している制約です。このストリームは同じファイナライズウィンドウ内で出力され、事前にサブセットが構築されていることに依存します。PDF/UA-1では、ISO 14289-1 §7.18.3に基づき、注釈を持つすべてのページが値/Sを持つ/Tabsを宣言することが要求され、EnsurePDFUATabsOnAnnotatedPagesという内部ルーチンが同ステージでそのキーを設定します。出力インテント(Output-intent)のチェックもこの段階で実行されます。

サブセット化を無効にしたのと同じ順序の誤りにより、注釈付きページのPDF/UAタブ順序キーも削除されていました。そのステップも書き込み処理の同じ誤った側に配置されていたためです。veraPDFおよびPACは、不足している/Tabs /SをMatterhornプロトコルチェックポイント21-001の違反として報告します。したがって、たった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;
    // A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
    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 Level Bを示す'2B'などの文字列を指定し、PDFUAComplianceはタグ付きPDFおよびタブ順序の要件を有効にするブーリアン値です。

Pdf := THotPDF.Create(nil);
try
  Pdf.PDFACompliance := '2B';     // PDF/A-2 Level B, drives /CIDSet emission
  Pdf.PDFUACompliance := True;    // stamps /Tabs /S on annotated pages
  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プレフィックスを確認し、/CIDSetの欠落による静かな欠陥を名前付きのエラーとして報告するveraPDFにPDF/A出力を検証させる必要があります。

フォント処理の出力側、すなわちレポート出力用にフォントがどのように登録および埋め込まれるかについては、レポート出力におけるフォントと画像に関する記事でカバーされています。これらのファイナライズステップが標準規格に準拠しているかどうかを確認する検証側については、PDF/AおよびPDF/UA検証のチュートリアルでカバーされています。どちらも、このブログの他の場所で扱われている読み込み、編集、暗号化、および署名APIと並んで、DelphiおよびC++Builder向けのHotPDF Componentの一部として提供されている、ここで解説したサブセット化および適合性検証の機能と深く結びついています。