技術文章

在 Delphi 中替換 PDF 頁面且不破壞書籤

替換一份已簽署合約中的第 3 頁,不應該連帶動到目錄。若是先刪除舊頁面再插入新頁面,每一個原本指向該處的書籤,現在都會落在別的地方。PDFlibPas Delphi PDF 函式庫避開了這個問題,做法是保留目標頁面物件本身,只搬移承載視覺內容的條目

為何替換 PDF 頁面後書籤會失效?

書籤會失效,是因為 PDF 的目的地(destination)是透過間接物件參照來指名頁面,而不是透過頁碼。ISO 32000-1 §12.3.2.2 將明確目的地定義為一個陣列,其第一個元素是指向頁面物件的間接參照。刪除該物件並附加一個替代頁面之後,這個參照就變成了懸空參照:大多數檢視器的反應方式是把讀者丟回第 1 頁,這正是使用者在先刪除、後插入的替換方式之後回報的典型症狀。頁面樹看起來完美無缺,頁數也對,渲染結果也對,唯獨整個導覽層在悄悄地出錯

具名目的地(named destination)也救不了你。§12.3.2.3 透過文件目錄中的 /Dests 名稱樹來路由一個名稱,但該名稱最終解析到的葉節點,仍然是一個持有相同頁面參照的明確目的地陣列。具名機制只是在頁面參照之上多加了一層間接性,而不是把它包起來繞過去。同樣的道理也適用於 §12.5 所描述的其餘互動層:連結註解帶有 /Dest 或帶有 /D 為該陣列的 /A GoTo 動作,每一個註解都可能帶有指向其頁面的間接參照 /P 條目,而表單欄位元件本身也是站在完全相同立足點上的一種註解。一次天真的頁面替換,會同時讓四個子系統斷裂,如果你想在真實檔案上看到它們被逐一列舉出來,大綱與註解內省一文 走訪的正是同一張物件圖

哪些頁面條目承載身分,哪些承載外觀

頁面字典混雜了兩種條目,而原地替換之所以能成功,關鍵就在於把這兩種條目區分開來。外觀這一側是有限且可列舉的:/Contents/Resources、五個頁面方框 /MediaBox/CropBox/BleedBox/TrimBox/ArtBox,再加上 /Rotate/Group/UserUnit/BoxColorInfo。這十一個條目決定了光柵化器(rasteriser)為該頁面產生的一切內容,而檔案中沒有其他任何東西會以名稱指向它們

身分這一側,則是文件其餘部分所綁定的對象:頁面物件編號與世代、指回頁面樹的 /Parent 反向連結,以及 /Annots。PDFlibPas 讓這些全部保持原封不動。ReplacePageRanges 會把目標頁面字典裡的十一個視覺條目清除,再從匯入的來源頁面重新加入,所以目標頁面物件是被原地修改,而不是被替換掉。§7.7.3 所要求的頁面樹結構,其形狀也保持逐位元組相同:/Kids 順序、/Count,以及每一個倖存的 /Parent,操作前後都完全一致,因為根本沒有任何節點被解除連結

PDFlibPas 如何在不重新編號物件的情況下替換頁面?

這個呼叫接受一個來源文件、一個從 1 起算的目標起始頁、一個來源範圍運算式,以及一個選項旗標。兩份文件都必須在同一個實例中開啟,而目標文件則是目前所選取的那一份。由於目標頁數永遠不會改變,你所要求的範圍必須從 TargetStartPage 開始,能夠完全落在文件內,而這項檢查會在建立任何東西之前先行進行

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

在內部實作上,來源頁面無法單純跨越文件邊界直接讀取,因為它們內部的每一個間接參照,都屬於來源文件自己的物件編號體系。所以來源範圍會先以一般方式匯入,成為附加在最後一個真實頁面之後的暫時頁面,這個過程會執行完整的物件圖重新映射:內容串流、字型、XObject、漸層與色彩空間全都會重新編號到目標文件裡。唯有到了這一步之後,那十一個視覺條目才會從每個暫時頁面複製到對應的目標頁面上,也唯有到了這一步之後,暫時頁面才會從頁面樹中解除連結。重新映射這項工作,會在便宜又安全的地方進行,而破壞性的編輯,則被壓縮成僅是在已存在的頁面上做字典層級的替換

那條會摧毀你剛剛搬移內容的刪除路徑

