技術記事

Delphi で PDF を縮める: 孤児オブジェクトとリソース重複統合

Delphi 向け PDFium Component(PDFiumPas)は、TPdf の 3 つのリライト API で PDF ファイルを縮めます。SaveAsReachable は何ものも参照しないオブジェクトを落とし、SaveAsDeduplicated はバイト単位で同一のフォント、画像、Form XObject を統合し、SaveAsFlateRecompressed は既存の Flate ストリームを最大圧縮で再符号化します。それぞれがレポートレコードを返すため、何が除去され、何バイト節約できたかが分かります

よくあるきっかけは、「20 ページ削除したのにファイルが大きくなった」と読めるサポートチケットです。誰かが契約書を開き、付録を削除し、表紙を統合し、保存しました。どのステップもインクリメンタル更新だったため、旧オブジェクトはまだファイルの中におり、その文書が後に読み込まれて FPDF_SaveAsCopy で書かれると、そのまま外に出てきます。PDFium は、メモリ内のオブジェクトテーブルに座っているものをすべてシリアライズします。間接オブジェクトの一覧、削除、参照の書き換えのための公開呼び出しは持ちません。したがって本記事のすべては、PDFium が生むバイト列の上の PDFiumPas ラッパー層で動きます

FPDF_SaveAsCopy が削除済みページをファイルに残すのはなぜか

FPDF_SaveAsCopy が孤児オブジェクトを残すのは、PDFium がクロスリファレンステーブルのすべての生きているエントリを、何もそれを指していなくても文書へ読み込むからです。ISO 32000-1 §7.5.6 はインクリメンタル更新を、新しいオブジェクトバージョンと新しい xref セクションをファイル末尾に追記するものと記述します。ページの削除が編集するのはページツリーだけです。ページ辞書、そのコンテンツストリーム、そのページだけが使うフォントや画像は、使用中のオブジェクトとして依然列挙されています。全量の非インクリメンタル保存でもこれは変わりません。オブジェクトテーブルの視点では、それらのオブジェクトは完全に正当だからです

同じ残骸は、ページ統合の後、他のツールがやるフォームのフラット化の後、破損ファイルの修復保存の後にも現れます。すべてのビューアで見えず、バイトを足す以外の何もしません。スキャン文書では、1 個の孤児ページ画像が数メガバイトになり得ます

SaveAsReachable による mark-and-sweep

TPdf.SaveAsReachable は、古典的な mark-and-sweep のパスで孤児を除去します。PDFium に現在の文書の非インクリメンタルコピーを書かせ、そのコピーをオブジェクトグラフへパースし、ルートから到達できるすべてに印を付け、印の付いたオブジェクトだけを含む単一のクラシックリビジョンを書き出します。オブジェクトストリーム内部のオブジェクトは道中で普通の間接オブジェクトとして実体化されるため、出力は常に素の xref テーブルを使います

ルートの選び方が、素朴な実装が間違える場所です。自明なシードは、トレーラーの /Root が名指す Catalog です。そこから歩行は、ページツリー、ページリソース、フォント、画像、Form XObject、AcroForm、構造ツリー、しおり、名前ツリー、オプショナルコンテンツ、XMP メタデータ、出力インテント、埋め込みファイルへ届きます。しかし文書情報辞書は Catalog ではなく、トレーラーの /Info エントリ(ISO 32000-1 §7.5.5)から参照されています。/Root だけをシードにすれば、処理するすべてのファイルの Title、Author、Producer を黙って削除します。PDFiumPas は歩行を /Root と /Info の両方でシードし、再構築されたトレーラーは、そのオブジェクトが実際に生き延びたときに限り /Info を先へ運びます

ウォーカは明示的なスタックと訪問済みセットを持つ深さ優先走査であるため、参照循環(ページツリーの親リンク、注釈の /P 逆ポインタ)はこれをハングさせられません。既に空きになっているオブジェクト番号への参照は、エラーとして扱われる代わりに単純にたどられません。このパスがわずかに破損した入力に寛容であり続けるのはこのためです

オブジェクト番号は保持され、振り直しされない

TPdf.SaveAsReachable は、生き延びたすべてのオブジェクトを、オリジナルのオブジェクト番号とジェネレーションの位置に保ちます。掃除されたオブジェクトは xref テーブルに空きエントリとして書かれ(ISO 32000-1 §7.5.4)、生きている番号の間の隙間は空きエントリで埋められ、オブジェクト 0 はフリーリストの先頭であり続けます。何も振り直されないため、生き延びたすべての参照は、1 バイトの書き換えもなしに正しくあり続け、間違え得る旧から新への対応表も存在しません。コストは数行の未使用 xref ライン、それぞれ 20 バイトです。払う価値のある代償です

uses
  PDFium, FPdfCompress;

procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Report: TPdfPruneReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    if not Pdf.SaveAsReachable(TargetPdf, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
      [Report.SourceByteCount, Report.OutputByteCount,
       Report.KeptObjectCount, Report.PrunedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

TPdfPruneReport は SourceByteCount(PDFium が生んだ非インクリメンタルコピーのサイズ)、OutputByteCount、KeptObjectCount、PrunedObjectCount、Succeeded、ErrorMessage を運びます。ファイル名のオーバーロードはターゲットの隣の一時ファイルへ書き込み、リライトが成功したときに限ってターゲットを置き換えるため、失敗した実行が途中切れの PDF を残すことはありません

これらの API はどのファイルを拒否するのか

3 つのリライト API はすべて、暗号化文書を拒否し、既定では署名付きも拒否します。暗号化された入力は、例外の代わりに Succeeded = False とエラーメッセージで失敗します。単一リビジョンのリライトは、暗号化辞書が依存するオブジェクト番号、バイト範囲、オブジェクトごとの鍵を保てないため、出力は読めないものになるからです。電子署名を持つ文書は、AllowSignedDocument = True を渡さない限り EPdfError を上げます。フルリライトは署名されたバイト範囲を常に無効化するからです。このフラグを立てることは、署名が壊れていると報告されるファイルを受け入れることを意味します。アーカイブコピーなら正しい判断で、今送り返そうとしている契約書では決してそうではありません

SaveAsDeduplicated は 2 つの画像が同一とどう判定するのか

TPdf.SaveAsDeduplicated は、辞書、到達可能なサブグラフ全体、生のストリームバイトがすべて一致するときに限り、2 つのリソースを同一と扱います。まず同じ到達可能性の歩行を実行し、到達可能なすべてのフォント、画像 XObject、Form XObject にフィンガープリントを付け、一致の各グループを最小のオブジェクト番号のメンバーへ統合し、すべての参照をその生き残りを指すよう書き直します。統合された重複は、孤児と同じく空き xref エントリになります。これが、「ロゴが 400 回埋め込まれた」という、同じリソースをページごとに再埋め込みする mail-merge ツールが生む古典的レポートの修正です

フィンガープリントは、再帰的に計算される 64 ビットの FNV-1a ハッシュです。各候補について、ウォーカはオブジェクト本体を取り、すべての N G R 参照を、その指す先のオブジェクトのフィンガープリントに置き換え、結果をハッシュします。これでハッシュはオブジェクト番号から独立します。ディスクリプタがたまたまオブジェクト 12 と 87 になっている同じフォントの 2 つのコピーも、同じ値を生みます。結果はメモ化されるため、共有されるサブオブジェクトは 1 回だけハッシュされ、訪問中セットが後退エッジに固定のセンチネルを返すため、循環グラフは終了します

微妙なバグはストリームペイロードに座っています。参照を安全に見つけるため、PDFiumPas は、ストリームデータ、文字列、コメントをスペースで空白化した各オブジェクトのコピーを走査します。そうしなければ、たまたま 12 0 R に見えるバイナリのピクセルデータの連なりが、参照として扱われてしまいます。その無害化コピーだけをハッシュすると、辞書が同一(同じ /Width、/Height、/BitsPerComponent、/Filter、/Length)でピクセルの違う 2 つの画像が 1 つのフィンガープリントに潰れ、ページの 2 枚目の写真が黙って 1 枚目に置き換えられます。SaveAsDeduplicated は、stream と endstream の間の生バイトをハッシュ対象データに追加するため、ピクセルは同一性の一部です。候補は比較の前にカテゴリごとにもグループ化されるため、フォントと画像の間の稀なハッシュ衝突が両者を統合することは決してありません

参照の書き換えは、同じ無害化スキャンのトリックを逆向きに再利用します。参照の位置は空白化コピーの中で特定し、バイト列はオリジナルの本体からコピーします。辞書と配列の構造だけが修正され、バイナリペイロードは手つかずで通り抜けます

procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
  Report: TPdfDedupReport;
begin
  if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
    [Report.MergedFontCount, Report.MergedImageCount,
     Report.MergedFormXObjectCount,
     Report.SourceByteCount, Report.OutputByteCount]));
end;

TPdfDedupReport は注意して読んでください。3 つの Merged*Count フィールドの隣に KeptObjectCount と PrunedObjectCount があり、このレポートで PrunedObjectCount が数えるのは統合された重複だけです。到達不能なオブジェクトは依然落とされますが、この数には含まれません。ログのために孤児カウントが要るなら、別の SaveAsReachable 実行から取ってください。もう 1 つの限界は、マッチングが符号化済みストリーム上でバイト厳密だということです。2 つの異なるプロデューサが 2 つの違う Flate レベルで圧縮した同じロゴは、統合されません

最大レベルでの Flate ストリームの再圧縮

