在 PDFium Component 中──這是以 PDFium 為基礎、適用於 Delphi、C++Builder 與 Lazarus 的 VCL/LCL 元件──表單欄位索引,並不等於註解索引。一個頁面上,除了元件之外,還會夾雜著連結、文字與手繪墨跡等註解,所以欄位列舉時,必須依 FPDFAnnot_GetSubtype 做篩選,並對外提供一個從零起算的邏輯索引,只有在真正呼叫原生函式時,才映射回真實的註解位置
揭露這個問題的錯誤,一旦你見過,就絕對不會認錯。測試人員在一份已填寫的發票表單上按下 Tab,游標卻消失了,因為焦點跑到了頁尾的一個超連結上。或者更糟的情況:什麼事都沒發生──你的程式碼記錄下欄位 3 已取得焦點,UI 面板也更新了,而 FORM_SetFocusedAnnot 卻自始至終都悄悄回傳了 false。這兩種症狀,都源自同一個設計上的錯誤,而其中一種症狀底下,還藏著第二個根本原因
PDFium 交給你的兩種索引空間
PDFium 針對同一個頁面,暴露出兩套編號方案,這兩者只有在一份文件恰好只含有表單元件、別無其他時,才會剛好一致。第一種是註解索引:也就是頁面 /Annots 陣列中的位置,這正是 FPDFPage_GetAnnotCount 計算的對象,也是 FPDFPage_GetAnnot(ISO 32000-1 §12.5.2)所接受的參數。第二種,則是一個應用程式層級的 API 理應提供的邏輯欄位索引,從零開始,涵蓋使用者實際能夠抵達的互動欄位。ISO 32000-1 §12.5.6.19 把元件註解定義為互動表單欄位的視覺呈現,§12.7 則定義了表單本身。頁面上其餘的一切,都是帶有不同語意的不同子型別:連結註解有一個目的地、手繪墨跡註解有一份筆劃清單、文字註解則是一張便利貼。這些東西沒有一個該被算進欄位計數裡,也沒有一個能接受表單焦點。然而,在 /Annots 陣列裡,它們卻與元件夾雜在一起,順序完全依照產生該文件的應用程式當初寫入的順序──而這個順序,往往和文件裡其他任何線索所暗示的順序都對不上
為何按下 Tab 會跳到超連結、而不是下一個欄位?
因為那個欄位計數,其實根本是一個註解計數。原始實作,是讓 FormFieldCount 直接回傳 FPDFPage_GetAnnotCount,而欄位資訊存取器、Tab 順序輔助函式,以及焦點輔助函式,卻全都把這同一個整數,當成元件位置來處理。在一個乾淨的 AcroForm 頁面上,只有六個元件、別無其他,六恰好等於六,每個測試都會通過。但只要在頁尾加一個超連結、在邊界加一則審閱者註解,計數就會回報八個欄位,索引 6 與 7 會解析到非表單物件上,而 Tab 就會直接走進去
在列舉端的修復方式,是去計算子型別、而不是計算註解本身。開啟每一個註解、詢問它的子型別、只保留元件,並在 finally 區塊中關閉控制代碼,因為 FPDFPage_GetAnnot 回傳的是一個具有所有權的控制代碼,必須經由 FPDFPage_CloseAnnot 歸還
function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
Count, I: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := 0;
if Page = nil then
Exit;
Count := FPDFPage_GetAnnotCount(Page); // every annotation, not just fields
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
Inc(Result);
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
請留意這個做法刻意不去做什麼。它不會向表單填寫環境詢問任何事情,也不需要表單控制代碼,因為子型別存在於註解字典裡,僅憑頁面本身就能讀取。這對順序而言很重要:在你決定這份文件究竟值不值得建立一個表單填寫環境之前,這個計數就已經可用了,AcroForm JavaScript 與宿主事件一文 把這件事當成一項安全性決策、而不是便利性考量來討論
在原生邊界把邏輯索引映射回去
讓這兩個空間不互相滲漏的規則很簡單:邏輯索引是唯一能跨越你公開 API 的數字,它只在呼叫原生函式之前的最後一個函式裡,才會被轉換成註解索引。有一個映射輔助函式,同時被欄位資訊、焦點、旗標設定函式,以及 Tab 順序共用,正是這個函式讓這條規則得以被強制執行
function AnnotationIndexForField(Page: FPDF_PAGE;
FieldIndex: Integer): Integer;
var
Count, I, Current: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := -1;
if (Page = nil) or (FieldIndex < 0) then
Exit;
Count := FPDFPage_GetAnnotCount(Page);
Current := 0;
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
begin
if Current = FieldIndex then
Exit(I); // real /Annots position: native calls only
Inc(Current);
end;
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
這個輔助函式有兩個特性,值得直接說清楚。它是一次線性掃描,所以在一個帶有數百個元件的頁面上,對每個欄位都天真地跑一次迴圈,其代價是二次方數量的註解開啟操作;如果你要列舉整個頁面,應該一次走訪所有註解、邊走訪邊收集元件控制代碼,而不是對每個欄位都各別呼叫一次映射函式。它也是回傳 -1、而不是拋出例外,這讓呼叫端能自行決定,一個過時的索引究竟是值得拋例外的程式設計錯誤,還是一個值得忽略的競速情況──例如某次編輯移除了一個註解,而某份快取的 UI 清單卻仍然參照著它
為何 FORM_SetFocusedAnnot 在無介面環境的頁面上會失敗?
因為 PDFium 拒絕把焦點給予一個頁面檢視(page view)從未被標記為有效的元件。FORM_SetFocusedAnnot 會把這個註解,解析成表單填寫環境裡的一個頁面檢視,如果這個頁面檢視不存在,它就會回傳 false、不附帶任何診斷資訊。因此,光是修正索引映射,只能修好 Tab 跳到超連結的問題,卻沒動到第二個症狀:你的邏輯焦點記錄,寫著欄位 3,原生焦點元件卻依然是空的,所有建構在原生焦點之上的存取器──焦點文字、焦點值、選項選取狀態──也會持續回傳空值。頁面檢視是由 FORM_OnAfterLoadPage 建立、由 FORM_OnBeforeClosePage 銷毀的。在一個圍繞著視覺化控制項打造的檢視器裡,這些呼叫會作為顯示頁面的一部分自然發生,這正是為何這個失敗看起來經常像是「只在無介面環境才會出現」的錯誤:在 GUI 展示程式裡運作正常的同一段程式碼,到了批次工具裡卻失敗了。這個生命週期,本該屬於文件物件,而不是屬於檢視器,所以現在只要有表單控制代碼存在,PDFium Component 就會在每次載入或卸載頁面時,主動發出這兩個呼叫。這個 C 語言簽章的參數順序,是先頁面、後表單控制代碼,手寫繫結時很容易把兩者順序寫反
procedure ReportFirstField(const FileName: string);
var
Pdf: TPdf;
Idx: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FormFill := True; // form-fill environment, before Active
Pdf.FileName := FileName;
Pdf.Active := True;
Pdf.PageNumber := 1; // page load also runs FORM_OnAfterLoadPage
Idx := Pdf.FocusNextFormField; // logical index, 0-based over widgets
if Idx < 0 then
Exit; // page holds no widget annotations
Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
string(Pdf.FocusedFormFieldValue)); // reads the native focused widget
finally
Pdf.Free; // page unload runs FORM_OnBeforeClosePage
end;
end;
能證明這項修復確實有效的檢查,就是把兩邊拿來互相比對的那一種。用一個邏輯索引呼叫 FocusFormField,接著透過一個經由原生焦點元件、而不是經由你自己記錄的存取器,去讀取一個值,例如 FocusedFormFieldValue 或 FocusedFormOptionSelected。如果邏輯索引能來回一致,原生存取器卻回傳空值,代表缺的是頁面檢視,而不是映射邏輯
邏輯欄位索引沒有承諾的事
一個從零起算的欄位索引,只是一種便利性設計,並不是一種語意上的身分識別,由此衍生出四項限制。第一,它是按頁面劃分的,不是按文件劃分的,所以第 2 頁上的索引 0,與第 1 頁上的索引 0,是完全不同的元件,把兩者拿來比較毫無意義。第二,它是依位置決定的,所以插入或刪除一個註解,會讓變動位置之上的每一個已快取索引失效;只有在頁面保持已載入且未經編輯的期間,才能把一個儲存下來的索引視為有效
第三項限制,是審閱欄位清單的人最容易感到意外的地方。這個索引列舉的是元件,不是欄位。一個選項按鈕群組,是一個帶有多個元件子項的欄位,所以一個三按鈕的群組,會貢獻三個連續的索引,而這三個索引回報的 Name,卻完全相同。TPdfFormFieldInfo 記錄,正是為了這種情況,才帶有 GroupCount 與 GroupIndex 這兩個欄位,一個忽略它們的清單 UI,會把同一個欄位顯示三次。第四項限制,關乎走訪順序:這裡所暴露的 Tab 順序,是元件列舉順序,它遵循的是 /Annots 陣列,而不是頁面的 /Tabs 條目(ISO 32000-1 §7.7.3.3),也不是 AcroForm 的欄位樹。對大多數產生器而言,這兩者是一致的;但對一個由某個先輸出右欄的產生器排成兩欄版面的表單而言,兩者就不一致了,這時 表單欄位導覽一文 所描述的鍵盤操作路徑,即使每個索引都正確,感覺起來也會不對勁。當客戶的檔案行為怪異時,先把兩種索引空間並排印出來,再開始推理:把同一個頁面的註解檢視與欄位檢視印在一起,通常一眼就能看出原因
procedure DumpIndexSpaces(Pdf: TPdf);
var
I: Integer;
Info: TPdfFormFieldInfo;
begin
for I := 0 to Pdf.AnnotationCount - 1 do
Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));
for I := 0 to Pdf.FormFieldCount - 1 do
begin
Info := Pdf.FormFieldInfo[I];
Writeln('field ', I, ': ', string(Info.Name),
' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
end;
end;
註解計數遠高於欄位計數,代表這個頁面混雜了多種子型別,這在已經過審閱的文件中很正常,也正是這套映射機制存在的理由;註解審閱工作流程一文 就是從標記的角度,來看待同一個頁面。反過來說,如果每一份測試檔案上兩個計數都相等,就代表你的測試素材,完全偵測不出這一整類錯誤,誠實的因應方式,是加入一份帶有一個連結與一則便利貼的表單測試素材
本文所描述的欄位列舉、焦點與註解 API,隨附於適用於 Delphi、C++Builder 與 Lazarus 的 PDFium Component 之中,其產品頁面提供完整的表單欄位參考文件,包含欄位資訊記錄與焦點存取器