技術記事

HotPDF CompressDocumentのコンパクトフォントサブセット

HotPDFのTHotPDF.CompressDocumentは、BeginDocにコンポーネントが書ける最小のロスレスPDF、つまり最大レベルのFlateDecode、オブジェクトストリーム付きのクロスリファレンスストリーム、フォントサブセット化、そして明示的な/CIDToGIDMapの後ろで保持グリフを再番号付けするコンパクトフォントサブセットを、1つのスイッチで実現します。EndDocはその後、自分の設定を戻します。ArialとSimSunの3ページのテスト文書は、10.2 MBから20 KBへ、描画結果同一のまま落ちました

CompressDocumentが実際にオンにするもの

CompressDocumentは、1つのドキュメントの間、6つのライター設定とオブジェクトストリームの上限を上書きし、後ですべて復元します。BeginDocで、PDFバージョンが確定する前に、HotPDFはあなたの値を記録し、CompressionをcmFlateDecodeへ、CompressionLevelをclMaximumへ設定し、EnableFontSubsettingとCompactFontSubsettingをオンにし、UseXRefStreamとUseObjectStreamsを有効にします(ISO 32000-1 §7.5.7と§7.5.8)。オブジェクトストリームにはPDF 1.5が必要なので、ロックされていない古いVersionは1.5へ引き上げられます。PDF/A-1は両方の構造を禁じるため、PDF/A-1の文書はクラシックなクロスリファレンステーブルを保ち、Flateとフォントの処理だけを受けます。画像は埋め込んだとおり、一切触られません

DelphiにおけるHotPDF CompressDocumentのライフサイクル図。BeginDocはライター自身の値を記録し、CompressionとUseObjectStreamsを含む6つの設定を1つの文書の間だけ上書きし、EndDocはCompressDocumentプロパティ自体をTrueのままにしながら、最も外側のfinallyで借りた値をすべて復元します
6つのライター設定とオブジェクトストリーム上限は、正確に1つの文書の間だけ借りられ、EndDocの実行時に返されます。失敗したレポートが、コンポーネントを最大圧縮のまま貼り付けておくことはありません
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'invoice-2026-1042.pdf';
    Pdf.CompressDocument := True;    // BeginDocで適用、EndDocで解除
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-1042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

復元はEndDocの最も外側のfinallyで起こります。レポートの途中で例外が起きても、次のジョブのために長命なコンポーネントが最大圧縮に貼り付いたまま、ということがありません。CompressDocumentプロパティ自体はTrueのままです。戻るのは借りた6つの設定だけです。バージョンの扱いはもっと慎重です。HotPDFが1.5への引き上げを取り消すのは、文書がまだ1.5で終わっているときだけです。実行中に別の機能がファイルを1.6へ押し上げた場合(たとえば埋め込みOpenTypeフォント)、高いバージョンはそのまま残ります。圧縮なしの場合と同じ挙動です

コンパクションなしではフォントサブセットがまだ大きい理由

クラシックなTrueTypeサブセットは、描かないアウトラインを落としますが、すべてのグリフIDを元の位置に保持します。その番号付けこそが重さの正体です。コンテンツストリームは元のGIDと等しいCIDを示すので、サブセットは、保持する最高グリフまでの全スロットについて、空であってもlocaのオフセットとhmtxのエントリーを保持しなければなりません。ラテン書体ならこのオーバーヘッドはノイズです。しかしSimSunのようなCJK書体、つまり漢字が非常に大きなグリフテーブルの深いところに座っている書体では、漢字2文字がフォント全体サイズのテーブルを引きずります。どのグリフが生き残るかは、整形グリフのフォントサブセット閉包ルールが決めます。コンパクションは、生き残りがいくらかかるかの話です

CompactFontSubsettingは保持グリフをゼロから始まる密な範囲へ再番号付けし、CIDFontへ/CIDToGIDMapストリームを書きます。ISO 32000-1 §9.7.4.2はこれを、CIDでインデックスされた2バイトGIDのテーブルと定義します。このテーブルがトリックのすべてです。コンテンツストリーム、/Wの幅配列、ToUnicode CMapはすべて元のCIDを保持するので、すでに書かれたものは何も変える必要がありません。CIDからグリフへの検索だけがマップへ移ります。この機能の動機になったテストでは、SimSunの2文字が24.8 KBのフォントデータから3.1 KBになりました