移除那些暫時頁面,是一個看起來微不足道、實則不然的步驟。函式庫裡一般的頁面刪除路徑,做的事情不只是解除節點連結:它會合併被刪除頁面的各層內容、清空第一個內容串流,並回收沒有其他頁面共用的資源。這在真正的刪除情境下是正確的行為,但在這裡卻是災難性的,因為到了移除暫時頁面的那一刻,目標頁面早已直接參照著那些內容串流與資源物件。清空它們會讓你剛剛替換好的頁面變成空白,而資源清掃動作也會回收此刻其實已經有存活擁有者的字型與影像

解決方法是在內部刪除路徑上,加入一個「保留已參照物件」模式。啟用這個模式後,刪除動作會同時跳過非共用資源的清掃與內容串流的清空,除了把頁面從頁面樹上分離、並修正頁面樹的簿記資訊之外,什麼都不做。被搬移的物件會以新擁有者的身分存活下來,操作完成後的物件擁有權關係,就跟你會畫在白板上的圖一樣簡單:一個內容串流、一個擁有它的頁面、一個從未變動過的物件編號。頁面建立、刪除與重新排序相關的生命週期規則,另外在 文件與頁面生命週期操作一文 中有說明

順序、重複,以及全有或全無的失敗方式

選項旗標決定了來源範圍要如何被解讀。0 會把解析出來的頁碼排序並移除重複項,這是呼叫端傳入類似 '4-6,2' 這種寫法、單純想表達那四個頁面時的合理預設值。1 則會保留你所寫下的順序,並允許頁面重複出現,所以 '2,1,2' 真正的意思是:從兩個來源頁面取出三次替換內容。驗證動作會最先執行、而且會完整執行:範圍語法、每個頁碼是否落在來源頁數之內、選項值本身,以及目標容量,全部都會在建立任何一個物件之前檢查完畢。一次被拒絕的呼叫,會把 LastErrorCode 設為 412、還原先前選取的頁面,並讓文件保持原本一模一樣的狀態

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

原子性不只涵蓋驗證階段,也延伸到搬移動作本身。在第一個來源頁面被匯入之前,範圍內每個目標頁面的十一個視覺條目,都會先被快照為已編碼的數值。如果匯入失敗,或是匯入的頁數與要求的不符,這些快照就會被解碼回目標頁面上,並移除暫時頁面,因此即使在半途失敗,原始的視覺內容仍然會保留在其原本的物件上。這一點的重要性遠超過聽起來的樣子:一份合約裡半替換完成的頁面範圍,比一次直接失敗的呼叫還要糟糕,因為檔案裡沒有任何東西會標示出它只做了一半

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

原地替換仍然無法為你做到什麼?

來源的註解、來源的表單欄位,以及來源的大綱,都是刻意不匯入的。若把一個元件搬過來卻不帶上它的 /AcroForm 欄位條目,或是把一個帶有標記內容的註解搬過來卻不帶上它在結構樹中的歸屬關係,會產生一個半匯入的互動物件,沒有任何檢視器能正確理解它,所以這項操作只搬移外觀。實務上的結果是:如果替換頁面本應帶有新的表單欄位或新的連結,你得在事後把它們加到目標頁面上──而那個目標頁面物件依然在原地,等著它們

還有兩個邊界值得你在自己的檔案上檢查一下。第一,/Annots 會被保留,但頁面幾何尺寸不會,所以把一個 220 mm 的頁面替換成 320 mm 的頁面時,註解矩形會保留在舊座標上,落在一個尺寸不同的 /MediaBox 裡;如果幾何尺寸有變動,就需要重新為保留下來的註解定位。第二,十一個視覺鍵以外的條目,依設計會留在目標頁面上,這對 /Trans/AA 而言是對的,但對 /Thumb 而言則會過時,所以替換完成後應重新產生縮圖。已標記(tagged)文件還需要多想一層:結構元素仍然透過 /Pg 指向正確的頁面物件,但它們的標記內容識別碼所描述的內容卻已經不在了,所以在 PDF/UA 工作流程中替換頁面,既是內容編輯,也是結構樹編輯。如果你真正要做的其實是合成,而不是替換──也就是把美術素材疊加到你保留的頁面上──頁面拼接與範本做法一文 提供了更省事的工具

本文所描述的一切,包括範圍運算式語法、選項值,以及周邊的頁面操作 API,都隨附於標準版 PDFlibPas Delphi PDF Library(適用於 Delphi 與 C++Builder)之中,其參考文件收錄了頁面替換呼叫及其錯誤碼的完整條目