技術記事

ドキュメント横断のPDFオブジェクトコピー:サイクルによるクラッシュ

PDFを手作業で統合し、単一のページオブジェクトをターゲット文書へ移動すると、コピーはそのままアクセス違反に突入します。PDFlibPasはこれをCopyForeignObjectで修正しました。一つの間接オブジェクトとその参照クロージャ全体をディープコピーし、/Parentのような循環する逆参照は再帰せずにnullへ解決します

ドキュメントをまたぐページのコピーがなぜクラッシュするのか

PDFページツリーは、下へ読むときだけ木だからです。再帰コピー機がたどるように、すべての辞書のすべての値を追って歩くと、ページ辞書は/Parentを差し出します。それは到達元の/Pagesノードを指し、そのノードはページへ逆戻りする/Kidsを差し出します。ISO 32000-1 §7.7.3はルートを除くすべてのページツリーノードに/Parentを必須としているため、これは拒否できる不正ファイルではなく、渡されるすべての文書が持つ正常な形です

問題の後半は番号付けです。間接オブジェクトは一つのファイルにローカルなオブジェクト番号で識別されるため(ISO 32000-1 §7.3.10)、文書Aから文書Bへ引きずり込んだオブジェクトは番号の振り直しが必要で、コピーしたクロージャ内の参照もすべて同じ方法で振り直さなければなりません。そうしないと、一つの共有フォントを指していた二つの参照が、無関係な二つのものを指すようになります。この番号の振り直しは、高速マージがバイトレベルで行うのと同じ仕事であり、両者を並べて読む価値があります。高速PDFマージのためのバイトレベル参照シフトはファイル全体を翻訳することで解決し、オブジェクトレベルのコピーは一つ一つのエッジごとに解決しなければなりません

Delphiでのドキュメント横断PDFコピーに注意が要る理由:ページ辞書とその/Pagesノードが/Parentと/Kidsで循環を閉じ、フォントのクロージャは下向きに走って終端し、PDFlibPasはファイルローカルなオブジェクト番号をすべて再マップします
ページツリーは/Parentと/Kidsでループを閉じ、コンテンツのクロージャは終端します。コピーされたオブジェクト番号はすべて、その途中で再マップされなければなりません

PDFlibPasのCopyForeignObjectが実際にコピーするもの

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber)は、一つの間接オブジェクトとそこから到達可能なすべて(ネストした辞書、配列、文字列、名前、数値、そして辞書がそのまま付いたストリーム)を、現在選択中の文書へクローンし、新しい間接参照への非ゼロのハンドルを返します。ソースのオブジェクト番号は、呼び出しの間だけ保持されるライブマップを通じて再マップされるため、クロージャ内で二度到達されたオブジェクトは一度だけクローンされ、二度共有されます。ソース文書IDが不明な場合、ソースが選択中の文書自身である場合、ObjectNumberが1未満の場合には、例外を投げずにゼロを返します

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFileは成功時に1を返す
    SourceDoc := Lib.SelectedDocument;   // ロードしたものが選択される
    Lib.SelectDocument(TargetDoc);       // コピーは選択中文書へ書き込まれる
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

最初の実行で人を噛む詳細が二つあります。LoadFromFileは1か0を返すのであって文書IDではないため、必要なハンドルはロード直後にSelectedDocumentから取得します。そしてコピーは、SelectDocumentが最後に現行にした文書へ常に書き込まれ、ロード元の文書へは決して書き込まれません。内部的に再帰はさらに64というハード深度上限を担いますが、これは病的なネストに対するバックストップであって、循環を扱う仕組みではありません。循環処理は別個に、意図的に設けられています

Nilマッピングの予約はなぜ循環を断ち切れないのか

マッピング表のNilが同時に二つの異なる意味を持ち、コードはその二つを区別できないからです。循環への自明な防御は、オブジェクトへ再帰する前にマップエントリを追加し、逆戻りしてくるものがエントリを見つけて停止するようにすることです。しかしエントリはまだ実際のターゲットを保持できません。下のクロージャが書き終わるまでターゲットは存在しないからです。そこでエントリはNilを保持し、逆エッジを捕捉するはずのルックアップはNilを読んで、オブジェクトはマップされたことがないと結論します

// 壊れている:予約済みのNilターゲットは「まだマップされていない」と区別できない
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // 予約済み、まだNil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // 戻り際にだけ埋め戻される
end;

これをページのループで追ってみます。ページのクローンは/Parentに到達し、/Pagesノードへ再帰し、そこから/Kidsに至り、再びページへ再帰して戻ります。ページの予約エントリはまだNilを読むため、二度目にクローンされ、三度目にもクローンされ、各レベルが新しいフレームと、半分だけ組み上がった新しいオブジェクトを積みます。観察されるのは綺麗なスタックオーバーフローでもありません。外側のフレームは、ターゲットが一度も代入されなかった参照の上に座っており、そのスロットの一つを通る最初の書き込みが、原因となったページコピーとは似てもつかない場所でアクセス違反になるのです

