在你的程式碼建置的 PDF 表單裡按 Tab,游標落在距應有位置兩個欄位之外、或整個跳過第二欄、或在第三個欄位之後跳回頂端而不是第四個。在你的檢視器裡填寫發票的人,期望鍵盤走訪表單的方式,就像它走訪他們用過的每一個網頁表單一樣。當它沒有如此,他們就伸手去拿滑鼠、尋找下一個方塊,並悄悄認定你的工具沒做完。可預測的欄位走訪,是人們能忍受的資料輸入檢視器與他們信任的檢視器之間的差別,而這幾乎完全是使用正確焦點 API、而非用模擬點擊偽造鍵盤輸入的事
以下範例使用 PDFium Component,一個基於 PDFium、供 Delphi、C++Builder 與 Lazarus 使用的 VCL/LCL 元件。導覽是表單檢視器必須做對的三件事之一;另外兩件——正確開啟表單,以及儲存填入的值讓它們真的顯示出來——才是大多數意外藏身之處,所以三者都在下面涵蓋
開啟表單:FormFill、FormType 與 XFA 問題
欄位存取需要表單填寫子系統,由 FormFill 屬性控制,必須在文件開啟之前啟用。一旦啟用,FormType 會告訴你面對的是哪種表單,而這個答案會改變你能承諾的功能集:
Pdf.FileName := FormPath;
Pdf.FormFill := True; // 在 Active 前啟用;任何欄位存取都需要
Pdf.Active := True;
case Pdf.FormType of
ftNone:
DisableFormPanel('This document has no interactive form');
ftAcroForm:
BuildFieldList; // 完整的欄位導覽與編輯可用
ftXfaFull:
ShowXfaNotice; // XFA 從自己的 XML 範本算繪;
// 將欄位編輯視為受限功能
end;
這個切換引出兩則實用筆記。AcroForm 是標準的 ISO 32000 表單模型,也是此處每個 API 所瞄準的對象。XFA 文件內嵌它們自己的 XML 表單架構,所以在一次快速的 AcroForm 展示之後向客戶承諾完整的 XFA 編輯,是你會後悔的承諾。第二則筆記關於副作用:把 FormFill 設為 True 也會初始化文件 JavaScript。在一個資料輸入檢視器裡這完全正確,因為計算指令稿正是讓執行中的合計在某個人打字時保持最新的東西。在一個給來源不明檔案用的預覽視窗裡,這就完全錯了。安全 PDF 預覽一文涵蓋了那個取捨的 FormFill := False 那一面
落在使用者預期之處的 Tab 鍵走訪
回到一開始的鍵盤問題。誘惑是用合成對下一個控制項矩形的一次滑鼠點擊來偽造 Tab,而這在一個欄位被捲出螢幕外、或兩個控制項重疊的那一刻就崩潰。焦點 API 直接移動表單自己的焦點,沒有任何幾何猜測。五個呼叫涵蓋它:依索引的 FocusFormField、用於步進的 FocusNextFormField 與 FocusPreviousFormField、讀取你所在位置的 FocusedFormFieldIndex,以及完全丟棄焦點的 ClearFormFieldFocus
procedure TFormViewer.HandleTabKey(Shift: TShiftState);
begin
if ssShift in Shift then
PdfView.FocusPreviousFormField
else
PdfView.FocusNextFormField;
UpdateFieldStatus; // e.g. "Field 4 of 17: InvoiceDate"
end;
絆住人的那一塊行為是繞回。走訪走過目前頁面的 Tab 順序,並在其內循環:跨過最後一個欄位,你就回到第一個。兩個步進函式都回傳新的欄位索引,或當頁面根本沒有欄位時回傳 -1。那個循環是每頁的,不是每份文件的,這意味著跨到下一頁是你的責任,不是程式庫的。把回傳的索引與你起始的那個比較,注意它何時繞回,並在表單意在讀作一條連續序列時自己推進 PageNumber。略過這個檢查,一份兩頁的表單會悄悄把游標困在第一頁,這是 Tab 失靈抱怨的另一種風味
一旦 UI 的其餘部分對走訪做出反應,走訪就變得有用。OnFormFieldEnter 事件在焦點到達時觸發,而在檢視器上 OnFormFieldFocusChange 回報新的欄位索引,所以側邊面板能與鍵盤剛選中的東西保持同步。當你需要反向對應——從螢幕位置到欄位時,FormFieldAt 索引屬性為工具提示預覽與點選即編面板做點擊測試。這一切之中有一個安靜的無障礙紅利:因為焦點遵循文件自己的欄位順序,你為 Tab 鍵接好的路徑,就是螢幕閱讀器朗報的同一路徑,無需額外工作
要顯示欄位名稱而非原始的索引編號,還需要一個屬性。FormFieldInfo[] 對每個索引回傳一筆 TPdfFormFieldInfo 記錄,帶有欄位名稱、型別、字型大小、勾選狀態、匯出值與群組成員,這正是導覽清單應該顯示的(「Field 4 of 17: InvoiceDate」而非「4」)。單選群組是值得一份專用測試檔案的案例。數個控制項可以共用單一欄位名稱,所以一份天真地從控制項組裝的清單,會把同一個群組顯示好幾次,讓每一個讀到它的人都困惑
為什麼填入的值變成空白,以及修復它的呼叫
塞滿支援佇列的另一個抱怨,比一個行為失常的 Tab 鍵更令人警覺:表單被以程式方式填好,客戶在 Acrobat 中開啟它,而每個欄位看起來都是空的。點進一個欄位,它的值就彈進視野。資料一直都在檔案裡。缺少的是資料的圖片,而這個原因值得了解一次,因為它解釋了一整個家族的錯誤
一個 AcroForm 文字欄位,把它的值儲存在欄位字典的 /V 條目裡(ISO 32000-1 §12.7.3.3)。檢視器實際繪製的是另一個分開的東西:控制項在 /AP 下的外觀串流(§12.5.5),一小段預先繪製的內容片段。寫入 /V 而讓 /AP 自生自滅,兩者就會漂離。值在那裡;它的繪製版本卻是過時或缺席的。Acrobat 恰好在一個欄位取得焦點時重建它的外觀,這就是那些值只在點選時出現的完整解釋。舊的 NeedAppearances 旗標——它要求檢視器為你重新產生外觀——從未一致地運作,並在 PDF 2.0 中被棄用,而列印伺服器與縮圖產生器完全忽略它。它們只繪製 /AP,別無其他,所以如果 /AP 是空的,它們就印出一個空白方塊
透過 FormField[i] 指派一個值,只寫入 /V。這也是為什麼填寫表單是一段三步序列,而團隊丟掉的是中間那一步:
procedure TFormViewer.FillAndSave(const Values: array of WString;
const OutputPath: string);
var
i: Integer;
begin
for i := 0 to Pdf.FormFieldCount - 1 do
Pdf.FormField[i] := Values[i]; // writes /V only
// 重建 /AP 外觀串流;否則表單
// 在 Acrobat 中會顯示空白,直到逐一點擊每個欄位
Pdf.GenerateFormAppearances;
Pdf.SaveAs(OutputPath);
end;
GenerateFormAppearances 就是整套修復。它從目前的值、字型與對齊重建每個控制項的外觀串流,所以一個從不執行焦點事件的檢視器、列印伺服器或縮圖產生器,無論如何都會繪製出填入的狀態。在一批指派「之後」呼叫它一次,而不是每個欄位一次。外觀產生做的是真正的排版工作,而逐欄位的呼叫會把那個工作量在一個大型表單上無謂地倍增
重新產生外觀也是字型與對齊主張自己的時刻,這是第二級意外的來源。新的串流使用欄位的字型、大小與對齊,把每個值排在控制項矩形內。一個在你的測試表單裡坐得舒舒服服的值,在客戶那份同一欄位更窄的副本裡可能被裁切或縮小。自動調整大小的欄位(字型大小為零)會把文字縮小到塞得下;固定大小的欄位就直接裁切它。兩者都合法,而要知道某份表單採取哪一個的唯一誠實方式,是看重新產生的輸出,而不是你寫入的字串。當有人回報文字在方塊邊緣被切斷時,這幾乎總是原因
請把驗證當作完成工作的一部分,而不是事後的想法。在 Acrobat 中開啟儲存的檔案,並在你碰觸任何欄位之前確認值是可見的。然後從另一個完全忽略表單邏輯的檢視器把它列印成 PDF 或影像,並確認值也在那條路徑上存活。這兩個檢查合起來,能抓到 /V 對 /AP 漂離的每一種變體
通過展示卻在現場失敗的欄位設定
乾淨的展示表單隱藏了一組客戶檔案不會的邊緣案例。其中四個佔了大多數「在我機器上能跑」的回報
- 核取方塊的匯出值。「開啟」狀態不總是
Yes。一份表單可以自由定義它自己的匯出值,而寫入錯誤的字串,會在方塊視覺上未勾選的同時,讓你的程式碼確信自己設好了。請從FormFieldInfo[]讀取匯出值,而不是假設某一個 - 共用名稱的單選群組。一個欄位、數個控制項。你指派的值決定哪個控制項讀起來是選中的,所以假設一個名稱對應一個矩形的 UI 程式碼,最後會把焦點環畫在錯誤的按鈕上
- 計算欄位。由文件 JavaScript 維護的合計,會回應欄位事件而更新。一個繞過這些事件的程式化填寫,要嘛必須觸發重新計算,要嘛必須直接覆寫計算欄位。一份明細項目與合計意見不一致的表單,比任何一種修復都更糟
- 隱藏的必填欄位。條件式表單會隱藏仍標記為必填的欄位。請事先決定你的驗證是要尊重可見性還是原始的必填旗標,然後把那個決定寫在某個支援找得到的地方
有一個區別值得在它咬你之前先弄清楚:產生外觀不是扁平化。GenerateFormAppearances 讓值在各地都可見,同時讓欄位保持可編輯。扁平化把外觀烤進靜態頁面內容,並永久剝除互動性,這對一份封存副本是對的,對一份下個人還得填的表單則是錯的。如果 FormType 回報的是 ftXfaFull 而非 ftAcroForm,這裡的編輯表面無論如何都無法乾淨地適用,因為文件從它自己的 XML 範本繪製;請偵測那種情況並告訴使用者,而不是讓他們自己去找極限
此處展示的表單填寫子系統、焦點走訪與外觀產生,是供 Delphi、C++Builder 與 Lazarus/FPC 使用的PDFium Component 的一部分。如果你的檢視器還在表單資料旁處理審查者的標記,註解審查一文涵蓋了那個相鄰的模型