技術記事

Delphi で PDF に埋め込み済みのフォントをサブセット化する

PDFium Component は、FPdfDocumentFontSubset ユニットの SubsetPdfDocumentFonts を通じて、既存の PDF にすでに埋め込まれているフォントをサブセット化します。この関数が埋め込み TrueType の /FontFile2 プログラムをより小さいものへ置き換えるのは、フォントリソース自体が文書が参照し得るすべてのグリフを証明するときだけです。グリフ領域を証明できないフォントは、そのままの状態で残されます

これが欲しくなる理由はたいてい、自分が作らなかったアーカイブです。古いジェネレータの請求書、明細書、レポートは、ファイルごとに完全な Arial や CJK フォント一式を運ぶことが多く、2 ページの文書ごとの数百 KB のフォントは、数百万文書にわたると急速に膨らみます。ソースからの再描画はめったに選択肢になりません。手元にあるのは PDF で、安全な唯一の問いは、1 ページも文字を失わずに各埋め込みフォントのどれだけを削れるかです

既存 PDF のサブセット化がグリフを失うリスクの理由

既存 PDF のサブセット化がグリフを失うリスクを持つのは、後処理ツールが元のライターがどのグリフを使ったか知らないからです。フォントを埋め込んだプロデューサは自分のテキストを知っていました。後から走るツールはその知識を再構築しなければならず、見落とした 1 つのグリフは、誰も見ないページで、顧客が見るまで誰も気付かない空の箱や .notdef の形になります

自明なアプローチは、すべてのコンテンツストリーム、すべての Form XObject、すべての注釈アピアランス、すべてのフィールドの既定アピアランスをパースし、各フォントで表示されたコードを収集し、それらのグリフを残すことです。ページとフォームを跨いで共有されるフォントリソース、/DA からビューアがアピアランスを再生成するフォームフィールド、スキャナがモデル化していない演算子列を持つコンテンツストリームに出会うまでは機能します。SubsetPdfDocumentFonts は意図的にコンテンツストリームを走査しません。グリフ領域を、PDF が各フォントについてどうしても運ばなければならないメタデータから導出します。/CIDToGIDMap、/W と /W2 の幅配列、/CIDSet ビットマップ、/ToUnicode CMap、単純フォントの /Widths 配列、/Encoding です。そのメタデータが領域を境界付けないとき、フォントは手つかずのまま残ります

Delphi から埋め込み TrueType フォントをサブセット化するとき PDFium Component がグリフ領域を証明する仕組み。/CIDToGIDMap、/W と /W2 の幅、/CIDSet、/ToUnicode、/Widths、/Encoding がコンテンツストリームを走査せずに領域を境界付け、証明できないフォントは手つかずで保存される
SubsetPdfDocumentFonts は何かを切り取る前にフォントメタデータを証明済みグリフ領域へ変え、リソースが領域を境界付けられないフォントは報告されて手つかずで残ります

元のグリフ ID を保ち、コンテンツストリームを手つかずに保つ

SubsetPdfDocumentFonts の背後にあるサブセットバックエンド TPdfTrueTypeSubsetBackend は、元のグリフ ID スロットをすべて保つため、コンテンツストリーム、CID、/CIDToGIDMap を書き直す必要が一度も生じません。このバックエンドは glyf と loca テーブルを組み立て直し、未使用のグリフすべてに長さゼロの loca 範囲を与え、maxp.numGlyphs を変えずに残します。グリフ 4711 はサブセット化後もグリフ 4711 です。文書が届くときだけアウトラインを持つようになるだけです

この選択は、完成した文書のサブセット化で最もリスクの高い部分を取り除きます。グリフの番号付け替えは、そのフォントを使うすべてのコンテンツストリーム内のすべての文字列と、写像ストリームと、グリフ ID をキャッシュするものすべてを書き直すことを意味します。ID の保持は少しのサイズを払います。loca が元のグリフごとに 1 エントリを保つからです。しかし TrueType プログラムの大半はアウトラインデータで、消えるのはそれです。複合グリフはその構成要素まで辿られるため、アクセント付き文字は基の文字と、その文字を組み立てるアクセントを保ちます。循環するか深すぎる複合参照は、壊れた閉包を作る代わりにフォントを失敗させます