PDFlibPasのドキュメント横断コピーでNilのマップターゲット予約が循環を止められない理由:ルックアップは予約済みエントリと未マップのエントリを区別できず、コピー機は書き込みがクラッシュするまで、どんどん深い半完成フレームを降りていきます
Nilターゲットは二つの異なる問いに同時に答えるため、逆エッジは決して認識されず、ページは毎回の通過で再クローンされます

修正:明示的な進行中状態

修復は、Nilの多重利用をやめて問いを直接尋ねることです。ターゲットがまだ未代入のマップエントリはこのオブジェクトは現在クローン中であることを意味し、InProgress述語が通常のルックアップが走る前にまさにそれを判定します。真であれば、そのエッジは現行クローンの祖先への循環逆戻りであり、PDFlibPasはそれを追わずにnullオブジェクトを生成します

// Nilターゲットを持つマップエントリは進行中のクローンを示す
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... CloneObject内、間接参照の場合:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // 循環する逆エッジ、再帰しない
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // 宙づりのソース参照
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // 予約してから再帰
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // 埋め戻し
end;
Exit(NewRef);

これを一般化して安全なのは、PDFに関する構造的事実があるからです。オブジェクトグラフの循環は逆リンクに現れ、コンテンツのエッジには現れません。ページツリーの/Parentとアウトライン鎖の/Prevは、すでに訪問済みのものへ上向きまたは後ろ向きに指します。フォント、画像XObject、フォームXObjectのクロージャは下向きに走って終端します。したがって、フォント記述子、カラースペース、シェーディング辞書のコピーはnull置換の影響を受けません。これらのクロージャ内の何ものもInProgressに当たらないからです。コストを率直に言えば、循環エッジはコピーを生き延びません。この方法でクローンされたページ辞書は、/Parentがnullオブジェクトのまま届きます。ISO 32000-1 §7.3.9はこれをエントリ不在と等価とするため、コピーされたページは、ターゲットの/Pagesノードに自分でリンクして/Countを修正するまで、どのページツリーにも属さない有効なオブジェクトです。コピーされたアウトライン項目も同じように/Prevを失い、兄弟鎖の再構築が必要です。これが正直なトレードです。CopyForeignObjectは正しいクロージャを与え、構造的な親子付け替えは呼び出し側に残します。オブジェクト番号を保ったままページを置換する処理が守るのと同じ境界です

Delphi向けPDFlibPas CopyForeignObjectの修正:明示的なInProgress判定がマップルックアップの前に走り、循環する逆エッジはnullオブジェクトになり、呼び出し側が後からコピーしたページをターゲットのページツリーへ再リンクします
明示的な進行中状態が多重利用されていたNilに取って代わり、逆エッジはnullへ解決され、呼び出し側に残るのは一つの構造修復だけになります

マップエントリをNewObjRefの前に予約しなければならない理由

分かりやすい代替案なら、進行中の面倒全体を回避できるでしょう。空のシェルオブジェクトを先に確保し、実際の番号をマップに登録し、子がクローンされた後でシェルを埋めるのです。ここではそれは機能しません。TPDFIndObj.Objは読み取り専用で、構築後に内容を差し替えられないため、埋めるべきシェルが存在しないからです。番号と内容はNewObjRefが一体として決めるため、マップエントリは再帰呼び出しの前に作成され、その後に完成しなければならず、この二つの時点の間隔こそInProgressがカバーすべき区間です。出力をdiffする前に知っておくべき帰結があります。NewObjRefは子クロージャが書かれた後に走るため、ターゲットの番号付けはボトムアップになり、オブジェクト番号はソースの順序を映しません。ファイル形式は何も気にしませんが、手作りの期待値とのバイト比較は気にします。実行の結果、どこにもリンクしないと決めたオブジェクトが残ったなら、それは壊れているのではなく参照されていないだけであり、保存前にそれらを一掃する道具が到達不能PDFオブジェクトのマーク・アンド・スイープ回収です

これをカバーする回帰テストには、TPDFlib向けテストを書く人が驚く詳細が一つ要ります。コンストラクターはすでに既定文書を保持しているため、DocumentCountは1から始まり、二文書のフィクスチャは= 2ではなく>= 2を表明しなければなりません。成功するコピーと並べて、テストは三つの拒否(不明なソースID、自分自身をソースとする選択中文書、ゼロのオブジェクト番号)を固定し、すべてが例外ではなく0を返すことを確認します。マージループは、ガード節が例外を投げると発見するには悪い場所だからです

マージパイプラインにおける位置づけ

オブジェクトレベルのコピーは、ファイル全体のマージが粗すぎるときに手を伸ばすプリミティブです。テンプレートから一つのフォントプログラムを引き抜く、単一のフォームXObjectをスタンプ用文書へ引き込む、ページの残りを引きずらずに外観ストリームとともに注釈をファイル間で移動する、といった作業です。PDFlibPasはこれを、ロード済み文書に対する単一の呼び出しとして公開しており、低レベルオブジェクトAPIの残りとどう並ぶかはPDFlibPas Delphi PDF Libraryのリファレンスで確認できます