技術記事

DelphiのHotPDFでオブジェクトストリームと増分更新

PDF 1.5では、それ以前のファイル形式では表現しようのなかった2つの格納構造が導入されました。オブジェクトストリームとクロスリファレンスストリームです。オブジェクトストリームは/Type /ObjStmを持つFlate圧縮された1つの容器で、小さな間接オブジェクトをファイル本体のあちこちに散らす代わりに、隙間なく詰めて多数まとめて保持します。クロスリファレンスストリームは、バージョン1.4までのあらゆるPDFを締めくくっていた固定幅のASCIIテーブルに代えて、可変幅フィールドの圧縮バイナリとして書き直された参照表です。この2つは連れ立って動きます。オブジェクトがいったんストリームへ折り畳まれると、古いテキストのテーブルではもうそれらを指し示せないので、バイナリのxrefが一緒に付いてくる必要があります

これを古典的な配置と並べてみれば、取り除かれるコストは一目で分かります。PDF 1.4のファイルではすべての間接オブジェクトが自分のobjヘッダーの後ろに非圧縮で座り、末尾のテーブルは1エントリあたりきっかり20バイトのASCIIを費やし、圧縮は禁じられています。20万個のオブジェクトを抱える文書は、グリフを1つも描かないうちにおよそ4 MBのクロスリファレンスデータを背負い、その上に非圧縮の辞書の本体がすべて積み上がります。PDF 1.5はこの2つの数字を同時に攻めます。辞書はFlateの容器へ折り畳まれ、4 MBのテーブルは数百キロバイトのバイナリへ縮みます。ISO 32000-1はこの2つの構造を§7.5.7と§7.5.8で定義しています

非圧縮のPDF 1.4のオブジェクトとASCIIのxrefテーブルを、圧縮されたPDF 1.5のオブジェクトストリームとバイナリのxrefストリームと並べて比較するHotPDFのファイル配置図
折り畳まれた辞書とバイナリのxrefストリームは数メガバイトの構造的オーバーヘッドを潰す一方、ページのコンテンツと画像データは元々の圧縮のままです——構造の重いファイルほど得をします

節約が実際に効くところ

オブジェクトストリームが触れるのはストリーム以外のオブジェクトだけなので、圧縮されるのは構造であってピクセルではありません。ページのコンテンツは1.5より前からFlate圧縮されていましたし、画像データは自前のコーデックを抱えています。だからこそ画像の多いカタログはほとんど変化しません。劇的に縮むのは構造の重いファイルです。数千個のフィールド辞書を持つAcroForm、深いアウトラインのツリー、タグ付きPDFの構造要素などです。それらのオブジェクトは小さく、数が多く、互いにほとんど同じ形をしています。その反復こそ、ヘッダーを挟みながら本体に散らばるのではなく1つのバッファーに収まったときにFlateが食い付く相手です

古いファイルのどれほどがオーバーヘッドなのかは、見くびられがちです。何年もの編集を吸い込んできたフォームの保管庫は、辞書のヘッダー、xrefの詰め物、そして誰も見ることのないリビジョンに、バイト数の半分をゆうに超える量を費やしていることがあります。ここで扱う2つの機能は、そのうち最初の2つを取り戻します。3つめの、積み上がったリビジョンが片付くのはコンパクションのときだけで、しかもファイルが自身の履歴を覚えている必要がなくなった後に限られます

HotPDFでは1組のプロパティで両方を有効にします。書く順序よりも、両者がどう依存し合っているかの方が重要です:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'catalog-2026.pdf';
    Pdf.UseXRefStream := True;      // バイナリのxref、ObjStmの前提条件
    Pdf.UseObjectStreams := True;   // オブジェクトを/Type /ObjStmへ詰める
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Compressed structure demo');
    Pdf.EndDoc;                     // XRefStmとObjStmの容器を出力する
  finally
    Pdf.Free;
  end;
end;

UseObjectStreamsUseXRefStreamTrueであることを必要とします。圧縮されたオブジェクトへはタイプ2のxrefエントリ経由で到達し、そのエントリはオブジェクトストリームの番号とインデックスを記録しますが、古典的な20バイトのテキスト行にはその組を収める場所がありません。ですからUseObjectStreamsだけでは目に見える効果は何もありません。BeginDocより前に両方のフラグを立てるのが、実際に効く設定です。BeginDocの後で立てても、HotPDFは既に古い配置で進むと決めてしまっています

なぜ両方とも既定でオフなのか

HotPDFは箱を開けた状態では両方のプロパティをFalseにしており、その理由は後段の古いコードとの連携に現れます。PDF 1.4しか理解しないリーダーは、圧縮オブジェクトを扱えないと自分から告げてはくれません。xrefストリームに出会い、期待していたトレーラーのキーワードが1つも見つからず、クロスリファレンステーブルが壊れていると報告するか、単にファイルを開くのを拒みます。出力が古いファックスゲートウェイ、組み込みインタープリターで動くハードウェアプリンター、あるいは10年前に1.4の仕様書を見ながら誰かが書いたパーサーへ流れ込むなら、その経路については両方のフラグをオフのままにし、ファイルが大きいことを受け入れてください。主要なビューアが20年前からPDF 1.5を読んできたアーカイブ保管やWeb配信では、これをオンにするのはほぼ無料で手に入る圧縮です

サポートチームに伝えておく価値のある二次的な影響もあります。辞書がオブジェクトストリームに詰め込まれると、生成した2つのファイルをバイト単位で比較することに意味がなくなります。フィールドを1つ変えるだけで容器全体が再びFlateされ、その後ろのすべてがずれるからです。この種のファイルはバイナリ比較ではなく、オブジェクトの内容で差分を取ってください

増分更新と、それが守るバイトオフセット

