200ページのハンドブックから7ページを落とすと、すべてのしおりがどこかおかしな場所に着地します。解決策は、フラットなタイトルリストからしおりを組み立て直すことではありません。PDFiumPasはTPdfOutlineEditorを公開します。本物のしおりツリーを読み込み、項目の移動と再ターゲットを可能にし、それからApplyPageMapを実行して、すべての明示的デスティネーションをページ計画に沿ってシフトします
ページを削除するとなぜすべてのしおりが壊れるのか
しおり項目はページ番号を保存しないからです。ページオブジェクトへの参照を保存しており、ページオブジェクトが変わると、参照は移動したページを指すか、まったく何も指しません。ISO 32000-1 §12.3.2.2は明示的デスティネーションを、最初の要素がページ辞書への間接参照で、その後に/Fitや/XYZのようなフィット名が続く配列として定義します。ページを削除すればダングリング参照が残り、ページを並べ替えれば参照はまだ有効ですが今は別の章を記述します。PDFiumPasは読み込み時にその配列をページ番号へ解決するので、TPdfOutlineItem.PageNumberはオブジェクト番号ではなく、公開TPdf APIと一致する1始まりのページインデックスを与えます。それがこの抽象化の要点です。再マッピングロジックは、文書を分割、並べ替え、面付けしたときにすでに組み立てたページ計画と同じ座標系で動きます。その計画を組み立てているなら、同じ1始まりの規約がPDF文書の複数ファイルへの分割とn-up面付けとページ並べ替えを貫いています
しおりはリストではなく双方向リンクのツリー
タイトルのフラットな配列を単純に直列化できない理由は、ISO 32000-1 §12.3.3がすべてのしおり項目を5つの別個のリンク——/Parent、/Prev、/Next、/First、/Last——に結線するからです。したがって1つのサブツリーを動かすと、旧親、新親、切断点と挿入点の両側の隣接兄弟、そして移動ノード自身の親ポインタを書き換えることになります。1つでも間違えると、準拠リーダーは切り詰められたツリーかループを表示します。PDFiumPasは編集状態を、安定した整数Idを持つTPdfOutlineItemレコードの深さ優先配列として保持します。だからサブツリーは連続したスライスであり、兄弟チェーンは導出されるもので、手で保守されることは決してありません。TPdfOutlineEditor.Moveはそのスライスを持ち上げ、要求された兄弟インデックスで新しい親の下へ再挿入し、ブロックのルートだけを再割り当てします。さらにグラフを壊す2つの移動を拒否します。項目を自分自身のサブツリーへ移すことと、存在しない親を名指すことです
/Countはなぜ符号付きなのか
符号がサイズではなく展開状態を運ぶからです。正の/Countは項目が開いていることを意味し、数は現在外に見える子孫の数です。負の/Countは項目が折りたたまれていることを意味します。PDFiumPasは子を持つすべての項目に子孫数を書き込み、IsOpenがFalseのときはそれを符号反転し、読み込み時にはIsOpen := HasCount and (CountValue > 0)として状態を読み戻します。これはしおりライターで最もよくある手作りバグです。符号なしカウントを出力し、ツリー全体を静かに強制展開する
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000、MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // ルートの2番目の子になる
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // 負の /Count を書き込む
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retargetは仕様が許す両方の形を扱います。DestinationInActionにFalseを渡せばPDFiumPasは直接の/Dest配列を書き、Trueを渡せばISO 32000-1 §12.6.4.2に従ってGo-Toアクション、/A << /S /GoTo /D [ page ref suffix ] >>を書きます。いずれの場合もまず項目から既存の/Destと/Aを剥がし、2つが共存して矛盾できないようにします。サフィックスはデフォルトで/Fitで、PDF名で始まらなければなりません。だから空または不正なサフィックスは、どのリーダーも解析できないデスティネーション配列を作る代わりに、即座に例外を投げます
ApplyPageMapはページ計画をどう消費するか
ApplyPageMapは、ページ計画がすでに検証済みのそのままの配列を受け取ります。NewPageNumbersは旧ページマイナス1でインデックスされ、新しい1始まりページ番号、またはそのページが生き残らなかった場合はゼロを保持します。項目配列を後ろから歩くので、サブツリーの削除がまだ訪れていないインデックスを無効化することはなく、結果はRemappedDestinationCountとRemovedDanglingItemCountで報告されます
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// 元の文書のページごとに1エントリ
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == このページは落とされた
NewPageNumbers[0] := 1; // 旧ページ1 -> 新ページ1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // 旧ページ10 -> 新ページ3
// True: ダングリングサブツリー全体を削除。False: 項目を残しターゲットを剥がす
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
DeleteDanglingフラグはゼロにマップされたデスティネーションのポリシーを決め、両方の分岐が意図的です。TrueならPDFiumPasは項目とそのサブツリー全体を削除します。ターゲットが消えたしおりノードは、たいていそれと一緒に消えた章の先頭だからです。Falseなら、項目はタイトルと階層を無傷で保ちながら/Destと/Aだけが剥がされて生き残ります。レビューで人が再ターゲットするつもりならこちらが欲しいものです。本当に不正な入力は継ぎ接ぎされず大音量で失敗します。負のエントリや提供マップの終端を超えるデスティネーションは、IssueKindをpoviInvalidPageMapに設定してFalseを返します
不透明なエントリと正直なトレードオフ
すべてのしおり項目がPDFiumPasが推論できるページ番号を持つわけではありません。3種は手つかずで運ばれます。名前付きデスティネーション、/S /GoToではないアクション、ファイルを作ったものが追加した未知の辞書キーです。これらはPageNumberがゼロのまま読み込まれ、項目内に元のバイトを保持し、明示的にRetargetを呼ばない限りそのまま書き戻されます
- 名前付きデスティネーションは文書名ツリーへのキーです。正しく再マップするとは、ツリーを解決しターゲットエントリを書き換えることであり、しおりレベルでの当て推量ではありません
/URI、/Launch、JavaScriptのアクションにはページ意味論がまったくなく、静かにGo-Toへ変換されてはなりません- ベンダー固有キーと構造デスティネーションは保持されます。理解していないものを落とすことがラウンドトリップでのデータ喪失のしかただからです
代償は現実であり、率直に述べる価値があります。ApplyPageMapはこれらの項目を完全にスキップします。だからしおりがすべて名前付きデスティネーションを使う文書は、ページ削除を通ってもしおりが構造的に有効なまま意味的に古びて出てきます。これは意図的な選択です。レビュアーが捕捉できる古いリンクは、誰も気づかない自信満々の間違いに勝ります。編集前に入力ファイルをトリアージしているなら、PDFインテイクレビューワークベンチのインベントリパスが、どの文書がその分類に入るかを教えます
保存: インクリメンタルリビジョン、そして独立した再読み込み
TPdfOutlineEditor.SaveIncrementalはファイルを書き直すのではなく、疎なインクリメンタルリビジョンを追記します。読み込まれた項目は正確なジェネレーションを含む元の間接オブジェクト参照を保持するので、既存の相互参照は有効なままです。新しい番号を引くのは追加した項目だけで、リビジョンの最大オブジェクト番号の1つ後から割り当てられます。カタログは同じリビジョンで更新され、ソースにしおりがまったくなかった場合は欠けていた/Outlinesエントリが追加されます
書き込みの後に起きることが、模範的な部分です。PDFiumPasは宛先ストリームを完全に独立したエディタで再オープンし、再読み込みしたツリーをメモリ内のツリーと比較します。項目数、タイトル、ページ番号、デスティネーションサフィックス、アクション対直接デスティネーションの形式、スタイル、展開状態、親関係です。不一致が1つでも、読み込み失敗が1つでもあれば、宛先ストリームはクリアされ、それらしく見えるファイルを渡す代わりにpoviVerificationFailureが返されます。暗号化ソースは冒頭でpoviEncryptedInputとして拒否されます。新しいタイトルとデスティネーションは、/Encryptトレーラを先送りコピーするだけでは作れない文字列コンテンツを作るからです
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
しおりをそれが何であるか——独自の不変条件を持つリンクされたオブジェクトグラフ——として扱えば、ページ削除はしおりの惨事ではなくなり、1つのメソッド呼び出しに渡すページマップになります。TPdfOutlineEditor、ApplyPageMap、検証済みインクリメンタルライターは、v3.98.0からDelphi、C++Builder、Lazarus向けのPDFiumPasに同梱されます。完全なAPIの確認とトライアルのダウンロードはPDFium Delphi Componentの製品ページでできます