技術記事

Delphiにおける遅延オブジェクトストリームとPDFの完全書き直し

HotPDF Delphi ComponentがLoadFromFileでPDF 1.5のファイルを読み込むとき、/Type /ObjStmコンテナの中に詰められたオブジェクトは解析しません。圧縮された各メンバーがどこにあるかを記録しておき、何かが要求したときにだけ解析します。この遅延の不変条件が、読み込み時間を「実際に触った分」に比例させてくれます。そして同時に、完全な書き直しが1バイトを出す前に余分な仕事を1つしなければならない理由でもあります。まだ解析されていないメンバーをすべて展開することです。書き直しは、それらのメンバーが住んでいるコンテナを捨て去ろうとしているからです

この記録を書くきっかけになった症状は、説明は簡単でデバッグは不快なものです。フォント、色空間、構造ツリーがオブジェクトストリームに入っているファイルを読み込み、BeginDocとEndDocの生成ペアに通すと、出力は文句もなく開きます。ページ数も正しく、抜き打ちで確認したページのテキストも見えます。ところが同僚が40ページ目を開くと、本文が代替フォントで描画されていたり、Extract Textコマンドが、以前はActualTextの置き換えがあった場所でゴミを返したりします。クラッシュはしていません。ライターが、一度も読み込まれなかったオブジェクトをそのまま直列化しただけであり、読み込まれていないオブジェクトは何もないものとして直列化されます

LoadFromFileは圧縮オブジェクトに対して実際何を保持するのか

タイプ2のクロスリファレンスエントリごとに、LoadFromFileはFCompactObjectsに小さなレコードを保持します。オブジェクト番号、コンテナテーブル内の格納先ストリームのインデックス、そのストリーム内でのメンバーの位置、そしてnilから始まるParsedObjectポインタです。コンテナ自体は位置を特定し、文書が暗号化されていれば復号し、展開しますが、メンバーの本体はバイト列のまま残します。これを可能にするコンテナのレイアウトを定義しているのがISO 32000-1 §7.5.7です。オブジェクト番号とオフセットの組のヘッダがあり、/Firstの後にメンバー本体が連結されるので、隣に触れることなく任意の1つのメンバーを切り出せます

レコードをオブジェクトに変える唯一の経路がEnsureCompressedObjectLoadedです。オブジェクト番号でレコードを探し、ParsedObjectがすでに設定されていればそのキャッシュ済みオブジェクトを返し、キャッシュヒットとして数えます。そうでなければ、コンテナが追い出されていた場合は再読み込みし、オフセットテーブルからメンバーのバイト範囲を計算し、そのスライスのゼロコピービューをパーサに渡し、結果をレコードに書き戻します。それ以降、そのオブジェクトは間接になり、本当のオブジェクト番号を持ち、ファイル本体から解析されたオブジェクトと同じく文書のオブジェクトインデックスに登録されます。catalog、info辞書、ページツリーのルート、ページオブジェクトは、ナビゲーションが必要とするので読み込み時にこの経路を通ります。フォント、色空間、ExtGState辞書、構造要素はそうではなく、ページ描画か書き直しが触れるまでレコードのまま残ります

HotPDF Delphi Componentが解析前の圧縮メンバーをどう保持するか。FCompactObjectsレコードはオブジェクト番号、コンテナインデックス、メンバーインデックス、nilのParsedObjectポインタを保ち、EnsureCompressedObjectLoadedがキャッシュヒット、コンテナ再読み込み、オフセットテーブルのスライス、ゼロコピー解析を通してレコードを登録済みオブジェクトに変えます
LoadFromFileは/ObjStmのメンバー本体をバイト列のまま残し、読み手が要求したときにだけ解析するので、読み込み時間は触った分に比例します。catalogとページツリーは早くに来て、フォント、色空間、構造要素はレコードのまま残ります

これは外側から観察できます。GetLoadedObjectStreamCacheInfoは、コンテナがいくつあるか、何個のメンバーがインデックスされたか、そのうち何個がこれまでに解析されたかを報告します

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