Delphi で PDFium Component が埋め込み TrueType フォントをサブセット化した後に保たれるグリフ ID。グリフ 4711 は番号とアウトラインを保ち、未使用グリフは長さゼロの loca 範囲を受け取り、maxp.numGlyphs は変わらないため、コンテンツストリーム、CID、/CIDToGIDMap は決して書き直されない
グリフ ID がサブセットを生き延びるため、コンテンツストリーム、CID、/CIDToGIDMap は書き直しを要しません。サイズの勝利は落ちたアウトラインから来るのであって、番号の付け替えからではありません

改名されたフォントは、サブセットフォント名が大文字 6 文字とプラス記号で始まることを要求する ISO 32000-1 §9.6.4 にも従います。コンポーネントは 6 文字を新しいフォントプログラムの SHA-256 ダイジェストから導出し、既存の ABCDEF+ 接頭辞をまず剥がし、同じ文書内のフォント間の衝突を解決し、新しい名前を記述子の /FontName とフォント辞書の /BaseFont の両方に、CIDFont の Type0 親も含めて書き込みます

uses
  System.SysUtils, System.Classes, FPdfDocumentFontSubset;

procedure SubsetEmbeddedFonts(const SourceName, TargetName: string);
var
  Source, Target: TFileStream;
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Source := TFileStream.Create(SourceName, fmOpenRead or fmShareDenyWrite);
  try
    if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
      raise Exception.Create('Font subsetting failed: ' + Report.ErrorMessage);
  finally
    Source.Free;
  end;
  // 出力は常に完全な PDF:Report.Applied が True なら組み立て直された
  // ファイル、そうでなければソースのバイト単位のコピー
  Target := TFileStream.Create(TargetName, fmCreate);
  try
    if Length(Output) > 0 then
      Target.WriteBuffer(Output[0], Length(Output));
  finally
    Target.Free;
  end;
end;

SubsetPdfDocumentFonts は CIDFont のグリフ領域をどう証明するのか

SubsetPdfDocumentFonts は CIDFontType2 フォントのグリフ領域を、CID からグリフへの写像から証明し、その写像が追加の証拠の量を決めます。明示的な /CIDToGIDMap ストリーム(ISO 32000-1 §9.7.4.2)は、フォントがアドレスし得るすべてのグリフのリストそのものなので、ストリーム内のすべての GID が領域に加わります。65536 エントリを超える写像ストリームや、バイト数が奇数のものは、上限付きプロファイルに失敗し、フォントは保存されます

/CIDToGIDMap /Identity が難しいケースで、しかも現代のファイルで最も一般的です。Identity は CID が GID に等しいと言うだけで、文書がどの CID を使うかは何も語りません。そこでコンポーネントは一次証拠を要求します。プロデューサがメトリクスを宣言したグリフを CID エントリで示す、明示的な /W か /W2 幅配列(§9.7.4.3)、またはフォント記述子内の /CIDSet ストリーム(§9.8.3)、CID ごとに 1 ビット、最上位ビット優先のビットマップです。どちらもない素の Identity フォントは、理由 Identity CIDToGIDMap requires an explicit W array or CIDSet stream 付きで保存されます

/W を証拠に使う罠は /DW です。プロデューサは、幅が既定幅に等しいすべての CID を省略でき、よく最適化されたライターは最も一般的なグリフ幅についてまさにそれをします。領域が /W だけから来るなら、それらのグリフは静かにサブセットから消えます。この隙間を塞ぐため、コンポーネントは /DescendantFonts が CIDFont を指すすべての Type0 フォントを辿り、2 バイトコードが CID であるよう、その /Encoding が /Identity-H か /Identity-V であることを要求し、その /ToUnicode CMap(§9.10.3)のすべての beginbfchar と beginbfrange エントリのソースコードを領域へマージします。他のエンコーディングや、予算内でデコードできない /ToUnicode ストリームは、フォントを保存します

Delphi 向け PDFium Component フォントサブセット化における Identity CIDToGIDMap の証拠判定。明示的な写像ストリームはすべてのグリフをすでに列挙し、Identity は /W か /W2 の幅配列か /CIDSet ビットマップを要求し、/ToUnicode のソースコードが /DW 既定幅の盲点を塞ぐ
Identity の CIDToGIDMap は単独では使用状況について何も語りません。だからこそサブセッターは /W か /CIDSet の証拠を要求し、/W が /DW で省くグリフをカバーするよう /ToUnicode のコードをマージします

