技術記事

DelphiでのPDFのUndo、Redo、クラッシュリカバリ

PDFium Componentは編集ジャーナルを通じて、PDFエディタに真のドキュメントレベルのUndo・Redoと、クラッシュリカバリパッケージを提供する。変更はBeginEditTransactionとCommitEditTransactionで包み、コミットされた各トランザクションは双方向デルタとなり、UndoEditとRedoEditがどちらの方向にも再生できる。SaveRecoverySnapshotはそのジャーナルを永続化するため、セッション途中で強制終了されたアプリケーションでもユーザーの作業を再構築できる

これが埋めるのは特定のギャップである。ネイティブのUndo機能は現在フォーカスのあるフォームフィールドにのみ適用されるため、テキストボックスは自身の編集を記憶するが、それ以外は何も記憶しない。ページの挿入、削除、並べ替え、ページオブジェクトの変更、注釈、添付ファイル、メタデータには一切ジャーナルが存在しない。それだけを基盤に構築されたエディタは、あるフィールド内でのみUndoを提供でき、それ以外の場所では提供できない。これはユーザーがUndoという言葉で意味するものではない

操作ログではなくスナップショットを使う理由

明白な設計は、「ページ4を削除した」「注釈7を移動した」といった意味的な操作を記録し、それらを反転させることである。これは操作の種類が増えるまでは機能するが、増えた時点で新機能ごとに逆操作が必要になり、逆操作が正確でない操作は長いUndoチェーンの中で静かにドキュメントを破損させる

代わりにジャーナルは、トランザクションの境界でドキュメントの状態を捕捉し、その差分を保存する。各デルタは最長共通接頭辞と接尾辞を保持し、削除されたバイト列と挿入されたバイト列の両方を持つため、前方にも後方にも適用できる。新しい編集機能にUndoサポートは一切不要である:それが変更するものは何であれスナップショットの中に含まれる

DelphiにおけるドキュメントレベルのPDF Undoを示す図:PDFium Componentの編集ジャーナルはルートスナップショットの上に双方向デルタを積み重ね、Undo・Redoの再生時に各エントリがSHA-256ダイジェストで保護される
コミットされた各トランザクションは接頭辞、削除されたバイト列、挿入されたバイト列に加え、両方の状態のSHA-256ダイジェストを保存する。そのため1つのレコードが前方にも後方にも再生でき、機能ごとの逆操作は一切不要である

このアプローチは1つの前提条件に依存する。通常のフル保存は非決定的な識別子と時刻フィールドを生成するため、変更していないドキュメントを2回保存しても異なるバイト列が生じ得る。そのためスナップショットはまず再現可能な出力経路を通じて正規化される。この手順がなければ、空のトランザクションが変更として見えてしまい、コミット時に計算したダイジェストがその後のドキュメントと一致しないため、Undoは自身の検証に失敗してしまう

実際のトランザクション

各トランザクションは任意のラベルを取ることができ、これがエディタのUndoメニューに表示されるものになる

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfEditJournalOptions;
  Info: TPdfEditJournalInfo;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.LoadDocument;

    Options := TPdfEditJournalOptions.Default;
    Options.MaxEntries := 50;               // undo depth
    Options.MaxStoredBytes := 256 * 1024 * 1024;
    Pdf.ConfigureEditJournal(Options);

    Pdf.BeginEditTransaction('Delete cover page');
    try
      Pdf.DeletePage(1);
      if not Pdf.CommitEditTransaction then
        raise Exception.Create('commit failed - document was reloaded');
    except
      Pdf.RollbackEditTransaction;
      raise;
    end;

    Info := Pdf.GetEditJournalInfo;
    Writeln(Format('entries=%d position=%d stored=%d bytes',
      [Info.EntryCount, Info.Position, Info.StoredBytes]));

    if Pdf.EditCanUndo then
      Pdf.UndoEdit;                          // pageが復元される
  finally
    Pdf.Free;
  end;
end;

キャプチャに失敗した、予算を超過した、あるいはデルタを構築できなかったといった理由でコミットが失敗した場合、アクティブなトランザクションや書きかけのジャーナルを残すのではなく、BeginEditTransactionで捕捉されたベースラインが復元され再読み込みされる。これは最も重要な失敗モードである。なぜなら、不整合な状態のジャーナルはその後のあらゆるUndoを汚染してしまうからである

すべての段階での検証

各デルタは、変更前後のドキュメントのSHA-256ダイジェストを、接頭辞の長さと両方のペイロードとともに保存する。Undo、Redo、インポートされたジャーナルの再生、逆方向の再構築はいずれも、何かに触れる前に現在のダイジェストと置き換え対象の領域を検査し、その後に結果のダイジェストを検証する

ジャーナル内の移動も同様に慎重である。位置は、対象のPDFが独立したドキュメントオブジェクトによって検証され、現在のものと正常に入れ替わった後にのみ前進する。それに失敗した場合、現在のスナップショットがフォールバックとして復元されるため、失敗したUndoはユーザーを壊れた状態にではなく、元いた場所に留めておく