電子署名は明示的な/ByteRangeを対象にします。物理ファイル上の2つの範囲を絶対バイトオフセットで示したもので、CMSのダイジェストはその範囲について取られています。ファイルを書き直せば、画面上は同一に見えるものへ書き直したとしても、それらのオフセットはすべて動きます。ダイジェストは一致しなくなり、署名は壊れていると読まれます。ISO 32000-1 §7.5.6が増分更新で解決するのは、まさにこの問題です。新規および変更されたオブジェクトは既存の%%EOFの後ろへ追記され、続いて新しいクロスリファレンスのセクションが書かれます。そのセクションの/Prevエントリは1つ前のものを指し戻します。元のバイトは決して乱されないので、署名済みのリビジョンは検証可能なまま保たれ、Acrobatは署名パネルで各署名リビジョンをそれぞれ単独で提示できます

HotPDFはこれを専用の入り口から公開しています:

Prevのxrefエントリで連鎖する3つの追記専用リビジョンを示し、元のByteRangeのダイジェストが依然として検証を通ることを表したHotPDFの図
追記されたリビジョンはPrevエントリを通じて後ろ向きに連鎖し、署名がダイジェストしたバイトには決して触れないので、以前の署名済みリビジョンはすべて検証を通り続け、ファイルは右へ伸びるだけです
Pdf.BeginIncrementalUpdate('contract-signed.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Addendum recorded 2026-06-11');
Pdf.SaveIncrementalUpdate('contract-updated.pdf');  // 差分だけを追記する

つまずきどころが2つあります。BeginIncrementalUpdateには元のファイル名を渡さなければなりません。追記されるxrefのセクションは、まさにその元のバイト列に対してしか意味を持たないオフセットを記録するからです。名前を変えたコピーや保存し直したコピーを指させば、オフセットはもう存在しないファイルを説明することになります。そして保存は構造上つねに追記専用なので、出力は必ず入力より大きくなります。その増加は削り取るべき無駄ではありません。以前の署名済みリビジョンをそのまま残すのと同じ性質です

読み込んだファイルの変更はLoadFromFileを通す

生成用のAPIからHotPDFに入った開発者は、ある壁にぶつかりがちです。BeginDocは真新しい文書を開くもので、既に存在する文書を変えたいときには道具の選択を誤っています。既存ファイルの編集は、代わりに読み込み済み文書向けの呼び出しを通ります:

PageCount := Pdf.LoadFromFile('base.pdf');
Pdf.InsertPagesFromDocument(OtherDoc, '1-3', 5);  // 1-3ページを5ページの後ろへ
Pdf.MovePage(2, 5);
Pdf.SaveLoadedDocument('modified.pdf');

この2つを混ぜると、出力ファイルには新しく書いた内容だけが入り、元の中身は何も入らないという症状が出ます。BeginDocが、編集しているつもりだった文書の隣に、何食わぬ顔で新しい文書を作ったからです。LoadFromFileSaveLoadedDocumentを1つの語彙、BeginDocEndDocをもう1つの語彙として読んでください。同じファイルに対して両方へ手を伸ばすルーチンは、ほぼ確実に間違っています

BeginDocが新しいファイルを作り、LoadFromFileとSaveLoadedDocumentが既存のファイルを編集するという、HotPDFの2つの保存の語彙を示した図
生成用の組は真新しい文書を作り、読み込み済み文書用の組は既にディスクにあるものを編集します——この2つを混ぜることが、元のページを1枚も含まない編集結果が時折出荷される理由です

追記されたファイルをコンパクションすべきとき

追記専用の保存にはゆっくりと効いてくる代償があります。同じPDFにステータス行を1本だけ押す夜間ジョブは、1年で365個のリビジョンを生み、リビジョンはそれぞれ新しいxrefのセクションを後ろに引き連れます。その履歴が役目を終え、ファイル内のどの署名も生き残る必要がなくなったら、読み込み済み文書の経路で書き直して全体を平坦化できます:

Pdf.LoadFromFile('stamped.pdf');
Pdf.SaveLoadedDocument('compacted.pdf');

この保存し直しは完全な書き換えです。以前のリビジョンを意図的に捨て、ファイルに残っている署名を壊すので、他の破壊的な手順と同じ方針のゲートの後ろに置いてください。実運用で通用する規則を1つ挙げるなら、リビジョン数がしきい値を超えたとき、または追記分のオーバーヘッドが元ファイルの一定割合を超えたときにコンパクションし、署名パネルに何かが載っている文書は決してコンパクションしないことです

出荷前に出力を確かめる

この2つの機能の検証は、気持ちよいほど具体的です。結果をAdobe Acrobatで開き、3点を確認してください。オブジェクトストリームを有効にした後は文書のプロパティがPDF 1.5以降を報告すること。増分更新の後も署名パネルが以前に署名されたリビジョンをすべて検証できること。そして読み込み、変更、保存の一巡を経てもページ数とブックマークが無傷で通っていること。アーカイブ用の出力なら、ファイルをveraPDFにも通してください。圧縮されたxrefは、寛容なビューアよりも厳格なバリデーターの方がずっと入念に見る種類の構造だからです。非常に大きな入力も扱う仕事なら、大きなPDFのワークフロー向けDirect File APIの解説にある点検手法が増分保存と自然に組み合わさりますし、上のバイト範囲の背後にある署名の仕組みはHotPDFの電子署名とPAdESの記事で詳しく扱っています

どちらの機能もDelphiとC++Builder向けのHotPDF Delphiコンポーネントの一部として提供され、このブログの他の記事で扱っている生成、フォーム、暗号化、署名の各APIと並んでいます。上の呼び出しを自分の文書パイプラインと突き合わせたい場合は、製品ページから完全なAPIリファレンスへたどれます