技術文章

在 Delphi 中合併 PDF 表單:重複欄位規則

PDF Library for Delphi 合併兩份 AcroForm 文件時,對同名欄位採用明確的政策。MergeDocumentEx 接受來源文件識別碼,以及三種策略之一:dfsReject 拒絕合併,dfsMerge 保留共用名稱並同步數值,dfsAutoNumber 則以確定性方式重新命名傳入的欄位。名稱掃描會在任何物件編號位移之前進行,因此一次被拒絕的合併,會讓兩份文件都完整可用

任何組裝過 PDF 應用套件的人都遇過這個問題。三份表單,各自有一個叫 SignatureDateTotal 的欄位,被合併成一份檔案。在 AcroForm 中,完整限定的欄位名稱就是該欄位的身分識別,因此兩個同名的欄位根本不是兩個獨立欄位:填其中一個就會填到另一個,套用在其中一個上的簽章,涵蓋的範圍會超出任何人的預期

為什麼名稱衝突要在合併之前就判定?

較舊的 MergeDocument 只是把兩份 AcroForm 根欄位陣列串接起來,不提供任何選擇。更糟的是,當結果不可用時,發現的時機是在物件編號已經重編、頁面樹已經縫合之後,讓呼叫端手上握著一份兩份原始文件都不曾處於的狀態

MergeDocumentEx 把順序反過來。它先蒐集兩份文件頂層的欄位名稱、比對,再套用策略,然後才移動任何東西。因此一次拒絕是乾淨的空操作:目標文件不受影響,來源文件不受影響,兩者都保持開啟且可用,合併測試會透過在一次被拒絕的合併之後,從來源文件讀回一個欄位值來驗證這一點

比對使用一個有序、區分大小寫的名稱集合,因此成本與兩份文件欄位總數乘上一個對數因子成正比,而不是與兩者數量的乘積成正比。區分大小寫在這裡是正確選擇,因為 PDF 欄位名稱本身就區分大小寫;若把它們折疊在一起,會合併規範中視為不同的欄位

三種策略,以及各自適用的時機

dfsReject 適合絕不能產生歧義文件的自動化流程。合併會回傳零,LastErrorCode 回報 705,這是一個專屬錯誤碼,讓重複名稱能與其他所有合併失敗區分開來,並導向一種特定的補救方式,通常是在上游重新命名欄位

dfsMerge 刻意保留共用名稱,並把目標值與預設值同步到來源欄位,因此一個符合規範的檢視器會把這幾個 widget 視為同一個邏輯命名的欄位,這是帶有多個 widget 標註的欄位的標準 AcroForm 行為。它不會做的,是把不同的欄位字典折疊成單一物件。每個欄位保留自己的頁面關聯、外觀與動作,因為把它們折疊起來,會悄悄丟失屬於傳入文件的格式與行為

dfsAutoNumber 會在傳入的重複欄位名稱後附加一個從 _2 開始、取第一個空號的數字後綴來重新命名。結果是可重現的:它只取決於現有的名稱,從不取決於欄位物件編號,因此合併同一對文件兩次,兩次得到的名稱結果完全相同。當下游程式碼、FDF 匯入或資料庫對應是依名稱參照欄位時,這個特性就很重要

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.SelectedDocument;
    Lib.LoadFromFile('application-part1.pdf', '');

    SourceDoc := Lib.NewDocument;
    Lib.LoadFromFile('application-part2.pdf', '');

    Lib.SelectDocument(TargetDoc);
    if Lib.MergeDocumentEx(SourceDoc, dfsReject) = 0 then
    begin
      if Lib.LastErrorCode = 705 then
      begin
        // 兩份文件仍完整無損 - 改用其他政策重試
        Log('duplicate field names; retrying with auto-numbering');
        Lib.MergeDocumentEx(SourceDoc, dfsAutoNumber);
      end;
    end;

    Lib.SaveToFile('application-complete.pdf');
  finally
    Lib.Free;
  end;
end;

請留意這段程式碼中的兩步驟模式,這唯有在拒絕不具破壞性的前提下才可能成立。先嘗試嚴格政策,檢視錯誤,再決定。若合併半途失敗,備援方案就得從重新載入兩份檔案重頭開始

合併後的表單長什麼樣子?

dfsMerge 之下,一個名為 Shared、帶有「目標值」的目標欄位,與一個同名的來源欄位,會產生兩個欄位,兩者都叫 Shared,兩者回報的都是目標值,因為目標值與預設值已同步到傳入的欄位。這正是共用名稱的預期語意:一個邏輯欄位、多個 widget、一個值

dfsAutoNumber 之下,同樣的輸入會產生 SharedShared_2 兩個各自獨立、擁有各自數值的欄位。要在兩者之間選擇,只需問一個問題:填其中一個,另一個應該跟著填嗎?對於出現在套件每個部分的簽署人姓名,答案是「應該」,該用 dfsMerge。對於每份表單上代表不同意義的總計欄位,答案是「不應該」,該用自動編號

// 合併之後,列舉你實際得到的結果
for I := 1 to Lib.FormFieldCount do
  Log(Format('%d: %s = %s',
    [I, Lib.GetFormFieldTitle(I), Lib.GetFormFieldValue(I)]));

組裝表單套件的實務提醒

成功的合併會消耗掉來源文件:它會從函式庫的文件清單中被移除,這就是為什麼 DocumentCount 會從二降為一。合併之後不要再使用來源識別碼。文件版本會提升為兩者中較高的一個,因此把一份 PDF 2.0 表單合併進一份 1.7 文件,得到的會是 2.0 檔案

順序對名稱有影響。把 A 併入 B 與把 B 併入 A,會產生不同的自動編號結果,因為執行合併的文件保留自己原本的名稱不變。當一個套件有一份規範性的主要表單時,把那一份設為目標

簽章欄位值得特別考慮。在合併之前套用的簽章,只涵蓋它所簽署的那個修訂版,因此合併會讓它在實際意義上失效,因為檔案自簽署以來已經變動過。應該先組裝,再對組裝完成的文件簽署,而不是合併已簽署的部分。當合併的對象是頁面內容而非表單時,以位元組參照位移實現的快速 PDF 合併 一文所述的更快路徑是更合適的工具

最後,請把套件的資料端與合併一併規劃。若欄位值來自外部系統,在選擇自動編號之前,先確認該系統是否依名稱定址欄位,因為 Shared_2 不會與一個預期 Shared 的對應表相符。匯入匯出格式說明於 FDF、XFDF 與 XFA 表單資料交換 一文,而同樣可能因重新命名而受影響的欄位層級指令碼行為,則說明於 互動式表單動作與 JavaScript 一文

表單合併、資料交換與簽署,都在同一套適用 Delphi、C++Builder 與 Free Pascal 的函式庫中運作;完整功能清單列於 PDF Library for Delphi 頁面