核取方塊與選項按鈕,平面化後會變成未勾選狀態,原因是外觀狀態 /AS 從未與欄位值 /V 同步過。PDFium Component 是以 PDFium 為基礎、適用於 Delphi、C++Builder 與 Lazarus 的 VCL 及 LCL 元件,現在改用 FPDFAnnot_GetFormFieldValue 來讀取這個值,這個函式解析的是父欄位字典,而不是元件註解
引出這篇文章的那則錯誤回報,是那種你一開始會半信半疑的類型。一位客戶把一份已簽署的同意書平面化,開啟結果檔案,發現每一個核取方塊都是空的。用 Acrobat 開啟原始檔案,方塊卻明明都是打勾的。用同一個元件讀回原始檔案,欄位值也是正確的。唯獨平面化後的輸出會遺失這些值,而且只發生在核取方塊與選項按鈕上:同一頁上的文字欄位則完全正常
為何平面化後核取方塊會變成未勾選?
因為平面化過程根本不會去看 /V。FPDFPage_Flatten 會把元件的外觀串流烘焙進頁面內容裡,而它挑選的外觀,正是 /AS 所指名的那一個。如果 /AS 仍然寫著 /Off,而欄位值卻表示這個方塊是勾選狀態,平面化過程就會忠實地把「未勾選」的外觀烘焙進去。這個值從來沒有遺失過,它只是從來沒有被查詢過
ISO 32000-1 §12.5.5 定義了外觀字典 /AP,可能帶有三個條目:/N、/R 與 /D。對核取方塊或選項按鈕而言,/N 條目並不是一個串流,而是一個子字典,其鍵是外觀狀態的名稱,而 §12.5.2 規定,當 /N 是一個子字典時,/AS 就是必須存在的選擇器。所以一個核取方塊,帶有兩個預先建好的外觀,加上一個指標。只要指標指錯,渲染結果就會出錯,而且無論 /V 有多正確,都無法修復這個問題。這也正是為何它的失敗模式,與完全沒有預建外觀可挑選的文字欄位不同:文字欄位的 /N 是單一一個串流,值一旦改變就必須從頭重新產生,所以 GenerateFormAppearances 是透過完全獨立的兩條程式碼路徑,來處理這兩種情況,而只有按鈕這條路徑出了問題
核取方塊的值實際上存放在哪裡?
在欄位字典上,而不是在元件上。ISO 32000-1 §12.7.5.2 把核取方塊與選項按鈕描述為按鈕欄位,其 /V 是一個名稱物件,指名目前的外觀狀態,而 §12.7.3.1 則把 /V 列在所有欄位字典共通的條目之中。§12.5.6.19 所定義的元件註解,提供的是 /AS 與 /AP。規範中沒有任何地方要求元件本身必須攜帶 /V
// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off
{ What the two objects look like when the field has several widgets:
12 0 obj % field dictionary (the parent)
<< /FT /Btn /T (Consent) /V /On
/Kids [ 13 0 R 14 0 R ] >>
endobj
13 0 obj % widget annotation (a kid)
<< /Type /Annot /Subtype /Widget /Parent 12 0 R
/AS /Off
/AP << /N << /On 20 0 R /Off 21 0 R >> >> >>
endobj }
FPDFAnnot_GetStringValue 本身並沒有缺陷,它的契約正如其名稱所述:從你交給它的那個註解字典中,取出一個字串條目。向它查詢物件 13 的 /V,得到的是空值,原因是物件 13 本來就沒有 /V。真正的缺陷出在呼叫端,它假設了一個扁平的物件模型,而這是 ISO 32000-1 從未承諾過的
欄位與元件何時共用同一個字典?
每當某個欄位恰好只有一個元件時。§12.5.6.19 允許把欄位字典與它唯一的元件註解合併成一個物件,而大多數製作工具都會走這個捷徑。在一個合併後的物件裡,/FT、/T、/V、/AS 與 /AP 全都並排存在,所以在元件層級讀取 /V 會成功,整個錯誤也就一直保持隱形
一旦某個欄位擁有兩個以上的元件,合併就不可能了,§12.7.3.1 要求這些元件必須成為一個獨立欄位字典的 /Kids。每一個選項按鈕群組,依其結構本質,天生就是這個樣子。重複出現在頁首與頁尾的同意核取方塊,以及任何被製作工具複製到第二頁的欄位,也都是如此。這正是為何這個缺陷能在回歸測試套件中存活下來的全部原因:測試語料庫裡全是單一元件的表單,客戶的檔案卻不是。如果你自己走訪元件、而不是依賴元件本身提供的功能,同樣的不對稱性,也會在列舉順序上出現,使用 PDFium Component 做 PDF 表單欄位導覽一文 中有討論頁面層級的註解走訪,與文件層級的欄位樹之間的關係
依 PDFium 設計初衷的方式讀取值
FPDFAnnot_GetFormFieldValue 才是正確的 API,它其實早就已經在元件裡繫結好了,只是核取方塊這條路徑一直沒有用到它。它接受的參數,除了註解本身,還有表單控制代碼,這正是重要的訊號:只要有表單填寫環境可用,PDFium 就會把該註解解析到它對應的表單控制項,並從欄位物件讀取值,所以無論是合併版面配置還是分離版面配置,它都能回傳正確的答案
FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
begin
// /AP is prebuilt per state; only /AS has to be synchronised with /V.
// FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
// which is where ISO 32000-1 12.7.5.2 keeps the value.
buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
if buflen >= 4 then
begin
SetLength(OrigVal, buflen div 2 - 1);
FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
end;
end;
這段程式碼片段裡有兩個細節,很容易出錯。回傳的長度,是 UTF-16 文字的位元組計數、含結尾符,所以字元數是 buflen div 2 - 1,值為 2 就代表一個空字串。因此 buflen >= 4 這道防護,代表至少有一個真正的字元,這正是能防止一個完全沒有 /V 的欄位,其 /AS 被一個空名稱覆寫掉的關鍵
/AS 與 /AP /N 之間,真正達成一致的是什麼
它們達成一致的,是一個名稱,而這個名稱由檔案的產生者自行選定。§12.7.5.2 要求未勾選狀態必須稱為 /Off,勾選狀態則完全交由產生者決定。/Yes 只是一種慣例,不是規則。Acrobat 寫的是 /Yes,但許多產生器寫的是 /On、/1、/Choice1,或某個本地化的詞彙,而一個選項按鈕群組,通常會給每一個子元件一個不同的勾選狀態名稱,好讓群組能表達究竟哪一個按鈕被選中。這正是為何把 /V 逐字複製到 /AS 是正確的做法、而不是一種取巧的手段:對一個已勾選的控制項,PDFium 回報的是檔案本身所定義的勾選狀態名稱,對一個未勾選的控制項,它回報的則是 Off,所以你寫入 /AS 的值,保證是那個元件 /AP /N 子字典裡確實存在的鍵。把 /Yes 寫死,在 Acrobat 產生的輸出上能正常運作,在其他所有地方卻會悄悄壞掉
操作順序,以及仍需留意的地方
這個順序是固定且不容出錯的:啟用表單填寫、賦予值、重新產生外觀、平面化、然後儲存。跳過重新產生外觀這一步,FPDFPage_Flatten 就會找到空的或過時的外觀串流,並毫無怨言地把它們烘焙進去,這是一種無聲的資料遺失,而不是一個錯誤回傳值
Pdf.FileName := FormPath;
Pdf.FormFill := True; // required: FormHandle must exist
Pdf.Active := True;
Pdf.FormField[0] := 'On'; // writes /V only
Pdf.GenerateFormAppearances; // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
Pdf.SaveAs('consent-flat.pdf');
仍然存在兩個誠實的限制。第一,這次同步,會把欄位值寫入該欄位每一個元件的 /AS,這對核取方塊而言是正確的,但對每個子元件各自定義自己勾選狀態名稱的選項按鈕群組而言,卻只是近似做法;一個 /AP /N 裡沒有任何條目與被寫入的 /AS 相符的子元件,依 §12.5.5 就沒有外觀可供選擇,所以一個未被選中的按鈕,平面化後可能會變成什麼都沒有、而不是一個空心圓圈。在平面化之前,先用 FPDFAnnot_GetFormControlIndex 稽核一次選項按鈕群組,多花這幾行程式碼是值得的。第二,這一切都不適用於 XFA,因為在 XFA 裡,值存放在一個 XML 資料封包裡,而不是存放在 AcroForm 字典中,這個區分在 未被持久化保存的 XFA 欄位編輯一文 中有討論。這裡有個一般性的教訓,值得記在心裡、不只用在這一次修復上:只要一個 API 除了註解之外,還額外接受表單控制代碼,就代表它會替你解析整個欄位階層;而只要它只接受註解,它讀取的就恰好是你傳入的那個物件。這個區別,同樣也支配著資料交換的方式,因為 匯出與匯入 XFDF 表單資料一文 所描述的作業,用的是完整限定的欄位名稱,而不是元件位置
表單平面化是這樣一種功能:看起來像是單一一次 API 呼叫,結果卻是三個字典之間的一份契約。如果你寧可使用一個早已把這份契約內建其中的元件,適用於 Delphi 與 C++Builder 的 PDFium Component,就以一般屬性與方法的形式,提供了本文所描述的外觀重新產生、平面化,與表單欄位存取功能