技術記事

DelphiでPDF名前ツリーと番号ツリーを編集する

リーフ配列にパッチを当てて1つの名前を追加しても、そのエントリは依然として存在しないと読まれることがあります。PDFium ComponentはこれにTPdfTreeEditorで応えます。ISO 32000の名前ツリーと番号ツリーのための汎用エディタで、厳格な検証を伴ってツリーをロードし、メモリ内でエントリを編集し、正しい/Limitsを持つバランスの取れたノードセットを再構築し、検証済みのインクリメンタルリビジョンを書き出します

このユニットを作る動機になった失敗は、バイトを読むことでしか診断できない種類のものです。あるツールがキーと値をリーフの/Names配列に追加し、新しいリビジョンを書き出し、どの16進ダンプを見てもキーはファイル内に存在します。それでもAcrobatは添付ファイルが存在しないと言います。素朴な意味では何も壊れていません。単に、検索アルゴリズムが降りていけないツリーになっただけです

パッチを当てたリーフ配列が検索を壊す理由

検索がリーフの走査ではなく、/Limitsを通る二分探索だからです。ISO 32000-1 §7.9.6は、名前ツリーを次のように定義しています。/Kidsが/Namesを持つリーフへと降りていくルートで、/Namesはキーと値を交互に並べた配列、キーはツリー全体でバイト比較による順序が付き、ルート以外のすべてのノードは/Limitsとして自身の最小キーと最大キーを宣言します。準拠したリーダーは、求めるキーを各キッドの範囲と比較し、ちょうど1つの子を開きます。リーフにキーを追加して古い/Limitsをそのままにしておくと、降下は実際にそのキーを持つノードを素通りします

PDFium ComponentのPDF名前ツリー検索の図:リーダーはバイト比較でキッドの/Limits範囲を二分探索します。古い/Limitsがまだ(f)で終わっているリーフにinvoice-2026-09のようなキーを追加しても、そのリーフが開かれることはなく、降下は代わりに(g) (z)のキッドを取り、添付ファイルはファイル内にあるのに存在しないと読まれます
検索は求めるキーを各キッドの範囲と比較してちょうど1つの子を開くため、/Limitsを更新せずにキーを得たリーフは素通りされます

番号ツリー(§7.9.7)も/Numsと整数キーで同じ形をしており、実際の交通量を担っています。§12.4.2の/PageLabelsは番号ツリーで、§7.7.4のカタログの/Namesカテゴリは、/Dests、/EmbeddedFiles、/JavaScriptを名前ツリーとして提供します。したがって4つの不変条件が、1つずつではなく同時に成立しなければなりません。キーはツリー順で厳密に増加し、すべてのレベルが実際のキー範囲と一致するリミットを公開し、兄弟の範囲が重ならず、ルートのオブジェクト番号と世代は正確に元の場所に留まる必要があります。カタログと他のすべての参照者が間接参照でそのルートを指しているからです。DelphiでPDF添付ファイルを扱うに関するノートは、同じ構造の消費者側を示しています

TPdfTreeEditorがロードを拒否するもの

予想以上に多く、そしてそれは意図的です。TPdfTreeEditor.TryLoadはTPdfSparseDictionaryReaderを通じてノードグラフを歩き、どのリビジョンがたまたまアクティブかを受け入れるのではなく、参照が指名する正確な世代でReadDictionaryを使ってすべての子を解決します。整形式であることを証明できないツリーは、1つの編集も許される前に拒否されます。壊れた入力が、もっともらしく見える壊れた出力になるのを防ぎます

  • サイクルと重複参照。どちらもptviCycleとして報告され、チェーンを上に指し戻す/Kidsエントリは再帰ではなく終了します
  • 混在したノード形状:/Namesや/Numsと一緒に/Kidsを持つ辞書はptviInvalidNodeShapeで、空の非ルートリーフも同様です
  • ソートされていない、または重複したキー。ptviUnsortedKeyとptviDuplicateKeyとして報告され、1つのリーフの中ではなく走査全体でチェックされます
  • 実際に存在するキーと食い違うリミット。ptviInvalidLimitsとして報告されます
  • 正確に1つの完全なPDFオブジェクトではない生の値。ptviInvalidValueとして報告されます
  • 予算超過:ptviDepthLimit、ptviNodeLimit、ptviEntryLimit、ptviDeadlineExceeded。制約はTPdfTreeEditOptionsと中央のパーサ予算の両方です