単純 TrueType フォント:Widths からグリフ ID へ

1 バイトの /Subtype /TrueType フォントは、コード範囲とエンコーディングを通じて証明され、それからフォントプログラム自身の cmap を通じてグリフ ID に解決されます。コンポーネントは 0 <= FirstChar <= LastChar <= 255 と、長さが正確に LastChar - FirstChar + 1 の /Widths 配列を要求します。短い、長い、数値でない配列はコード領域が証明されていないことを意味し、フォントはそのまま留まります

その範囲の各コードに加え、/Differences で名指しされたか /ToUnicode でカバーされたコードは、ISO 32000-1 §9.6.6 が述べるやり方で写像されます。/WinAnsiEncoding、/MacRomanEncoding、/StandardEncoding の下のコードは対応するテーブルを通って Unicode へ行きます。/Differences のグリフ名は、Adobe Glyph List、uniXXXX と uXXXX の名前、258 の標準 Macintosh グリフ名、フォント自身の post format 2.0 名前テーブルを通って解決されます。結果の値は、Unicode の (3,1)、(3,10)、(0,*)、Mac Roman の (1,0)、そして F000 プラスコードの Windows Symbol (3,0) を含む、format 0、4、6、12 の cmap サブテーブルで調べられます

フォントに基底エンコーディングがまったくないとき、コンポーネントは 1 つを推測する代わりに、あらゆるもっともらしい解釈の和集合に印を付けます。おそらく未使用のいくつかのグリフが残りますが、間違えるならこの方向が正しいのです。逆のケースは厳格で、証明されたグリフ ID へ写像されない /Differences の名前はフォント全体を拒否します。解決されない名前が 1 つあるということは、領域が不完全だという意味だからです

procedure ReportFontSubset(Source: TStream; Log: TStrings);
var
  Options: TPdfDocumentFontSubsetOptions;
  Report: TPdfDocumentFontSubsetReport;
  Output: TBytes;
  I: Integer;
begin
  Options := TPdfDocumentFontSubsetOptions.Default;
  Options.SubsetOptions.MinimumSavingPercent := 10;  // per font
  Options.MinimumDocumentSavingPercent := 5;         // whole file
  if not SubsetPdfDocumentFonts(Source, Options, Output, Report) then
  begin
    Log.Add('Rejected: ' + Report.ErrorMessage);
    Exit;
  end;
  Log.Add(Format('Fonts %d, subset %d, preserved %d, glyphs %d',
    [Report.FontCount, Report.SubsetFontCount, Report.PreservedFontCount,
     Report.GlyphCount]));
  Log.Add(Format('Font bytes %d -> %d, file bytes %d -> %d',
    [Report.OriginalFontBytes, Report.SubsetFontBytes,
     Report.SourceByteCount, Report.OutputByteCount]));
  for I := 0 to High(Report.Issues) do
    Log.Add(Format('  object %d kept: %s',
      [Report.Issues[I].ObjectNumber, Report.Issues[I].Reason]));
end;

SubsetPdfDocumentFonts は何に手を出さないのか

SubsetPdfDocumentFonts は証明できないフォントすべてに手を出さず、文書を失敗させる代わりに、それぞれを Report.Issues にオブジェクト番号と理由付きで報告します。Report.Complete が True になるのは、保存されたフォントが 1 つもないときだけです。サイズ削減を約束する前に知っておくべき保存のケースは次のとおりです

  • Type 1 の /FontFile と CFF か OpenType の /FontFile3 プログラム。Only TrueType FontFile2 programs are supported として報告されます
  • /W、/W2、/CIDSet のどれもない Identity CIDFonts、および写像ストリームも /Identity もない CIDFonts
  • フォント記述子以外の場所から参照されるフォントプログラム、および CIDFontType2 でも単純 TrueType でもない消費者と共有される記述子
  • サブセットが SubsetOptions.MinimumSavingPercent に届かないフォント、および再構築されたファイルが MinimumDocumentSavingPercent に届かない場合の全フォント。後者では Report.Applied が False になり、出力は元のバイトです

2 つの入力は保存ではなく丸ごと失敗します。署名済み文書と署名フィールドを持つ文書は拒否されます。フォントオブジェクトの置換はすべての署名のバイト範囲を無効化するためで、暗号化されたファイルにはまず復号されたソースが要ります。成功した実行は置換オブジェクトをスパース更新として書き、それから孤立オブジェクトの刈り込みとリソースの重複排除のガイドで述べたのと同じ到達可能性パスを通じてファイルを再構築するため、古いフォントプログラムは到達不能オブジェクトとして生き残らず、結果は 1 つのきれいなリビジョンになります