疎なHotPDFフォントサブセットとコンパクト出力の比較。疎な方は保持する最高GIDまでのすべての元グリフIDについてlocaとhmtxのエントリーを保持します。CompactFontSubsettingの出力は保持グリフをゼロから密に再番号付けし、コンテンツストリーム、/W、ToUnicodeを変えないまま、CIDをCIDToGIDMapストリーム経由で割り当てます
再番号付けはコストをフォントプログラムから1つの小さなマップストリームへ移します。SimSunの2文字は、すでに書かれたコンテンツの1バイトにも触れずに、24.8 KBから3.1 KBへ落ちました

コンパクションには堅い限界があり、失敗ではなく静かにグレードダウンします。HotPDFがコンパクトサブセットを組み立てるのはType 0 TrueType書体だけです。サブセット化オンでSetFontされたものと、RegisterUnicodeTTFで登録された書体の両方です。シンプルなTrueTypeフォントは、フォントプログラム内のcmapでグリフを見つけます。再番号付けはこれを壊すので、疎なサブセットを保ちます。OpenType-CFF書体にもコンパクト経路はありません。失敗したコンパクト構築は、raiseする代わりに疎なサブセットへフォールバックします。プロパティはデフォルトでオフなので、既存の出力はバイト単位で同一のままです。一方PDF/Aの下では、登録されたUnicode書体は常にコンパクトサブセットを得ます

