HotPDF Delphi Component 透過 THotPDF.SetFormFieldValue 填入已載入 PDF 上既有的 AcroForm 欄位,可用從零起算的欄位索引或完整限定欄位名稱定址。寫入新的 /V 項目是簡單的部分;讓這個呼叫在真實世界的表單上可靠的原因,是它同時讓三項在出錯之前看不見的狀態保持一致:欄位解碼後的身分,這樣非 ASCII 的名稱才找得到;核取方塊與選項按鈕小工具上的 /AS 外觀狀態;以及選擇欄位上的 /I 選取索引陣列。看得見的外觀串流是另一個明確的步驟,透過 EnsureLoadedFieldAppearanceStream
情境再平常不過:客戶把他們自己的表單寄來,一份報稅單、一份保險理賠申請、一張多年前有人用 Acrobat 做的採購單,而您的 Delphi 應用程式必須從資料庫把資料填進去,再交回一個在哪裡都開得正確的檔案。表單當初怎麼被製作的,您完全無法控制。欄位名稱可能是 UTF-16 編碼,核取方塊的匯出值可能是 2 而不是 Yes,而下拉式方塊可能使用 [export display] 選項配對。這些細節每一項在 ISO 32000-1 裡都有規則,而每一條規則現在都是 SetFormFieldValue 替您處理的事。本文談的是它做了什麼、為什麼,以及它在哪裡停下來。至於建立尚不存在的欄位這個姊妹問題,請看在 Delphi 中將 AcroForm 欄位加入已載入的 PDF
為什麼 SetFormFieldValue 找不到名稱含非 ASCII 字元的欄位
在 v2.752.1 之前,答案是編碼:那個欄位在檔案裡是一個十六進位 UTF-16BE 名稱,而名稱快取存的是十六進位拼法而不是文字。ISO 32000-1 §12.7.3.1 把部分欄位名稱 /T 定義成文字字串,而 §7.9.2.2 說文字字串可以是帶前置 FE FF 位元組順序標記的 UTF-16BE。製作工具習慣依 §7.3.4.3 把這類名稱序列化成十六進位字串,所以一個叫 Straße 的欄位送來時是 <FEFF005300740072006100DF0065>。在 HotPDF 內部,只要 IsHexadecimal 設起,THPDFStringObject.Value 裝的就是原始十六進位文字,這對原始字典的無損來回正是您要的,而當成查表鍵則正是您不要的。HPDFLoadedFormTextName 把這兩件事分開。建立關聯快取時,每個 /T 值都會經過它:如果字串物件是十六進位,HPDFHexToBytes 會還原位元組序列;如果位元組以 FE FF 開頭且長度為偶數,payload 就以 UTF-16BE 解碼並重新編碼為 UTF-8;結果接著用句點與它的父名稱串起來,形成 §12.7.3.1 所述的完整限定名稱,所以一個名為 City 的子項在名為 Address 的父項之下,會註冊成 Address.City。快取鍵會正規化成小寫,這讓 SetFormFieldValue('address.city', ...) 也能成功;那是超出標準的便利,因為規格把名稱視為大小寫敏感。關鍵在於變的只有快取鍵。欄位字典裡的 /T 物件保留它的十六進位編碼,所以存檔不會改寫一個您只是填了值的欄位的身分
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;
// 限定名稱從 UTF-16BE 的 /T 字串解碼而來,並以句點
// 串接,所以巢狀與非 ASCII 名稱都解析得到
Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');
// 非 Latin-1 的值以帶 FEFF 前置的 UTF-16BE 十六進位形式
// 傳遞,並寫成 PDF 十六進位字串
Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');
Pdf.SaveLoadedDocument('claim-form-filled.pdf');
finally
Pdf.Free;
end;
end;
SetFormFieldValue 實際寫入了什麼
兩個多載跑同樣五個步驟:定位欄位字典、透過 HPDFSetDictFormValue 寫入 /V、調整選擇索引、把字典標記為 dirty、調整按鈕外觀狀態,最後透過 NoteLoadedFormFieldDirty 記錄欄位索引。最後那一步在表單帶有計算腳本時要緊,因為 dirty 集合正是無參數的 RecalculateLoadedFormFieldsIncremental 多載所消費的東西,用來只重跑那些會遞移讀取到已變更欄位的計算。HPDFSetDictFormValue 本身對它要取代的物件型別很小心。如果既有的 /V 是名稱物件,也就是核取方塊與選項按鈕欄位用來當匯出值的那種,新值就寫成名稱,絕不寫成字串,因為 PDF 名稱在結構上只能是 ASCII。否則它寫入一個字串物件,並檢查您傳進來的值:一個以 FEFF 開頭、長度為偶數、且全部由十六進位數字組成的字串,會被當成 §7.9.2.2 的 UTF-16BE 傳輸形式,並在 IsHexadecimal 設起的情況下儲存,所以它序列化成 <FEFF...> 而不是字面字串 (FEFF...)。上面那行 City 靠的就是這個機制;其他任何字串都以您給的位元組存成字面字串,所以對純 Latin 文字,您就傳純文字
為什麼核取方塊在值改變之後還留著舊的勾
因為對按鈕欄位來說,光靠值並不能決定畫出來的是什麼。ISO 32000-1 §12.7.4.2.3 規定核取方塊小工具帶著一個 /AS 外觀狀態,指名 /AP /N 裡目前顯示的是哪一條串流,而檢視器是從 /AS 上色,不是從 /V。如果您把 /V 改成 Yes,卻讓 /AS 留在 Off,這個檔案在內部就是自相矛盾的,而展平會高高興興地把過期的未勾選外觀烘進頁面,同時表單資料卻說已勾選。ReconcileLoadedButtonAppearanceStates 的存在就是為了補這個缺口:對一個 /FT 為 Btn 的欄位,它會拜訪欄位字典本身與它 /Kids 陣列裡的每一筆項目、從 /AP /N 讀出 on 狀態名稱,並在它與欄位值相符時把 /AS 改寫成那個名稱,否則改寫成 Off
真實表單裡有兩個細節形塑了 v2.752.3 的修法。第一,normal 外觀字典允許只含 on 狀態;§12.7.4.2.3 把 off 外觀命名為 Off,但製作工具經常省略它的串流,讓檢視器什麼都不畫。早先的程式碼在字典少於兩筆項目時就放棄,所以那些單一狀態的核取方塊會默默留著舊的勾。現在的檢查就只是字典非空,而 on 狀態名稱取第一個不是 Off 的鍵。第二,on 狀態名稱是作者選的。真實表單用 2、Yes、On 或某個本地化字詞,所以比對是對實際的鍵做、而且不分大小寫,絕不是對一個硬寫死的 Yes。選項按鈕還多一層曲折,§12.7.4.2.4 有描述:選取狀態住在父欄位的 /V 上,而個別子項擁有小工具、通常自己沒有 /V。所以巢狀的 InheritedButtonValue 輔助函式會沿著 /Parent 鏈往上走最多 64 層,直到找到非空的值,於是每個子項都是與它所屬群組的值相比。把父項設定成某個子項的匯出值,就恰好打開那個子項並關掉每一個兄弟
// 核取方塊:匯出值必須與 /AP /N 裡的 on 狀態鍵相符
// (通常是 'Yes',但真實表單可能是 '2'、'On' 或任何別的字串)
Pdf.SetFormFieldValue('Consent', 'Yes');
// 選項按鈕群組:/V 寫在父項上;每個子項小工具的
// /AS 會被設成它自己的匯出名稱或 Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');
// 清除核取方塊:任何不匹配 on 狀態的值都會得到 /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');
選擇欄位:讓 /I 與 /V 保持同步
對下拉式方塊或清單方塊而言,/V 不是記錄選取狀態的唯一地方。§12.7.4.4 的 Table 231 把 /I 定義成一個由 /Opt 零基底索引組成的陣列,用來指明被選中的項目,而一個檢視器若發現 /I 指向選項 0 卻看到 /V 是選項 3,可能會把錯誤的那一列反白。從 v2.754.1 起,HPDFReconcileChoiceSelection 會在每一次 SetFormFieldValue 呼叫裡執行,並在繼承來的 /FT 是 Ch 時,依新值重建 /I。操作的順序是刻意的。本地 /I 項目會先被刪除,完全不碰它的內容:如果舊陣列是一個與另一個欄位共用的間接物件,就地改動它會破壞另一個欄位的選取狀態,所以這個常式丟掉那筆參照,改成建立一個全新的直接陣列。接著它沿著 /Parent 鏈解析 /Opt,因為選擇選項可能是繼承來的,然後掃描那些項目。裸字串選項直接比對;[export display] 配對則比對它的匯出元素,而少於兩個元素的配對會被跳過。兩邊都會經過 HPDFLoadedFormTextName,所以一個十六進位 UTF-16 的選項能與一個十六進位 UTF-16 的值相符,不需要您把它們拼成一樣。第一次匹配時就寫入一個單元素的 /I 並停止掃描;純量值一律取代先前的多重選取,不管 MultiSelect 旗標怎麼設
當什麼都不匹配時,就完全不寫 /I。對可編輯的下拉式方塊來說那是正確結果,§12.7.4.4 允許使用者在選項清單之外輸入值;這樣的值沒有索引,而過期的索引比沒有更糟。如果您對一個配對式選項清單傳入的是顯示標籤而不是匯出值,得到的也是這個結果,所以當一個下拉式方塊拒絕顯示您的選取時,檢查一下您給的是配對的哪一半
// /Opt 是 [[US United States] [CA Canada] [MX Mexico]]:
// 比對匯出值,於是 /I 變成 [1]
Pdf.SetFormFieldValue('Country', 'CA');
// 可編輯下拉式方塊帶一個落在 /Opt 之外的值:/V 會寫入,
// /I 被移除,也不會捏造任何索引
Pdf.SetFormFieldValue('Title', 'Principal Engineer');
值與外觀是兩個獨立的操作
SetFormFieldValue 從不碰文字或選擇欄位的外觀串流。呼叫之後,/V 裝著新文字,而 /AP /N 仍然畫著舊的,檢視器顯示兩者之中哪一個,取決於 AcroForm 字典是否依 §12.7.3.3 帶著 /NeedAppearances true,以及該檢視器是否尊重它。如果您需要檔案在每個閱讀器裡都渲染出新值,包括那些忽略這個旗標的展平工具與縮圖產生器,就用欄位索引呼叫 EnsureLoadedFieldAppearanceStream。它會從繼承來的 /DA 字串、/Q 對齊方式、/MaxLen comb 佈局與值建出一個 Form XObject,透過 AcroForm 的 /DR 資源解析具名字型,好讓 Type0 字型保住自己的後裔字型而不是退化成 Helvetica,並在至少有一個小工具收到串流時回傳 True。SetFormFieldValue 的依名多載不會回傳索引,所以要透過 GetFormField 取一個,它回傳一個由您擁有、必須自行釋放的 THPDFLoadedFormField。針對 v2.752.1 那次改動的回歸測試套件對這個切分講得很清楚:它設一個值、呼叫 EnsureLoadedFieldAppearanceStream、然後渲染頁面並檢查小工具矩形內的像素變了而矩形外的沒變。驗證 /V 改了,對使用者會看到什麼毫無證明力
var
Field: THPDFLoadedFormField;
begin
Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
Field := Pdf.GetFormField('Applicant.FullName');
try
// 把新值畫進 /AP,讓忽略 /NeedAppearances 的
// 檢視器仍然顯示得出來
if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
raise Exception.Create('No widget rectangle to paint into');
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;
在您以此為基礎建置之前該知道的限制
ReconcileLoadedButtonAppearanceStates 測試的是您所定址字典的本地 /FT,所以它作用在選項按鈕父項、或作用在自帶 /FT 的核取方塊上;一個被單獨定址的子項小工具,若 /FT 只在父項上,就不會經由那條路徑被調整。HPDFReconcileChoiceSelection 處理單一純量值,最多寫入一個索引;有多筆選取項目的多重選取清單方塊,超出 SetFormFieldValue 所建模的範圍。兩個常式都不會拿您傳的值去對 /Opt 或對 on 狀態鍵做驗證,所以打錯字會產出一個 Off 的核取方塊或一個沒有索引的下拉式方塊,而不是例外。而 GetFormFieldValue 回傳的是字典裡原樣躺著的 /V 文字,對一個十六進位編碼的值而言,那意味著十六進位拼法,不是解碼後的文字
值填進去、外觀也畫好之後,兩個自然的下一步就位在這個操作的兩側。與外部系統批次交換欄位資料,而不是一次一個 SetFormFieldValue 呼叫,是在 Delphi 中匯入與匯出 XFDF涵蓋的事。而當填好的表單定案、不該再被編輯時,在 Delphi 中展平 AcroForm 與 XFA 欄位會把這裡所述的 /AS 狀態與外觀串流烘進靜態頁面內容,這也是為什麼在展平之前先讓它們一致不是選項
本文所述的已載入表單編輯 API,包括 SetFormFieldValue、EnsureLoadedFieldAppearanceStream 與增量重算圖,都是 HotPDF Delphi Component 的一部分,支援 Delphi 與 C++Builder