在幾乎任何 PDF 檢視器的欄位清單裡,德語理賠表單的街道欄位顯示為 Straße。其 /T 條目背後的位元組講的是另一個故事:<FEFF005300740072006100DF0065>,一串帶位元組順序記號前置的十六進位 UTF-16BE 字串。拿欄位名稱去比對這些原始位元組的程式碼,會回報欄位不存在,即使使用者能直接點進去。同一份表單可能把核取方塊的 on 狀態存成 2 而不是 Yes,國家清單存成 [export display] 成對值——您寫入的值與檢視器畫出的值,就此有了三種分道揚鑣的方式
這三種變化出自同一處:表單是幾年前用客戶手邊的製作工具建的,如今您的 Delphi 應用程式得從資料庫填它、再交回一份在每個閱讀器裡渲染一致的檔案。表單不可能重做,所以填寫程式碼必須遵守表單當初的成文規則。ISO 32000-1 對三種情況各有一條規則:文字字串見 §7.9.2.2、按鈕外觀狀態見 §12.7.4.2.3、選擇項目見 §12.7.4.4 的表 231。建立尚不存在的欄位是另一件事,見 在 Delphi 為已載入 PDF 新增 AcroForm 欄位
找到欄位:HotPDF 先解碼 /T 再比對名稱
THotPDF.SetFormFieldValue 有兩個多載,一個收從零起算的欄位索引,一個收完整限定名,兩者都對照元件首次使用時從 AcroForm 樹建好的名稱表解析。建表的時候,每個部分名稱都過一道解碼器:十六進位 /T 字串先轉回位元組;若位元組以 FE FF 開頭且長度為偶數,其餘部分按 UTF-16BE 讀出、再編成 UTF-8。解碼後的部分名稱以句點接上父欄位,與 §12.7.3.1 的描述一致,所以名為 Address 的父欄位底下名為 City 的子欄位,會註冊成 Address.City。被解碼的只有查找鍵。檔案裡的 /T 物件保持十六進位原貌,存檔也不會動到您只填了值的欄位的身分
鍵進表之前先轉小寫,所以 'address.city' 找得到 Address.City。這超出了標準——標準把欄位名稱視為區分大小寫——也代表兩個只有大小寫不同的欄位會在查找時撞名。對從人工輸入的設定檔讀欄位名稱的程式碼來說,漏一個大寫比同一份表單裡兩個只差大小寫的欄位常見得多
解碼後的名稱是裝在 AnsiString 裡的 UTF-8 位元組,而 Delphi 自己的字串轉換正好在這裡攪局。'Applicant.Straße' 這樣的字面值傳進 AnsiString 參數時,會經系統 ANSI 字碼頁轉換,ß 到了那頭變成單一位元組 DF,而不是 UTF-8 的 C3 9F,查找就落空。純 ASCII 名稱不受影響。其他情況請一次性列舉欄位、用 UTF8ToString 解碼每個名稱、走索引多載寫入:
// FindFieldIndex 是您自己加的輔助函式:拿解碼後的 UTF-8 名稱
// 與 Unicode 字串比對,字面值與查找之間就不會隔著
// ANSI 字碼頁
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
I: Integer;
begin
for I := 0 to Pdf.GetFormFieldCount - 1 do
if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
Exit(I);
Result := -1;
end;
var
Pdf: THotPDF;
Street: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;
// ASCII 名稱可以直接走 by-name 多載
Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
Street := FindFieldIndex(Pdf, 'Applicant.Straße');
// 值一樣是 AnsiString,字面 'ß' 會看系統字碼頁臉色;
// FEFF 十六進位(Hauptstraße 12)可避開
if Street >= 0 then
Pdf.SetFormFieldValue(Street,
'FEFF00480061007500700074007300740072006100DF0065002000310032');
// 同樣帶 FEFF 前置的 UTF-16BE 十六進位也能配 by-name
// 多載(這個是 München);以十六進位字串寫入
Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');
Pdf.SaveLoadedDocument('claim-form-filled.pdf');
finally
Pdf.Free;
end;
end;
SetFormFieldValue 寫了什麼,逐步看
找到欄位之後,兩個多載跑同一套流程:寫 /V、重建選擇索引、標記欄位已變更、切換按鈕外觀狀態、重建文字或選擇外觀,並把欄位記進髒集合。最後這步餵給無參數的 RecalculateLoadedFormFieldsIncremental,它只重跑讀到已變更欄位的計算腳本——直接讀,或經列項、小計、合計這類鏈條讀
新 /V 的物件型別取決於欄位。核取方塊與單選鈕,SetFormFieldValue 一律寫名稱物件,因為 §12.7.4.2.3 把選中狀態存成名稱,而 PDF 名稱就是純位元組、沒有文字字串編碼。其餘欄位都拿字串物件,傳入的值先被檢查:以 FEFF 開頭、長度偶數、只含十六進位數字的字串,視為 §7.9.2.2 的 UTF-16BE 形式,序列化成 <FEFF...>。上面的 City 那行靠的就是這個。其他任何值都以字面字串儲存、位元組原樣照收,所以純拉丁文字就以純文字進去
為什麼核取方塊在值改變之後還留著舊的勾
對按鈕欄位來說,值決定不了畫什麼。核取方塊控件帶 /AS 條目,指明 /AP /N 裡目前顯示的是哪條串流,檢視器照 /AS 上色,不看 /V。/V 改成 Yes 而 /AS 還說 Off,檔案就自相矛盾:壓平器會把未勾選的框烤進頁面,表單資料卻聲稱已勾選。所以 SetFormFieldValue 會走訪按鈕欄位與 /Kids 陣列裡的每個條目,從每個控件的 /AP /N 讀出 on 狀態名稱,與欄位值相符就把 /AS 設成該名稱、不符就設 Off
真實表單會在兩個地方彎折這條規則。off 外觀在標準裡叫 Off,製作工具卻常常根本不留它的串流、讓檢視器什麼都不畫,所以只有單一條目的 normal 外觀字典完全合法。on 狀態名稱則隨作者高興:Yes、On、2、表單自己語言裡的某個詞。所以 on 狀態取 /AP /N 裡第一個不是 Off 的鍵,與您的值比對時不分大小寫。單選鈕加上 §12.7.4.2.4 的安排:選中值住在父欄位的 /V,每個子控件通常沒有自己的 /V。每個子項對照的是經 /Parent 鏈繼承來的值
// 核取方塊:值必須等於 /AP /N 裡的 on 狀態鍵
// (這裡是 'Yes';表單設計者可能挑了 '2' 或 'On')
Pdf.SetFormFieldValue('Consent', 'Yes');
// 單選鈕群組:/V 以名稱形式寫在父項上;每個子控件
// 的 /AS 被設成它自己的 on 狀態名稱或 Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');
// 清除核取方塊:與任何 on 狀態都對不上的值會得到 /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');
反面代價是打錯字不會丟例外。'yes ' 帶尾隨空格,什麼都不匹配,方塊落到 Off,檔案照樣完全有效。值來自資料庫欄位的話,呼叫前先 trim
選擇欄位:讓 /I 與 /V 保持同步
下拉選單與清單方塊把選擇記在兩處。/V 之外,表 231 把 /I 定義為指向 /Opt 的從零起算索引陣列,檢視器看到 /I 指向選項 0、/V 卻指名選項 3,可能會亮錯列。SetFormFieldValue 對選擇欄位的每次呼叫都重建 /I。它先把欄位自己的 /I 條目刪掉,絕不原地編輯舊陣列:間接陣列可能與另一個欄位共用,動它就連那個欄位的選擇一起動了。接著經 /Parent 鏈解析 /Opt——選項可能繼承而來——並走訪條目。裸字串選項直接比對,[export display] 成對值比對匯出元素,元素少於兩個的成對值跳過
比對在解碼後的文字上進行,所以以 PDFDocEncoding 儲存的選項,配得上以 UTF-16BE 十六進位寫入的值,反之亦然。純量值最多產生一個索引;欄位先前帶的多重選擇一律被取代。全都對不上時,完全不寫 /I。對可編輯下拉,這正是正確結果:§12.7.4.4 允許使用者輸入清單外的值,這種值沒有索引可指
下拉選單最常見的抱怨,根源就是同一條規則:呼叫成功、/V 帶著新文字,檢視器卻顯示沒有選中項。通常是呼叫者把顯示標籤(Canada)傳給了以 [CA Canada] 成對值建出來的清單。元件不能把這當錯誤,否則可編輯下拉就廢了,所以檢查得靠您自己的程式碼。GetFormFieldOptionValue 回傳每個選項的匯出元素,防線寫起來很短:
// SetChoiceByExport 是您自己的防線,不是元件的一部分:
// 對 /Opt 裡沒有匯出元素相符的值一律拒絕
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
ExportValue: AnsiString);
var
I: Integer;
begin
for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
begin
// /Opt 是 [[US United States] [CA Canada] [MX Mexico]]:
// 'CA' 對上匯出元素,/I 變成 [1]
Pdf.SetFormFieldValue(FieldName, ExportValue);
Exit;
end;
raise Exception.CreateFmt('%s is not an export value of %s',
[string(ExportValue), string(FieldName)]);
end;
// 可編輯下拉配自由文字:直接寫。/V 設好、
// /I 移除,不虛構索引
Pdf.SetFormFieldValue('Title', 'Principal Engineer');
沒有 /NeedAppearances,新值也看得到嗎?
文字與選擇欄位看得到。檢視器畫的是儲存的 /AP 串流,不看 /V,所以新值配舊外觀,螢幕上與列印出來的都是舊文字——除非 AcroForm 字典帶 /NeedAppearances true(§12.7.3.3)且檢視器願意買帳。壓平器與縮圖產生器經常無視那個旗標。SetFormFieldValue 在呼叫過程中就為文字與選擇欄位重建外觀串流,用的是與公開的 EnsureLoadedFieldAppearanceStream 相同的建構器。串流從繼承的 /DA 字串、/Q 對齊、comb 欄位的 /MaxLen 與值本身建出。/DA 點名的字型經 AcroForm 的 /DR 資源解析,所以 Type0 字型保住自己的字形,不會退回 Helvetica。按鈕保留設計者畫的狀態外觀;對它們,動的只有 /AS
SetFormFieldValue 是程序,不告訴您到底畫了沒有。沒有可用 /Rect 的控件拿不到串流,而帶孤兒控件或缺矩形控件的壞表單確實存在。渲染結果要緊時,設完值自己呼叫 EnsureLoadedFieldAppearanceStream。至少一個控件拿到串流就回 True。GetFormField 給您一個 THPDFLoadedFormField,其 Index 屬性正是呼叫要的;那個物件歸您管,用完要釋放
var
Field: THPDFLoadedFormField;
begin
Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
Field := Pdf.GetFormField('Applicant.FullName');
try
// False 代表這個欄位沒有任何控件有矩形可畫;
// 值存進去了,但不會有任何東西顯示它
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;
檢查 /V 改了沒,幾乎證明不了使用者會看到什麼。有意義的測試是渲染前後的頁面,確認控件矩形內的像素變了、矩形外的沒變。我們堅持這道檢查,是因為外觀位元組曾經騙過我們:某條串流每個位元組都對,卻有一個照辦 /NeedAppearances true 的檢視器從 /V 重建顯示,什麼也沒顯示出來
讀回數值,以及接下來常做的事
GetFormFieldValue 回傳欄位繼承的 /V。以 FEFF 前置十六進位儲存的值回來時已解碼成 UTF-8,所以上面的 City 欄位讀回來是 München 的 UTF-8 位元組,UTF8ToString 把它變成 Delphi 字串。字面字串帶著儲存時的位元組回來。往返測試請兩側都比解碼後的文字,別比您傳入的拼法
表單與外部系統之間整批搬欄位資料、而非一欄一呼叫,是 Delphi 的 XFDF 匯入與匯出的主題。把完成的表單鎖到沒人改得動,是 在 Delphi 壓平 AcroForm 與 XFA 欄位,它把這裡描述的 /AS 狀態與外觀串流精確變成靜態頁面內容。那個時間點上不一致的東西永遠不一致,所以先填、再查、最後壓平
SetFormFieldValue、EnsureLoadedFieldAppearanceStream 與漸進式重算 API 都是 HotPDF Delphi Component 的一部分,旁邊還有從 Delphi 或 C++Builder 重新命名、移除與壓平已載入文件欄位的各個呼叫