把一整區表單欄位從去年的範本移到今年的版面,正是 FDF 與 XFDF 往返不再夠用的地方:值到了,外觀串流、計算動作與預設資源卻沒到。PDFiumPas 以 GraftPdfAcroForm 回應這種情況——它把整個欄位物件圖從一個 PDF 複製出來,寫進另一個 PDF
資料層級匯出做不到這件事,原因是結構性的。欄位不是一筆記錄,它是一個子圖。ISO 32000-1 §12.7 定義容納 /Fields、/CO、/DR 與 /DA 的互動表單字典,§12.7.3 定義掛在它底下的欄位字典,§12.5.6.19 定義讓那些欄位在頁面上有個可見方框的小工具註記。XFDF 攜帶的是這個結構的葉子。嫁接攜帶的是結構本身
為什麼只複製 /Fields 陣列永遠不夠
把 /Fields 從一個文件複製到另一個,會產生一份在每個有意思的面向上都壞掉的表單,因為這個陣列只裝著間接參考,別無他物。ISO 32000-1 §7.3.10 讓間接物件以物件編號加上世代編號定址,而那些編號只在它們所來自的檔案內有意義。把陣列貼過去,裡頭每個參考不是懸空,就是更糟——悄悄解析到目的地檔案中恰好佔住那個槽位的無關物件。每個參考底下坐著的,是一個既共用又帶循環的圖。欄位字典指向它的子節點,每個子節點指回它的 /Parent;小工具經由 /P 指向它的外觀串流與承載它的頁面;外觀串流指向表單預設資源字典中的字型;/AA 底下的額外動作字典又指向更多物件。不同頁面上的兩個小工具經常共用一個字型與一個外觀 XObject。所以正確的嫁接必須走訪該圖、把每個可達物件恰好複製一次、把每個小工具的 /P 重新指向對映後的目的地頁面,並把複製的小工具加進該頁的 /Annots 陣列——否則欄位存在於表單中,在頁面上卻看不見。如果您曾追查過欄位、它的小工具,以及顯示它的頁面註記三者之間的差異,我們關於小工具索引對上註記索引的筆記涵蓋的正是那個分野
GraftPdfAcroForm 需要您提供什麼?
它需要三個各自獨立的串流與一份明確的頁面對映。GraftPdfAcroForm 接受分開的 TStream 執行個體 Source、Destination 與 Output、一個 TPdfGraftPageMappings 陣列、一個 TPdfAcroFormGraftOptions 記錄、一個選填的 TPdfCrossDocumentGraftMap,以及一個 out 參數 TPdfAcroFormGraftReport。它回傳 Boolean 而非擲出例外,失敗時報告在 ErrorMessage 中帶著原因。頁面對映兩端都是一基,而且不會用推斷的:每個承載您打算嫁接之小工具的來源頁,都必須出現在對映中。對嫁接對映表傳 nil 是正當用法——此時函式會在這次呼叫期間建立並釋放一個私用的對映表——而 TPdfAcroFormGraftOptions.Default 給您的是:CollisionPolicy 設為 pagcpReject、RenamePrefix 設為 Imported_、MaxObjects 為 100000、MaxDepth 為 128,以及 AllowSignedDestination 設為 False。最後三項是預算,它們存在,是因為您即將走訪的物件圖來自一個不是您寫的檔案
uses
Classes, SysUtils, FPdfCompress;
var
Source, Destination, Output: TMemoryStream;
Options: TPdfAcroFormGraftOptions;
Mappings: TPdfGraftPageMappings;
Report: TPdfAcroFormGraftReport;
begin
Source := TMemoryStream.Create;
Destination := TMemoryStream.Create;
Output := TMemoryStream.Create;
try
Source.LoadFromFile('claim-template-2025.pdf');
Destination.LoadFromFile('claim-layout-2026.pdf');
Source.Position := 0;
Destination.Position := 0;
Options := TPdfAcroFormGraftOptions.Default;
SetLength(Mappings, 2);
Mappings[0].SourcePageNumber := 1;
Mappings[0].DestinationPageNumber := 1;
Mappings[1].SourcePageNumber := 2;
Mappings[1].DestinationPageNumber := 3;
if GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, nil, Report) then
Output.SaveToFile('claim-2026-with-fields.pdf')
else
raise Exception.Create(Report.ErrorMessage);
finally
Output.Free;
Destination.Free;
Source.Free;
end;
end;
嫁接對映表如何避免把共用字型複製兩次?
TPdfCrossDocumentGraftMap 持有一張來源到目的地的參考表,其鍵同時帶著物件編號與世代編號,遞迴複製器在下降之前會先查詢它。操作順序正是循環安全的關鍵:複製器先配置目的地物件編號並登錄對映,然後才走訪來源物件的子參考。父節點若碰到一個指回父節點的子節點,會發現父節點早已登錄,於是回傳既有的目的地參考,而不是繼續遞迴。同一次查詢,也讓被六個小工具共用的字型、外觀串流或動作只被複製一次、被參考六次。這張對映表以來源位元組的 SHA-256 雜湊與來源文件繫結,以 SourceIdentity 暴露。如果您交給 GraftPdfAcroForm 一張身分與您傳入的來源不符的對映表,它會拒絕這次呼叫,而不是重用從未對此檔案有效的參考。頁面對映在複製開始前就播種進同一張對映表,這正是小工具的 /P 最後會指向目的地頁面的方式:來源頁物件早已解析到對映後的目的地頁物件,所以普通的參考改寫行程就能處理它,無需任何特例
uses
Classes, SysUtils, FPdfCompress, FPdfSha256;
var
GraftMap: TPdfCrossDocumentGraftMap;
SourceBytes: TBytes;
EntriesBefore: Integer;
begin
SetLength(SourceBytes, Source.Size);
Source.Position := 0;
if Length(SourceBytes) > 0 then
Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));
GraftMap := TPdfCrossDocumentGraftMap.Create(
AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
try
EntriesBefore := GraftMap.Count;
Source.Position := 0;
if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, GraftMap, Report) then
begin
// 這次呼叫加入的條目已被回滾;
// 它之前登錄的任何東西都完好如初
Assert(GraftMap.Count = EntriesBefore);
WriteLn('graft refused: ', Report.ErrorMessage);
end;
finally
GraftMap.Free;
end;
end;
那個回滾,正是您自己持有對映表的意義。PDFiumPas 以交易方式看待呼叫端提供的對映表:失敗的嫁接會丟棄該次呼叫加入的條目,保留事先已存在的每個對映,所以一次拒絕絕不會留下一個「指向從未寫出之物件」的參考快取。不過,請為每個目的地文件保留一張對映表——每個條目的目的地端是那個特定檔案中的物件編號,在另一個檔案裡毫無意義
欄位名稱衝突:拒絕或重新命名
完整限定的欄位名稱在表單內必須保持唯一,當它們衝突時,PDFiumPas 不會猜您的意思。TPdfAcroFormCollisionPolicy 恰好提供兩種答案。在預設的 pagcpReject 下,第一個標題已存在於目的地的來源欄位,會讓整個嫁接帶錯中止,並讓輸出串流保持空白。在 pagcpRename 下,衝突的來源欄位會加上 RenamePrefix 前置詞重新命名,嫁接繼續進行,Report.RenamedFieldCount 會告訴您這事發生了幾次
Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;
if GraftPdfAcroForm(Source, Destination, Output, Mappings,
Options, nil, Report) then
begin
WriteLn('source fields : ', Report.SourceFieldCount);
WriteLn('existing fields: ', Report.DestinationFieldCount);
WriteLn('grafted fields : ', Report.GraftedFieldCount);
WriteLn('renamed fields : ', Report.RenamedFieldCount);
WriteLn('cloned objects : ', Report.GraftedObjectCount);
WriteLn('reused objects : ', Report.ReusedObjectCount);
WriteLn('mapped pages : ', Report.MappedPageCount);
WriteLn('output bytes : ', Report.OutputByteCount);
end
else
WriteLn('graft refused : ', Report.ErrorMessage);
重新命名不是免費的,您應該刻意決定它,而不是為了讓錯誤消失就伸手拿它。被重新命名的欄位是另一個欄位:目的地中任何按名稱指涉它的 JavaScript、/CO 中任何人針對舊名寫下的計算條目,以及任何以欄位名稱為鍵的下游消費者,都需要知道這個前置詞。如果兩份文件確實描述同一個欄位,誠實的修法通常是在上游對齊名稱,而不是在嫁接時。嫁接落地之後,走訪合併後的表單以確認您實際得到什麼,是自然的下一步,PDFiumPas 的表單欄位導覽涵蓋了那趟走訪
嫁接刻意故障封閉之處
每個曖昧狀況都是錯誤,絕不是盡力而為的結果;這是一個值得在實務上讓您吃驚之前先理解的設計決策。GraftPdfAcroForm 遇到以下任何一種狀況,就回傳 False、重設輸出串流,並回報原因
- 來源表單帶有
/XFA條目——XFA 封包是平行的表單模型,無法化約為 AcroForm 欄位字典 - 某個小工具所在的來源頁在頁面對映中沒有條目,否則它會悄悄丟掉該欄位,或把它附到錯誤的頁面
- 頁面對映超出範圍,或兩個對映重用了同一個來源頁或目的地頁
- 兩份表單都定義了預設資源字典
/DR,因為合併兩個資源名稱空間可能讓既有名稱被改指向不同的字型 - 物件圖超過
MaxObjects,或遞迴超過MaxDepth - 目的地含有簽章,而
AllowSignedDestination為False - 提供的嫁接對映表屬於不同的來源文件,或某個來源參考懸空
寫入路徑同樣保守。PDFiumPas 把結果以附加在目的地之後的稀疏增量修訂版輸出,然後把寫出的結果重新具體化、重新讀取其表單:如果結果的欄位數不等於目的地原有欄位數加上來源欄位數,整個嫁接就被拒絕,輸出也被清空。您永遠不會得到一個只嫁接了一半的檔案。這項政策的代價是真實的——/DR 衝突或已簽章的目的地會直接擋下您,您得自己解決,而不是接受一個合併後的近似品——但替代方案是一份開起來正常、算出來卻錯的表單
當嫁接是錯誤的工具時
嫁接移動的是結構,所以當您缺的就是結構時用它。如果兩份文件都已帶著同一組欄位,而您只需要在它們之間移動值與註記,XFDF 表單資料一文中的匯出匯入路徑更輕、更標準,也可逆。當目的地完全沒有欄位、或帶著不同的一組欄位,而您需要小工具、外觀串流、動作與計算順序完好地過來時,再伸手拿 GraftPdfAcroForm。關於身分,最後一點實務提醒:由於嫁接對映表以物件編號加世代編號為鍵,並與來源位元組的 SHA-256 繫結,在兩次執行之間重新儲存或最佳化來源,會產生不同的身分與一張不再適用的對映表。對您嫁接所依據的來源做快照,並在整個批次中保持它穩定;把它當成輸入產物,而不是夜間作業可以隨意改寫的東西
GraftPdfAcroForm、TPdfCrossDocumentGraftMap 以及周邊的串流層級 PDF 工具箱,隨適用於 Delphi、C++Builder 與 Lazarus 的 PDFiumPas Delphi PDFium 元件出貨;產品頁面附有嫁接選項、報告欄位與其餘文件編輯介面的完整 API 參考