技術文章

PDFium Delphi 表單中的元件索引與註解索引之別

在 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,接著透過一個經由原生焦點元件、而不是經由你自己記錄的存取器,去讀取一個值,例如 FocusedFormFieldValueFocusedFormOptionSelected。如果邏輯索引能來回一致,原生存取器卻回傳空值,代表缺的是頁面檢視,而不是映射邏輯

邏輯欄位索引沒有承諾的事

一個從零起算的欄位索引,只是一種便利性設計,並不是一種語意上的身分識別,由此衍生出四項限制。第一,它是按頁面劃分的,不是按文件劃分的,所以第 2 頁上的索引 0,與第 1 頁上的索引 0,是完全不同的元件,把兩者拿來比較毫無意義。第二,它是依位置決定的,所以插入或刪除一個註解,會讓變動位置之上的每一個已快取索引失效;只有在頁面保持已載入且未經編輯的期間,才能把一個儲存下來的索引視為有效

第三項限制,是審閱欄位清單的人最容易感到意外的地方。這個索引列舉的是元件,不是欄位。一個選項按鈕群組,是一個帶有多個元件子項的欄位,所以一個三按鈕的群組,會貢獻三個連續的索引,而這三個索引回報的 Name,卻完全相同。TPdfFormFieldInfo 記錄,正是為了這種情況,才帶有 GroupCountGroupIndex 這兩個欄位,一個忽略它們的清單 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 之中,其產品頁面提供完整的表單欄位參考文件,包含欄位資訊記錄與焦點存取器