構造の多いファイルでは、読み込み直後は3番目の数が2番目のごく一部です。この差が遅延読み込みの狙いそのものであり、同時に、完全な書き直しが拾い直さなければならないオブジェクトの集合でもあります

完全な書き直しがインクリメンタル保存では保たれるフォントを落とす理由

完全な書き直しは、元ファイルの/ObjStmと/XRefのコンテナを捨て、オブジェクトグラフを一から直列化し直します。ですからParsedObjectがまだnilのメンバーは、出力の中に表現を残せません。インクリメンタル更新にはこの問題がありません。元のバイト列の後ろに新しいオブジェクトを追記し、古いコンテナは以前のクロスリファレンスセクションが指せるようにそのまま残すからです。違いは2つのモードがフォントをどう扱うかにあるのではありません。元のコンテナが、次のビューアに読まれる形で生き残るかどうかです

修正はSaveToStreamにあります。FileNameを設定してもOutputStreamを設定してもEndDocが駆動するシリアライザです。どのライター分岐へ振り分けるよりも前に、FCompactObjectsをたどり、すべてのエントリに対してEnsureCompressedObjectLoadedを呼びます。メンバーを読み込めなければ、保存は続行せず例外を送出します。フォント辞書を黙って落とす書き直しは、止まる書き直しより悪いからです。展開はその階層、つまりクラシック、パック、リニアライズの各分岐より上、そしてリニアライズ経路の再読み込みされた構造ストリームの枝刈りより上に置く必要があります。以前のバージョンはSaveLoadedDocumentの中でだけメンバーを展開しており、読み込み済み文書の語彙はカバーしていましたが、生成の語彙は完全に取りこぼしていました。LoadFromFileに続けてBeginDoc、ページ編集、EndDocと進むと、触られていないメンバーがすべて未解析のままライターへ直行していました

HotPDFの完全書き直しの展開がどこに座るか。SaveToStreamはクラシック、パック、リニアライズのライターへ振り分ける前に、すべてのFCompactObjectsエントリをEnsureCompressedObjectLoadedへ通すので、SaveLoadedDocumentの語彙もLoadFromFile+BeginDoc+EndDocの語彙も、nilのレコードではなく完全に解析済みのオブジェクトを直列化します
インクリメンタル更新は元のバイト列の後ろに追記し、古いコンテナを読める形で残しますが、完全な書き直しはそれを捨てます。すべてのライター分岐の上にある1回の展開パスが、読み込まれていないフォントや構造要素が何もないものとして直列化されるのを止めます
// どちらの書き直しの語彙も、ライターが動く前にコンパクトメンバーを展開するようになった。
// 読み込み済み文書の経路:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// 読み込み済みファイルに対する生成の経路:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStreamがすべてのFCompactObjectsエントリを先に実体化する

キャッシュ済みのメンバーは、こちらが加えた変更をそのまま保ちます。保存の前に解析され、編集され、ダーティとして印を付けられたオブジェクトは、その編集内容とともにキャッシュから返されます。削除したメンバーも、繰り返し保存しても削除状態を保ちます。この展開パスは構造上冪等です。nilのスロットを埋めるだけだからです

3ページの画素比較がActualTextのケースを見逃す理由

このバグが最も長く隠れるのは構造要素です。マーク付きコンテンツシーケンス上のActualTextエントリは、ISO 32000-1 §14.9.4で定義されており、抽出とアクセシビリティのためにグリフを置き換えますが、描画には影響しません。その構造要素がオブジェクトストリームに入っていて書き直しで失われても、ページは正しく描画され、最初と中間と最後のページは元と1ピクセル単位で一致し、誰かがテキスト抽出やスクリーンリーダーを走らせてはじめて問題が現れます。ページを描画するだけの書き直しテストは、タグ付きPDFの書き直しテストではありません。抽出テキストと構造ツリーも差分を取ってください

空のユーザーパスワードは読み込みをどう変えるのか