シリアライズされた形式は入力として信頼されるのではなく、データとして検証される:マジックナンバー、バージョン、エントリと位置の値、ダイジェストチェーン、ラベル長、パッチ長、合計ストレージ予算がすべて検証される。インポートされたジャーナルは切り詰められたり編集されたり別バージョンで生成されたりしている可能性のあるファイルであり、それを敵対的なものとして扱うことだけが唯一安全な姿勢である

ジャーナルが満杯になった時に何が起こるか

3つの上限が増大を制限する:エントリ数、累積デルタバイト数、そして単一スナップショットのサイズである。容量に達すると、最も古いエントリが追い出され、ルートのフィンガープリントが前進する

この最後の部分には設計上考慮すべき帰結がある。エクスポートされたジャーナルは、それが報告するルートフィンガープリント、または記録済みのフィンガープリントに一致するドキュメントにしか適用できない。一度追い出しが起きると、ジャーナルはもはや元のファイルまで遡って到達できなくなる。したがって自動保存は、一致するルートスナップショットとジャーナルを1つのアトミックなリカバリ単位として扱わなければならない——ベースなしにジャーナルだけを保持することは、再生できないものを保持していることになる

クラッシュリカバリパッケージ

リカバリパッケージは意図的にソースPDFを埋め込まない。ソースの表示名、ソースおよび復元後の状態のSHA-256フィンガープリント、そして編集ジャーナルを保存する。したがって復元には元のファイルが必要であり、呼び出し側がまずそれをロードする。その後パッケージはジャーナルを前方・後方に再生することで両方のフィンガープリントを検証する

Delphiにおけるクラッシュ後のPDF編集リカバリの流れを示す図:各コミット後のアトミックなSaveRecoverySnapshotの書き込み、セッション途中でのクラッシュ、そして元のPDFに対するRestoreRecoverySnapshotのフィンガープリント検証を示す
コミットは小さなフィンガープリント封印済みパッケージをアトミックに置き換える。復元ではまず元のPDFを再読み込みし、その後ジャーナルを両方向に再生して記録済みの両方のフィンガープリントを検証する
// commit成功ごとにrecovery fileをrefresh
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// 次のsessionでcrash後:originalをloadしてからrestore
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

ソースを埋め込まないことでリカバリファイルは小さく保たれ、これによってどんなサイズのドキュメントに対してもコミットごとの自動保存が現実的なコストで行えるようになる。ヘッダー、ソース名、ジャーナルを覆う外側のダイジェストがあるため、切り詰められた、あるいは改ざんされたパッケージはどのデルタも解析される前に拒否され、読み取り総量にも上限が設けられている

ファイルの書き込みはターゲットディレクトリ内の一時ファイルへ行われ、その後アトミックに置き換えられる。リカバリパスをソースPDFに設定することは明示的に拒否される。自動保存は、変更を伴う成功したコミット、Undo、Redoの後にのみ実行される。ディスクへの書き込みが失敗した場合、メモリ上のドキュメントとジャーナルの状態はすでに有効であるため、その例外はデータ損失ではなく永続化の失敗を報告し、呼び出し側はパスを修正して再試行できる

署名済みドキュメントはデフォルトで拒否される

トランザクションのスナップショットは元の署名済みバイト列ではなく新しい正規化されたリビジョンであるため、署名済みドキュメントに対してトランザクションをコミットすると署名が無効になる。そのようなドキュメントに対してトランザクションを開始することは、呼び出し側がAllowSignedDocumentを渡さない限り拒否され、ジャーナルのインポートとリカバリ復元もデフォルトでは同様に拒否される

これは禁止ではなく意図的な減速帯である。一部のワークフローは正当に署名済みドキュメントを編集し、後で再署名する必要があり、そのフラグはそうした用途のために存在する。これが防いでいるのは偶発的なケースである。エディタが、ユーザーがまだ署名済みだと信じているドキュメントの有効性を静かに剥ぎ取ってしまう場合である。この話の検証側についてはPDFデジタル署名とPAdESレベルの検査で扱われている

エディタにとって、ジャーナルを役立てる習慣が3つある。ユーザーに見える操作ごとに1つのラベル付きトランザクションで包むこと。そうすればUndoメニューは自分の呼び出しスタックではなくユーザーのメンタルモデルのように読める。エントリ数の上限はドキュメントに応じて調整すること。200MBのスキャンのスナップショットを50個保持するのはささやかな設定ではない。そしてタイマーではなくコミットのたびにリカバリスナップショットを書き出すこと。タイマーでは、失われる作業が最後のティック以降に起きたものすべてになることが確約されてしまう。これによって恩恵を受けるドキュメントレベルの編集操作については暗号化PDFの増分更新で扱われている

編集ジャーナリング、リカバリパッケージ、そしてその基盤となる編集プリミティブは、Delphi、C++Builder、Lazarus向けの同じコンポーネントにまとめられている。完全なAPIはDelphi向けPDFium Componentページに記載されている