saSubsetNewFonts で保存時に新しいテキストをサブセット化する

PDFium Component が現在のセッションで埋め込むフォントは別のケースで、SubsetPdfDocumentFonts ではなく saSubsetNewFonts 保存オプションが扱います。saSubsetNewFonts は PDFium の FPDF_SUBSET_NEW_FONTS フラグに写像され、TPdf.AddText のようなネイティブ呼び出しがシステムからロードしたフォントに、OpenType CFF フェイスを含めて、ネイティブの HarfBuzz サブセッターを走らせます。上流の PDFium は今もこのフラグを実験的と位置付けています。すでにファイルにあったフォントには触れません

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(0, 595, 842);
    Pdf.AddText('Quarterly statement', 'Arial', 14, 72, 770, clBlack, $FF, 0.0);
    if not Pdf.SaveAs('statement.pdf', saSubsetNewFonts) then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

コンポーネントのプローブでは、Arial テキストの文書が saSubsetNewFonts の下で 590 KB から 22 KB に落ち、保存されたファイルからのテキスト抽出は今も正確に一致しました。COLR v0 カラーフォントの Segoe UI Emoji 7.0 MB は同じ経路で 10.5 KB になり、PDF に COLR レイヤーの概念はないため、ベースのアウトラインとして描かれました。可変フォントが例外で、ブロックはサブセッターの前にあります。GDI の GetFontData は 1.85 MB の Segoe UI Variable ファイルに対して 18.3 MB の代替ストリームを返し、テキストオブジェクトの作成はサブセット化が始まる前に失敗しました。Delphi からの CID Type 2 フォント埋め込みのウォークスルーが、それらのフォントがまず通る埋め込み経路をカバーします

証明が止まる場所

SubsetPdfDocumentFonts 内の証明は、プロデューサが書いたフォントメタデータとまったく同じ強さです。証拠が /W だけの Identity CIDFont には今も 1 つの盲点があります。幅が /DW に等しく、/ToUnicode エントリがなく、記述子が /CIDSet に列挙しないグリフです。そのようなグリフはリソースから証明できず、コンポーネントは、意図的に避けているコンテンツストリーム走査なしには、それが存在すると知り得ません。/W を圧縮しながら /ToUnicode と /CIDSet の両方を省くプロデューサはまれですが存在するため、未知のジェネレータからのアーカイブには、前後でサンプルを描画し、ピクセルと抽出テキストを比較してください

バッチを実行する前に、相手が何かを知っているのも役立ちます。PDFium Component で PDF フォントのプロパティを読むのアプローチが、そもそもどのフォントが埋め込まれているかを教え、ドライランの Report.Issues がそのうちどれが保存され、なぜかを教えます。既定のオプションも仕事を境界付けます。1024 フォント、合計約 100 万グリフ、128 MB のソース、固定の作業予算で、長いバッチジョブには任意の CancellationToken があります

FAQ

SubsetPdfDocumentFonts は PDF のテキストやレイアウトを変えるのか

いいえ。元のグリフ ID、CID、幅、コンテンツストリームが保たれるため、テキスト抽出と描画は以前と同じグリフを使います。取り除かれるのは届かないアウトラインだけで、フォント名に新しいサブセット接頭辞が付きます

埋め込みフォントがサブセット化されなかった理由

Report.Issues を読んでください。保存された各フォントは、Identity CIDFont での /W や /CIDSet の欠落、非 TrueType プログラム、不正な /Widths 配列、しきい値未満の削減のような理由を運びます

saSubsetNewFonts と SubsetPdfDocumentFonts は同じか

いいえ。saSubsetNewFonts は保存時に現在のセッションで埋め込まれたフォントをサブセット化し、SubsetPdfDocumentFonts は既存の PDF ストリームに対して働き、別のプロデューサがすでに埋め込んだフォントを置き換えます

文書フォントのサブセット化、保存時のフォントサブセット化、その背後の孤立オブジェクトの刈り込みパスはすべて、Delphi、C++Builder、Lazarus プロジェクト向けに PDFium を包む PDFium Component for Delphi and C++Builder とともに出荷されます