空のユーザーパスワードでもファイルは暗号化されており、そのようなファイルのオブジェクトストリームは、ファイルキーが復元されるまで暗号文です。ISO 32000-1 §7.6.3.4のAlgorithm 2は、その鍵をパスワード、/Oエントリ、/P、そして最初の文書識別子から導出します。HotPDFはタイプ2の処理がコンテナを1つでも展開する前に、それを空文字列に対して実行しなければなりません。そのため、読み込み済みの暗号化文書に対するBeginDocは、何よりも先に空のパスワードでDecryptLoadedDocumentを呼びます。呼び出し側が出力を保護するつもりかどうかに関係なく、書き直しを始める前にオブジェクトグラフを認証し復号しなければならないからです。出力の暗号化は別の判断で、呼び出し側の保護設定によって決まります。BeginDocは復号のパスの後にそれらの設定を復元するので、暗号化された入力が黙って暗号化された出力になることはありません

コンテナのポリシーは、パスワードを試す前に/Encrypt辞書から読み取ります。/Vが1と2の場合、すべてのストリームがファイルキーで暗号化されます。crypt filterの場合、HotPDFは/StmFを/CFを通して解決します。Identityフィルタまたは/CFMがNoneなら平文のコンテナ、V2とAESV2なら暗号化されたコンテナです。その答えはFReloadObjectStreamsEncryptedに入り、1つの特定のケースで効いてきます。コンテナは平文だが文字列はそうでない場合、メンバーは個別に復号しなければならない暗号化された文字列を運びます。そこでMaterializeMembersOfPlaintextObjectStreamsが、オブジェクト単位の復号パスより前にすべてのコンパクトメンバーを展開します。ポリシーがまだ分かっていないときは何もせず、コンテナ自体が暗号化されていたときも何もしません。暗号化されたコンテナのメンバーはすでにそれと一緒に復号されており、二重に復号してはならないからです

コンテナを復号できないとき何が起きるのか

復号に失敗したコンテナは隔離され、致命的にはなりません。タイプ2の処理はFObjStmQuarantineにTHPDFObjStmQuarantineInfoのエントリを記録します。コンテナのオブジェクト番号、THPDFObjStmQuarantineReason、診断文字列、そしてクロスリファレンスがそのコンテナに振り分けていたメンバーのオブジェクト番号のリストです。osqrDecryptFailedは4つの異なる状況で立てられます。crypt filterを解決できなかった、AES-256またはAES-GCMの復号が例外を投げた、旧来のRC4またはAES-128の復号が例外を投げた、使用できるファイルキーがまったく存在しない、の4つです。独立したコンテナは読み込みを続けるので、1つのコンテナが壊れた文書でも開け、それに依存しないページはすべて描画されます

読み込み済みPDFに対するHotPDFの復号隔離の仕組み。復号が例外を投げたコンテナはosqrDecryptFailedの理由とメンバーのオブジェクト番号を伴うTHPDFObjStmQuarantineInfoとして記録され、独立したコンテナは読み込みを続け、BeginDocは書き直しが成功を報告する前に最初の失敗エントリで例外を送出します
隔離の記録はパーサのフォールバックを生き延び、BeginDocは暗号化フラグではなく名前でそれらを確認するので、1つのコンテナが壊れた文書は開ける一方で、書き直し経路は空のオブジェクトを書く代わりに停止します

隔離リストはパーサのフォールバックを生き延びます。最初のクロスリファレンス読み込みが失敗し、HotPDFがファイルを走査してオブジェクトテーブルを再構築する場合、最初の試行で得た暗号化フラグはその再構築を生き延びないかもしれませんが、隔離の記録は生き延びます。BeginDocが暗号化フラグではなく隔離リストを確認するのはそのためです。読み込み済み文書ではFObjStmQuarantineをたどり、最初のosqrDecryptFailedエントリで例外を送出し、コンテナを名指しして、有効なパスワードでの再読み込みを求めます。その地点を越えて進んだ書き直しは、そのコンテナが保持しているはずのメンバーを空のオブジェクトとして書き出し、成功を報告してしまいます。同じ検査は、公開アクセサを通して、もっと早い段階で、自分のポリシーで自分で実行できます

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // 空のユーザーパスワード
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // ここから先は書き直して安全
end;