TPdf.SaveAsFlateRecompressed は、到達可能な各 FlateDecode ストリームを展開し、最高圧縮レベルでもう一度 deflate します。結果を保つのは、それがより小さく、自分の閾値をクリアするときだけです。多くのプロデューサは Flate を速い既定レベルで書くため、コンテンツストリームと埋め込みフォントプログラムはしばしばさらに縮みます。オブジェクト番号とジェネレーションはそのままです。変わるのはペイロードと、その直接の /Length だけです

TPdfFlateRecompressOptions には 2 つのフィールドがあり、既定のコンストラクタはありません。両方設定してください。MinSavingBytes はストリームごとの最小絶対節約量、MinSavingRatio はオリジナル長に対する割合(0.05 は 5% を意味する)で、比率 0 は比率テストを無効化します。再構築されたストリームは、stream、CRLF、新しいペイロード、CRLF、endstream で書かれるため、結果は元ファイルの行末に依存しません

procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
  Options: TPdfFlateRecompressOptions;
  Report: TPdfFlateRecompressReport;
begin
  Options.MinSavingBytes := 256;    // 小さな利得は無視する
  Options.MinSavingRatio := 0.05;   // さらに 5% 未満も無視する
  if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
    [Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
     Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;

エッジケースは設計上、保守的です。/Length が間接参照であるストリームはスキップされます。リライトは直接の整数を格納せねばならないからです。展開に失敗するペイロードは FailedCount に数えられ、手つかずでコピーされます。閾値を満たさない Flate ストリームは SkippedCount に数えられます。DCT、JPX、JBIG2、CCITT の画像は候補リストに決して入らないため、このパスが JPEG 品質に触れることはありません。非可逆の画像節約には、最終保存の前にPDFiumPas のアダプティブ画像リサンプリングを使ってください

その閾値テストを書いていて踏んだ移植性の罠が 1 つあります。最初の草案は、Int64 と宣言された Saving を使って Single(Saving)/Single(OrigLen) を比較していました。Delphi は受け付けますが、FPC 3.2.2 は Illegal type conversion: "Int64" to "Single" で止まります。FPC ではこの構文は数値変換ではなく型キャストだからです。素の Saving / OrigLen は両コンパイラで本物の除算であり、Single と直接比較できるため、キャストは単純に不要でした

3 つの API はどんな順序で実行すべきか

まず SaveAsDeduplicated を実行し、出力を再読み込みし、それから SaveAsFlateRecompressed を実行します。重複統合は既に到達可能性の掃除を含むため、その前に別の SaveAsReachable パスを挟むのは冗長です。SaveAsReachable は、孤児の掃除と正確な剪定カウントだけが欲しい仕事のために取っておきます。2 番目に再圧縮することは、高価な展開と再圧縮の仕事が、重複ごとに 1 回ではなく、生き残ったストリームごとに 1 回走ることを意味します。再圧縮のパスはまた、グラフを /Root と /Info からもう一度たどるため、統合された重複フォントだけがまだ指していたフォントディスクリプタやフォントファイルをきれいにします

procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  TempPdf: string;
begin
  TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;
    Pdf.Active := True;
    MergeDuplicateResources(Pdf, TempPdf);   // 剪定と統合

    Pdf.Active := False;
    Pdf.FileName := TempPdf;
    Pdf.Active := True;
    RecompressFlate(Pdf, TargetPdf);         // 生き残りを再 deflate
  finally
    Pdf.Free;
    DeleteFile(TempPdf);
  end;
end;

黒塗りも適用するなら、どれよりも先にやります。物理的にコンテンツを取り除くことが、そもそも新しい孤児を生むものだからです。オペレータレベルの黒塗りとリソース剪定の記事が黒塗りの側を扱います。そして最終ファイルは、パイプラインの健全性チェックとして常に再読み込みしてください。ソースと一致するページ数は、顧客が気にする何かを上書きする前に払える安い保険です

FAQ

SaveAsReachable はオブジェクトを振り直したり参照を変えたりするのか

いいえ。TPdf.SaveAsReachable は、生き延びたすべてのオブジェクトをオリジナルの番号とジェネレーションに保ち、除去されたオブジェクトを空き xref エントリとして印付けします。書き直さねばならない参照はありません

SaveAsDeduplicated は違う 2 つの画像を誤って統合し得るのか

辞書の一致だけでは無理です。フィンガープリントには生のストリームバイトと参照されるサブグラフ全体が含まれ、候補は同じカテゴリ(フォント、画像、Form XObject)の中でだけ比較されます

これらの API は電子署名を有効のまま保つのか

いいえ。各 API はフルリライトを行い、署名されたバイト範囲を壊します。署名付きファイルは、AllowSignedDocument = True を渡し、出力の無効な署名を受け入れるのでない限り拒否されます

3 つの API はすべて、Delphi と C++Builder 向け PDFium Component に同梱されています。このブログの他の記事で扱う黒塗りと画像リサンプリングの機能とともに