技術文章

Delphi 中的 PDF 復原、取消復原與當機恢復

PDFium Component 透過編輯日誌,為 PDF 編輯器提供真正文件層級的復原與取消復原功能,外加一套當機恢復機制。你用 BeginEditTransaction 與 CommitEditTransaction 包住每次變更,每個已提交的交易都會成為一筆雙向差異記錄,供 UndoEdit 與 RedoEdit 朝任一方向重放。SaveRecoverySnapshot 會把這份日誌持久化,讓一個在工作階段中途被強制終止的應用程式,得以重建使用者的工作內容

這填補的是一個明確的落差。原生的復原能力只適用於目前擁有焦點的表單欄位,因此一個文字方塊只會記得自己的編輯內容,其餘一概不記得。頁面的插入、刪除與重新排序、頁面物件變更、附註、附件與中繼資料,全都完全沒有日誌可言。只靠這一層打造的編輯器,只能在欄位內部提供復原功能,別的地方一概不行,而這並不是使用者心目中的「復原」

為何用快照而非操作日誌?

顯而易見的設計是記錄語意層級的操作——「刪除了第 4 頁」、「移動了附註 7」——再把它們反轉回去。這種做法在操作集合還小的時候行得通,可是一旦操作集合成長,每個新功能都需要自己的反向操作,而任何反向操作不夠精確的操作,都會在一長串復原鏈中悄悄毀損文件

日誌改採在交易邊界擷取文件狀態,並只儲存其中的差異。每筆差異記錄都保留最長共同前綴與後綴,並同時攜帶被移除與被插入的位元組,因此可以正向或反向套用。新的編輯功能完全不需要額外的復原支援:它們改動的任何內容,本來就都在快照之內

示意圖:Delphi 中的文件層級 PDF 復原機制,PDFium Component 的編輯日誌在一個根快照之上堆疊雙向差異記錄,每筆項目在復原與取消復原重放時,都以 SHA-256 摘要值把關
每個已提交的交易都儲存前綴、被移除與被插入的位元組,加上前後兩種狀態的 SHA-256 摘要值,因此單一筆記錄就能正向或反向重放,不需要每項功能各自實作反向操作

這種做法有一項先決條件。一般的完整儲存會產生不確定性的識別碼與時間欄位,因此把同一份未變更的文件儲存兩次,也可能得到不同的位元組內容。因此快照會先經過可重現輸出路徑進行正規化。少了這一步,一個空的交易看起來就會像是一次修改,而復原也會在自身的驗證上失敗,因為提交當下計算出的摘要值,會與事後的文件不相符

實務中的交易

每筆交易都可以附上一個選用的標籤,這正是編輯器在復原選單中顯示的內容:

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;               // 復原深度
    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;                          // 頁面會恢復回來
  finally
    Pdf.Free;
  end;
end;

提交失敗時——無論是因為擷取失敗、超出額度,還是差異記錄無法建立——都會還原至 BeginEditTransaction 時擷取的基準狀態並重新載入,而不是留下一個仍在進行中的交易,或一份寫到一半的日誌。這正是最重要的失敗模式,因為一份處於不一致狀態的日誌,會毒害後續每一次復原

每一步都經過驗證

每筆差異記錄都儲存變更前後文件的 SHA-256 摘要值,連同前綴長度與兩份負載內容。復原、取消復原、匯入的重放,以及反向重建,都會在動手變更之前先檢查目前的摘要值與即將取代的區域,並在事後驗證結果摘要值

在日誌中移動同樣十分謹慎。只有在目標 PDF 已由一個獨立的文件物件驗證通過、並成功取代目前文件之後,位置才會前進。若這一步失敗,目前的快照會作為備援還原,因此一次失敗的復原,會讓使用者停留在原本的位置,而不是陷入損壞狀態

序列化格式是被當成資料來檢查,而不是被當成可信的輸入:識別碼、版本、項目與位置值、摘要值鏈、標籤長度、修補長度與總儲存額度,全部都會被驗證。一份匯入的日誌,可能來自一個已被截斷、被編輯過,或由不同版本產生的檔案,把它當成具敵意的輸入來對待,是唯一安全的態度

