PDFからページを削除しても、そのフォント、画像、コンテンツストリームは削除されない。losLab PDF Libraryは、trailerのルートから前方にオブジェクトグラフを辿り、誰からも参照されなくなった間接オブジェクトをすべて除去するマークアンドスイープコレクタでこれらを回収する。これはフルセーブ時に実行され、デフォルトでは無効になっており、削除したオブジェクト数を返す
なぜPDFページを削除してもファイルは縮小しないのか
ページ削除が参照の編集であって、ストレージ操作ではないからだ。DeletePages(StartPage, PageCount)はページオブジェクトをページツリーからリンク解除し、それらを指していたアウトライン項目を修復する。それが行えないのは、それらのページが使っていたフォントプログラム、コンテンツストリーム、画像XObjectがもう死んでいると判断することである。なぜなら削除の時点で、ファイル内のどこにも他に誰がそれらをまだ指しているかを記録している場所がないからだ。これらのオブジェクトは文書オブジェクトリストに残り続け、フルセーブはそのすべてを書き戻す。結果として、こうしたサポートスレッドの大半を発生させる不満が生まれる:顧客がページの九割を削除して保存すると、ファイルはたった二パーセントしか縮小しない。さらに悪いことに、この漏れは複利で膨らむ。読み込み、削除、保存、再度読み込み、再度削除、再度保存を繰り返すと、ページ数が減る一方でファイルは単調に増大していく。これはフォントサブセット化と画像ダウンサンプリングが解決する問題とは別のものであり、あちらは生きているオブジェクトを小さくする。ここではオブジェクトが大きすぎるのではない。単にもう文書の一部でなくなっているだけである
ルートセットはページツリーではなくtrailerである
PDFオブジェクトグラフには逆参照フィールドが存在しない。フォーマットは参照カウントも背後へのポインタリストも定義しておらず、実際に存在する/Parentキーもページツリーのような特定の構造に属するものであって、オブジェクトグラフ全体に属するものではない。間接オブジェクトの側からは誰がそれを指しているか一切わからないため、「オブジェクト47をまだ誰かが使っているか」という問いには、既知のルートから前方に辿って到達するかどうかを確認するという答えしか存在しない。だからこそlosLab PDF Libraryのコレクタは参照カウント方式ではなくマークアンドスイープ方式なのである
ルートはファイルtrailer(ISO 32000-1 §7.5.5)から得られる。ルートを運ぶキーは3つ:ページツリー、名前、アウトライン、AcroForm、メタデータのすべてがぶら下がる§7.7.2の文書カタログを指す/Root、文書情報辞書を指す/Info、暗号化辞書を指す/Encryptである。残る2つのtrailerキーは紛らわしいだけの存在だ。/IDは2つのバイト文字列の配列であり、/Prevは前のクロスリファレンスセクションへの整数バイトオフセットである。どちらも間接参照ではないため、どちらもルートには寄与しない。losLab PDF Libraryは3つの名前付きキーではなくtrailer辞書全体をキューに入れており、これはコストなしに私的なtrailer拡張も生かし続ける
走査そのものは再帰ではなく反復で行われる。走査が間接参照に出会うと、オブジェクト番号と世代番号だけを記録し、対応するスロットにマークを付けて即座に参照を解決するのではなくFIFOキューに積む。これにより深いページツリーや長いアウトラインの連鎖がコールスタックを圧迫することがなく、同じオブジェクトが二度デコードされることもなくなる。直接の辞書、配列、ストリーム辞書は訪問済み集合で守られた第二のキューに積まれる。実際の文書には本物の循環が存在するからだ:ページの/Parentはそのページツリーノードを指し返し、アウトライン項目は/Prevと/Nextで双方向に連鎖する。世代番号は装飾ではなくマッチングの一部である。参照はオブジェクト番号と世代番号の両方が一致したときにのみ解決され、異なる世代で存在する番号への参照は、仕様が要求するとおりnullオブジェクトとして扱われ、生きたエッジとは決して見なされない
保存時にガベージコレクションを有効化するには
ガベージコレクションはオプトインであり、save optionsレコードに属する。デフォルトはFalseである。コレクタはオブジェクトグラフに対する破壊的なパスであり、呼び出し側が調べてほしいと頼んでもいないオブジェクトをライブラリが黙って削除するべきではないからだ
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
同じコレクタに到達する経路がほかに2つある。SetGarbageCollect(1)は選択された文書にフラグを設定し、通常のSaveToFileがそれを尊重するようにする。GarbageCollectObjectsはそのパスを即座に実行し、除去した孤立間接オブジェクトの数を返す。即時形式はログに残したりアサートしたりしたい数値がほしいときに使うべきものであり、確認する価値がある。なぜなら負の戻り値はカウントではないからだ
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
この失敗経路は見た目以上に重要である。オブジェクトは遅延デコードされ、一度もデコードされていないオブジェクトは参照を一切さらけ出さない。もしコレクタがデコード不能なオブジェクトを空のノードとして扱ったなら、それを通じてしか到達できないものすべてを一掃してしまうことになる。そのため走査は各オブジェクトに触れるたびにデコードを強制し、一つのデコードエラーがパス全体を中断させて負の結果を返し、文書はバイト単位で無変更のまま残る。一部しか理解していないグラフをスイープすることは、コレクタが破損したファイルを完全に破壊されたファイルへと変えてしまう典型的なやり方だ
素朴なPDFコレクタは何を壊すのか
2つの詳細があり、どちらも派手にではなく静かに失敗する。1つ目はオブジェクトストリームである。PDF 1.5以降、ストリームでないオブジェクトは/ObjStmコンテナ(§7.5.7)内に圧縮された状態で存在でき、そのクロスリファレンスエントリはコンテナとその中でのインデックスを名指しするタイプ2エントリになる。つまり圧縮されたオブジェクトはそのコンテナを経由してしか到達できない。メンバーにマークを付けながら、文書オブジェクトとして誰にも参照されていなかったという理由でコンテナをスイープしてしまえば、もはや存在しないオブジェクトをxrefが指すファイルを書いてしまったことになる。コンテナは文書データではなく構造的なストレージであるため、走査しているオブジェクトグラフの中でエッジとして現れることは決してない。losLab PDF Libraryはこれに対処するため、コンテナが消える前に生き残ったすべての圧縮メンバーをその元コンテナから切り離し、その後保存処理が生存者を新しいオブジェクトストリームへと再パックする。2つ目の詳細は、ストリームオブジェクトが実際に何を参照しているかである。バイト列そのものはグラフの一部ではない。/F1 12 Tfでテキストを描画するコンテンツストリームはリソース名でフォントを指定し、その名前はページの/Resources辞書を通じて解決される。したがって到達可能性のエッジはページ→/Resources→/Font→フォントオブジェクトという経路を辿り、ストリームのペイロードを通ることは決してない。ストリームがもたらす参照は辞書だけから来るものであり、そこでは/Length、/Filter、/DecodeParmsのすべてが間接参照であることを許されている。ストリームのバイト列を解析して参照を探すコレクタは無駄に高コストな作業をしているだけであり、ストリーム辞書をスキップするコレクタはlengthオブジェクトを見失いファイルを破損させる
解放したオブジェクト番号はどうなるのか
それらはフリーエントリになるが、同じ保存内では再利用されない。スイープはオブジェクトリストを降順に走査して削除のたびにインデックスがずれないようにし、削除のたびにではなく最後に一度だけルックアップインデックスを再構築する。そして削除された各オブジェクトについて、その番号を世代番号を1増やしたうえでフリーリストに記録する。これはまさに§7.5.4が後に再利用されうるエントリについて規定しているとおりである。すでに65535に達している世代番号はそのまま留まり、その番号が永久に引退したことを示す。オブジェクト番号は意図的に詰め直されない。コレクション後もファイルには穴が残る:オブジェクト12は空きでも13と14は使用中であるかもしれず、trailerの/Sizeは生存数ではなく相変わらず最大番号プラス1を報告する。これは合法かつ正常である。番号を振り直せばクロスリファレンステーブルで数バイトを節約できるかもしれないが、文書内のすべての参照を書き換える必要が生じる。それは外部からオブジェクト番号を保持しているものすべてを静かに無効化してしまう類の変更だ。得られるサイズの縮小はxrefテーブルからではなく、オブジェクト本体から来ている
コレクタを実行してはならないとき
インクリメンタル更新の上では決して実行してはならない。コレクタはフルセーブに限定され、文書に追記が行われているときはフラグ自体が読まれることすらない。この制限は回避すべき制約ではない。インクリメンタル更新(§7.5.6)は元のバイト列に手を触れず、/Prevで前のものと連鎖する新しいクロスリファレンスセクションを追記する。それより前のどのリビジョンも、常に指していたオブジェクトを今も指し続けている。したがって現在のリビジョンで到達不能なオブジェクトも、より古いリビジョンではまさに到達可能である。それを削除すれば最後のリビジョンを除くすべてを壊してしまう。その仕組みの詳細はインクリメンタル更新とアペンドモード保存に関する記事で扱っている。同じ理屈は署名済み文書へのガベージコレクションも禁じる。コレクションを可能にするフルリライトそのものが、署名を無効化してしまうからだ
コレクションが何でないかも明確にしておく価値がある。これはサニタイザーではない。コレクタは誰からも参照されていないオブジェクトを除去するだけであり、その内容が機密だったかどうかについては何の意見も持たない。まだ参照されているオブジェクトはそのままの姿で残る。ファイルを小さくするのではなく情報を復元不能にすることが目的なら、オブジェクトグラフは間違った層であり、命令レベルの黒塗りと文書サニタイズのほうが正しい層である。この2つはその順序でよく組み合わさる:まず黒塗りとサニタイズを行い、それからコレクションを行うことで、黒塗りが切り離したオブジェクトが実際にファイルから去っていく。同じ組み合わせはリソースパージAPIにも存在し、ガベージコレクトオプションを渡すとパージの後にコレクションが実行され、除去した孤立オブジェクトがOrphanObjectsRemovedで報告される
最後にもう一つ、身に付ける価値のある習慣がある。ページ削除を行うバッチジョブの中でGarbageCollectObjectsの戻り値をログに残し、実際の文書で数週間観察してみるとよい。半分に切り詰めたばかりのファイルでゼロが返るなら、名前ツリーのエントリ、アウトラインの行き先、あるいはページが失われても生き残ったAcroFormフィールドなど、想定外の参照を上流の何かがまだ保持していることを意味する。コレクタはあなたが手にする中で最も安上がりな到達可能性デバッガである。PDFフォーマット自身が答えることを拒む問いに答えてくれるからだ
ここで説明したガベージコレクタ、save-optionsレコード、リソースパージAPIは、DelphiとC++Builder向けのlosLab PDF Libraryの一部であり、その製品ページには保存パイプライン全体のリファレンス——コレクション、オブジェクトストリームのパッキング、線形化の相互作用を含む——が掲載されている