他の隔離理由は暗号以外の失敗をカバーします。ストリームでないコンテナ、辞書の欠落、不正な/Nや/First、受け付け範囲外のストリームサイズ、展開の失敗、データの先を指す/First、そしてデコードはできたが解析できなかったメンバー本体です。これらは取り込み時にログに残す価値があります。それぞれが、下流で欠けることになるメンバーを正確に名指ししてくれるからです

なぜ書き直しに元の数値トークンが必要なのか

HotPDFはすべての数値オブジェクトをSingleとして保持しており、Singleは実数のソーステキストを再現できません。ISO 32000-1 §7.3.3は、同じ値に対して0.750000、.75、0.75のどれを出力してもよいとしていますが、24ビットのバイナリと汎用のフォーマッタを通る往復で、それらが変わらずに残ることはありません。さらに悪いことに、0.7のような値はSingleで表現できません。最も近いfloatとして解析され、そのfloatをフォーマットし直すと、桁のループ次第で0.69999999や丸めた近傍値になります。塗り色や/CAの透明度定数では、それが8ビットチャンネルの1カウントの差になり、元との画素比較を落とすのに十分であり、グラデーションの境界では目に見えるのにも十分です

THPDFNumericObject.RememberSourceTokenが、変更されていない場合についてこれを解決します。パーサはValueを代入した直後に生のトークンを渡してこれを呼びます。このメソッドは数字と、多くても1つの小数点、そして省略可能な先頭の符号だけでできたトークンだけを受け付け、トークンを、それが対応していた値とともにFSourceValueに格納します。SourceTokenプロパティは、ValueがまだFSourceValueと等しい間だけ、格納されたテキストを返します。数値を変えればトークンは消えるので、変更された値は必ず既存のフォーマット経路を通り、古いテキストを出すことはありません。SaveNumericObjectはまずSourceTokenを確認し、存在すればそれを逐語的に書き、メモリ上で生成または編集された数値についてのみ、整数・色空間参照・小数の各分岐へ落ちていきます

この不変条件は小さく、率直に述べておく価値があります。触っていない数値は読み込んだときのバイト列で書かれ、触った数値はHotPDF自身のフォーマッタで書かれます。コンパクトメンバーも本体のオブジェクトと同じようにこの恩恵を受けます。EnsureCompressedObjectLoadedがメンバーのスライスに対して同じパーサを走らせるからです。数値のフォーマットそのものと、それがプロセスのロケールから独立している話は、HotPDFのロケール非依存なPDF数値フォーマットの記事で扱っています

オブジェクトストリームに対して書き直し経路をテストする

上で述べたあらゆる失敗は3つの検査で捕まえられ、どれもAcrobatを必要としません。1つ目は、保存後にIndexedObjectCountとMaterializedObjectCountを比較します。完全な書き直しでは両者が等しくなければならず、差があればそれは落とされたメンバーです。2つ目は、両方のファイルでテキストを抽出し構造ツリーを列挙します。描画するだけではいけません。失われたActualTextや失われた構造要素が差分として現れるようにするためです。3つ目は、出力を新しいインスタンスで読み込み、GetLoadedQuarantinedObjStmCountがゼロであることをアサートします。これは、ライターがリーダーの開けないコンテナを作らなかったことの証明にもなります。FReloadObjectStreamsEncryptedを決めるcrypt filterの組み合わせは、StmF、StrF、EFFのポリシーの記事に整理されています。この話のライター側、つまりオブジェクトストリームの出力方法と、書き直しよりインクリメンタル更新を選ぶべき場面は、オブジェクトストリームとインクリメンタル更新の手引きにあります

遅延メンバー読み込み、ライター前の展開パス、復号の隔離、そしてソーストークンの保持は、DelphiとC++Builder向けのHotPDF Delphi Componentにすべて含まれています。製品ページにはAPIリファレンスへのリンクがあり、GetLoadedObjectStreamCacheInfoや隔離のアクセサを自分の取り込みパイプラインに照らして追いかけられます