Pdf.EnableFontSubsetting := True;
Pdf.CompactFontSubsetting := True;   // CompressDocumentなしでも使える
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('SimSun', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, WideString('Total: '#$4E2D#$6587));
Pdf.EndDoc;

パックドライターがファイル構造をどう締めるか

フォントとストリームが小さくなれば、辞書とクロスリファレンスデータが残り最大のコストになります。CompressDocumentの背後にあるオブジェクトストリームライターは、これらも削ります。コンテナ形式そのものはオブジェクトストリームとインクリメンタル更新のガイドが扱います。圧縮経路はその上に4つの改良を加えます:

  • ISO 32000-1 §7.2.2に沿ったコンパクト構文。スペースは、そうしないと通常文字として連なってしまう2つのトークンの間にだけ書かれます。つまり/Type /Pageが/Type/Pageになります
  • クロスリファレンスストリームのフィールドは§7.5.8.2が許す任意の幅を取ります。16 MB未満のファイルは各オフセットを4バイトでなく3バイトで格納します
  • 各オブジェクトストリームには通常の100でなく最大250オブジェクトが入ります。ただしConfigureAdaptiveObjectStreamPackingで自分の上限を設定した場合はそちらが優先です
  • ファイルが暗号化されていないときは、CatalogとInfo辞書もオブジェクトストリームへ詰め込みます。暗号化出力では最上位レベルに保たれます

コンパクト構文には、ライターを拡張するなら知っておく価値のある罠が付いてきます。署名は、ファイルを書いた後、バイト列からリテラルのプレースホルダー/ByteRange (と/Contents <を探して値を埋めます。コンパクト綴りはこれらを/ByteRange(と/Contents<へ変え、検索は決して見つけられません。そこで署名辞書(Type SigやDocTimeStamp、FT Sig)と暗号化辞書は、スペース付きのレイアウトを保ちます。関連する欠陥がv2.766.41より前のビルドに影響しました。すべてのオブジェクトストリーム保存、CompressDocumentを含む、が2行の%PDF-ヘッダー行で始まっていたのです。厳格なバリデーターが出力にフラグを立てるならアップグレードしてください

ロード済みのPDFも圧縮できるか

できます。オプションオーバーロードのCompressLoadedDocument(Options, Info)が、既存のファイルに同じロスレスの手順を実行します。THPDFLoadedDocumentCompressionOptions.Defaultでは、未使用のページリソースを削除し、同一のフォントとフォームをマージし、埋め込みフォントをコンパクトサブセット付きでサブセット化し、フィルターなし、Flate、LZW、ASCII、RunLengthのストリームを、結果が小さくなるときだけFlateで再圧縮し、次の保存でオブジェクトストリームを使うようにします。HighRatioFlateはデフォルトでオフ、オブジェクトストリームはPDF/A-1とインクリメンタル保存ではスキップされます。引数なしのCompressLoadedDocumentオーバーロードは、より古く狭い呼び出しで、圧縮されていないストリームをFlate圧縮するだけです

DelphiにおけるHotPDF CompressLoadedDocumentのフロー。呼び出しは未使用のページリソースを削除し、同一のフォントとフォームをマージし、埋め込みフォントをコンパクトサブセットでサブセット化し、結果が小さくなるときだけストリームをFlateで再圧縮し、次の保存のためにオブジェクトストリームをオンにします。署名フィールドはRefusedBySignaturePolicyを引き起こし、ファイルを無傷のまま残します
すべてのステップは署名が覆うバイトを書き直します。だから明示的に無効化を許さない限り、文書全体が拒否されます。Info.BytesSavedが合計するのは、リソース、フォント、ストリームの作業だけです
var
  Doc: THotPDF;
  Options: THPDFLoadedDocumentCompressionOptions;
  Info: THPDFLoadedDocumentCompressionInfo;
begin
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    Doc.LoadFromFile('quarterly-report.pdf');
    Options := THPDFLoadedDocumentCompressionOptions.Default;
    Doc.CompressLoadedDocument(Options, Info);
    if Info.RefusedBySignaturePolicy then
      Writeln(Format('Left untouched: %d signature fields', [Info.SignatureCount]))
    else
    begin
      Writeln(Format('Compact fonts: %d, stream bytes saved: %d',
        [Info.Fonts.CompactSubsetFontCount, Info.BytesSaved]));
      Doc.SaveLoadedDocument('quarterly-report-compact.pdf');
    end;
  finally
    Doc.Free;
  end;
end;

ロード済みの経路では2つの境界が効きます。すべてのステップは署名が覆うバイトを書き直すため、署名フィールドを持つ文書は丸ごと拒否されます。呼び出しは0を返し、RefusedBySignaturePolicyを設定し、何も変えません。AllowSignatureInvalidationを設定した場合に限り、その後Info.SignaturesInvalidatedが何を諦めたかを伝えます。コンパクションはこの経路では作成経路より保守的です。HotPDFがコンパクト化するのは、Identityの/CIDToGIDMap、つまりCIDがGIDと等しいCIDFontType2フォントだけが使うフォントプログラムだけです。既存のマップストリームや/CIDSet、COLR、sbix、CBDT、SVGといったカラーグリフテーブルを持つプログラムはスキップします。コンパクト再構築がカラーレイヤーを落としてしまうからです。さらにInfo.BytesSavedはリソース、フォント、ストリームのステップだけを合計します。オブジェクトストリームの利得は、ファイルが書かれるときに現れます

実運用で期待できる結果

利得は、ファイルのどれだけが非圧縮の構造と過大なフォントデータかで決まります。ページ数ではありません。ArialとSimSunの3ページサンプルは、CompressDocumentで生成すると10.2 MBから20 KBへ、非圧縮の元ファイルをロードしてCompressLoadedDocumentに通すと10.2 MBから19.8 KBへ縮みました。どちらも描画は同一です。すでにコンパクトなPDFはほとんど動きません。リグレッション集合では、そのようなファイルは元サイズの-0.07%から+0.06%の範囲でしか変わりませんでした。写真中心のファイルの利得はわずかです。どちらの経路も画像データには触れないからです

毎晩同じCJKレポートを生成するなら、コンパクトサブセットをディスク上の永続フォントサブセットキャッシュと組み合わせてください。サブセット化作業が実行ごとに繰り返されなくなります。圧縮出力のdiffはバイトでなくオブジェクトコンテンツで取ってください。1つのフィールドの変更が、オブジェクトストリーム全体を再Flateするからです。プロパティとレコードのリファレンス全文は、HotPDF Delphi PDF component product pageにあります