日誌被填滿時會發生什麼事

三項限制約束了日誌的成長:項目數量、累計差異位元組數,以及單一快照的大小。一旦達到容量上限,最舊的項目就會被淘汰,根指紋也會隨之前進

最後這一點有個值得特別設計因應的後果。一份匯出的日誌,只能套用到與它所回報的根指紋(或它記錄過的某個指紋)相符的文件上。一旦發生過淘汰,這份日誌就再也追溯不回原始檔案了。因此自動儲存必須把相符的根快照與日誌視為一個不可分割的復原單位——只保留日誌而沒有其對應的基準,等於保留了一份根本無法重放的東西

當機恢復套件

恢復套件刻意不內嵌來源 PDF。它儲存的是來源檔案的顯示名稱、來源狀態與恢復後狀態的 SHA-256 指紋,以及編輯日誌。因此恢復操作需要原始檔案,由呼叫端先行載入;套件接著會透過正向與反向重放日誌,驗證這兩個指紋:

Delphi 中 PDF 編輯的當機恢復流程示意圖:每次提交後原子式寫入 SaveRecoverySnapshot、工作階段中途當機,以及 RestoreRecoverySnapshot 對照原始 PDF 進行指紋驗證
每次提交都會以原子方式取代一份以指紋封印的小型套件;恢復時會先重新載入原始 PDF,再透過正向與反向重放日誌,驗證兩個已記錄的指紋
// 每次成功提交後,都重新整理恢復檔案
Pdf.BeginEditTransaction('Redact account numbers');
if Pdf.CommitEditTransaction then
  Pdf.SaveRecoverySnapshot('contract.recovery');

// 下一個工作階段,當機之後:先載入原始檔案,再進行恢復
Pdf.FileName := 'contract.pdf';
Pdf.LoadDocument;
Pdf.RestoreRecoverySnapshot('contract.recovery');

不內嵌來源檔案讓恢復檔案得以保持精簡,這正是無論文件多大,都能負擔得起「每次提交都自動儲存」的原因。一個外層摘要值涵蓋標頭、來源名稱與日誌,因此一個被截斷或遭竄改的套件,會在任何差異記錄被剖析之前就被拒絕,總讀取量也有上限

檔案寫入會先寫到目標目錄中的暫存檔,再以原子方式取代,而把恢復路徑設定為來源 PDF 本身,則會被明確拒絕。自動儲存只會在一次成功的、有變更的提交、復原或取消復原之後執行。若磁碟寫入失敗,記憶體中的文件與日誌狀態早已有效,因此例外回報的是持久化失敗,而不是資料遺失,呼叫端可以修正路徑後重試

已簽署的文件預設會被拒絕

交易快照是一份新的正規化修訂版本,而不是原始已簽署的位元組內容,因此對已簽署的文件提交交易,會使簽章失效。除非呼叫端傳入 AllowSignedDocument,否則在這類文件上開始交易會被拒絕,日誌匯入與恢復還原預設也採用相同的拒絕原則

這是刻意設下的減速丘,而不是全面禁止。有些工作流程確實需要合法編輯已簽署文件、之後再重新簽署,這個旗標就是為了它們而存在。它要防止的是意外情況:編輯器悄悄剝奪了一份使用者仍以為已簽署有效的文件的有效性。這件事在驗證面的說明,涵蓋於檢視 PDF 數位簽章與 PAdES 等級一文

對編輯器而言,三個習慣能讓這套日誌真正發揮價值。把每一項使用者可見的動作,包在一筆有標籤的交易之中,這樣復原選單讀起來會符合使用者的心智模型,而不是你的呼叫堆疊。依文件本身調整項目數量上限,因為為一份 200 MB 的掃描檔保留五十份快照,絕不是一個保守的設定。並且在每次提交時就寫入恢復快照,而不是靠計時器,因為計時器保證會遺失的,正好就是上次觸發之後所發生的一切工作。能從中受益的文件層級編輯操作,涵蓋於加密 PDF 增量更新一文

編輯日誌、恢復套件與背後的編輯基本操作,在 Delphi、C++Builder 與 Lazarus 中同屬一個元件;完整 API 說明請見PDFium Component for Delphi 頁面