TPdfTreeEditOptions.Defaultは、これらのツリーローカルな上限を、1,000,000エントリ、100,000ノード、深さ64、リーフあたり64エントリ、ノードあたり32キッドに設定します。リーフとキッドの数値は単なるガードではありません。後でツリーを再構築するときに使われるファンアウトであり、レコードが両方が少なくとも2であることを検証する理由もそこにあります

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { カタログ /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { 値は1つの完全なPDFオブジェクトの生バイト }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { 下のセーブの後に Editor.Free }
  end;
end;

エントリ値は生のPDFオブジェクトバイトのまま

エントリ値はそのまま保存されます。これは、自分が作っていないファイルに対してエディタを安全に向けられるようにする、唯一の設計判断です。TPdfTreeEntryはNameKey、NumberKey、RawValueを保持し、RawValueは1つの完全なPDFオブジェクトの生バイト列です。辞書、配列、文字列、ストリーム参照、あるいは裸の間接参照です。Delphiのvariant、TStrings、型付きレコードには何もデコードされません。その見返りは、ユニットが何も知らない値のロスレスラウンドトリップです。ベンダー拡張を持つ/Filespec、珍しいフィットタイプのデスティネーション配列、プライベートキーを持つ/JavaScriptアクション辞書——これらはすべて、エディタが内容について意見を持ったことがないため、再構築をバイト単位で生き延びます。また、変更APIを証明可能なものに縮小します。UpsertNameとUpsertNumberは、与えられた値が正確に1つの完全なオブジェクトとしてパースできることだけを検証すればよく、RemoveName、RemoveNumber、Clearはエントリリストにしか触れません

ルートに番号を振り直さずにバランスの取れたツリーを再構築する方法

1つの不動点を持つ2パスです。BuildBalancedObjectsはまず、ソートされたエントリリストを最大MaxLeafEntriesのリーフに切り分け、次に各レベルを最大MaxKidsPerNodeの親に折り畳んで、単一のノードが残るまで繰り返します。リミットはボトムアップで計算され、リーフは最初と最後のキーを取り、内部ノードは最初の子の下限と最後の子の上限を取るため、兄弟範囲の重複は構造的に発生しません

不動点はアイデンティティです。最終的なルートは常に、ツリーがロードされたときのオブジェクト番号と世代を受け取るため、カタログ参照は有効なままで、ドキュメント内の他の何も書き直す必要がありません。他のすべてのノードは、ノード種別が一致し、完全なキー範囲が一致する場合にのみ、以前にロードされたノードの参照を再利用し、そうでない場合はアクティブな最大オブジェクト番号に1を足した新しい番号を割り当てます。この組み合わせは、変更されていないサブツリーを既存のオブジェクトに指したまま保ち、内容が動いたノードは静かに再解釈されるのではなく新しいオブジェクトとして書かれることを保証します。同じ参照再利用の規律が、兄弟ユニットのアウトラインディタとそのページリマップを駆動しています

PDFium ComponentのTPdfTreeEditor再構築の図:BuildBalancedObjectsはソートされたエントリリストを最大MaxLeafEntriesキーのリーフに切り分け、各レベルを最大MaxKidsPerNodeの親に折り畳み、/Limitsを最初と最後のキーからボトムアップで計算し、完成したルートにツリーがロードされたときの正確なオブジェクト番号と世代を与えます
リーフは最初と最後のキーからリミットを取り、内部ノードは最外側の子から取り、最終的なルートはツリーがロードされたときのオブジェクト番号と世代を保持します
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumberはソースリビジョンから来る }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;

リビジョンを書いて、結果を読み戻す

セーブは、出力が再オープンされて比較されたときにのみ完了します。SaveIncrementalはソースリビジョンの事実を読み、ドキュメントカタログそのものであるルートを拒否し、バランスの取れたノードセットとカタログオブジェクトをシリアライズし、すべてをAppendPdfSparseUpdateに渡します。これは既存の/Info参照とドキュメントの/ID配列を保持しながら、§7.5.6に従って新しいリビジョンを追加します

次に来るのが、自分のライターにコピーする価値のある部分です。エディタはTPdfTreeEditor.TryLoadを再度呼び出します。今度は宛先ストリームに対して、同じルート参照と同じオプションで呼び出し、再ロードされたエントリ数とすべてのキーとRawValueを、書き込もうとしたものと比較します。再ロードが失敗するか、どの比較が食い違っても、宛先ストリームは返す代わりにリセットされるため、部分的に書かれたリビジョンが成功として渡されることは決してありません。検証は、スパースな遅延PDFオブジェクトインデックスに関する記事で説明したのと同じ境界付きスパース経路で出力を読みます。これが、大きなファイルでもチェックのコストを手頃に保つ仕組みです

PDFium Componentの検証付きインクリメンタルセーブの図:SaveIncrementalはバランスの取れたノードセットをシリアライズし、AppendPdfSparseUpdateは/Infoとドキュメントの/ID配列を保持して新しいリビジョンを追加し、その後TPdfTreeEditor.TryLoadが宛先ストリームを再読み込みしてエントリ数とすべてのキーとRawValueを比較し、半分書かれたリビジョンを返す代わりにストリームをリセットします
セーブは、出力が同じ境界付き経路で再オープンされ、エントリごとに比較されたときにのみ完了し、比較の失敗は宛先ストリームをリセットします
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { 宛先はすでにリセット済み。半分書かれたものは何も保持されない }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

暗号化ソースは拒否される——それが正直な対応

現在の実装は、暗号化されたソースを完全に拒否します。その理由は防御的ではなく具体的です。ツリーの再構築は新しい文字列オブジェクトを導入し、新しいストリーム参照を導入する可能性があり、§7.6はそれらをドキュメントキーと所有オブジェクトの番号・世代でオブジェクトごとに暗号化することを要求します。/Encryptトレーラーを前方にコピーしながら平文文字列を追加すると、開くことはできるが新しいキーがある場所で正確にガベージとして読まれるファイルができあがります。明確な拒否より悪い結果です。さらに2つの境界を率直に述べておきます。エディタはソースストリームのスナップショットに対して動作するため、エディタが生存している間はソースを変更してはいけません。また、ドキュメントカタログをツリールートとして扱うことを拒否します。カタログはツリーノードそのものではなく、たまたまツリーを含む辞書だからです。/EmbeddedFiles、/Dests、/JavaScript、/PageLabelsを書くDelphi、C++Builder、Lazarusコードをメンテナンスしていて、リーフ配列に手でパッチを当ててきたなら、ツリーエディタとその検証付きインクリメンタルセーブはPDFiumPas Delphi PDFium componentに同梱されています