技術文章

Delphi 跨檔案 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 給您一個正確的閉包,把結構性的重新掛接留給呼叫方,這與保留物件編號地替換頁面所遵守的是同一條邊界

PDFlibPas CopyForeignObject 在 Delphi 中的修正:明確的 InProgress 測試在對應表查詢之前執行,循環反向邊變成 null 物件,之後由呼叫方把複製出的頁面重新鏈入目標頁面樹
明確的進行中狀態取代了被超載的 Nil,於是反向邊解析為 null,留給呼叫方一項結構性修復

為什麼對應項目必須在 NewObjRef 之前預留

一個顯而易見的替代方案可以繞開整個進行中狀態的麻煩:先配置一個空殼物件,把它真正的編號登記進對應表,等子物件複製完再填入內容。這在這裡行不通,因為 TPDFIndObj.Obj 是唯讀的,其內容在建構後無法替換——根本沒有可填的殼。編號與內容由 NewObjRef 一起決定,這代表對應項目必須在遞迴呼叫之前建立、在之後補完,而這兩個時刻之間的區間正是 InProgress 必須涵蓋的範圍。在您 diff 輸出之前還有一個後果值得知道:因為 NewObjRef 在子閉包寫完後才執行,目標文件中的編號是由下往上產生的,物件編號不會映照來源順序。檔案格式對此毫不在意,但拿手工建立的期望值做位元組比對會在意。如果某次執行留下您決定不鏈入任何地方的物件,它們是未被參照,而非損毀,對不可達 PDF 物件的標記-清除回收就是在儲存前清掉它們的工具

涵蓋這個行為的回歸測試有一個細節,會讓第一次為 TPDFlib 寫測試的人感到意外:建構函式已持有一個預設文件,所以 DocumentCount 從 1 開始,雙文件的測試夾具必須斷言 >= 2 而非 = 2。除了成功的複製之外,測試還固定了三種拒絕——未知的來源 ID、以選取中文件作為自己的來源、物件編號為零——全部回傳 0 而非拋出例外,因為合併迴圈不是發現守衛子句會拋例外的好地方

這在合併管線中的位置

物件層級複製是當整檔合併太粗糙時您會用到的原語:從範本中單獨取出一套字型程式、把單一表單 XObject 拉進蓋章文件,或把一則註解連同其外觀串流跨檔搬移而不拖著頁面其餘部分走。PDFlibPas 把它暴露為對已載入文件的單一呼叫,您可以在 PDFlibPas Delphi PDF Library 參考文件裡看到它與其餘低階物件 API 的關係