技術文章

PDFium Component:PDF 表單 Tab 順序與 /Tabs

從 3.223.0 版起,PDFium Component for Delphi 按每頁宣告的 /Tabs 政策排列表單欄位導覽順序:R 代表列順序、C 代表欄順序、S 代表結構順序,再加上 PDF 2.0 的 A 與 W。TPdf.FormFieldTabOrder 回傳該順序,它的狀態 overload 則回報宣告的政策被套用了、還是動用了退回方案

驅動這一切的抱怨永遠是同一個:使用者填兩欄式的保險表單,在名字欄位後按下 Tab,游標跳到右欄底部。索引沒有壞,只是產生器把右欄先寫進了 /Annots,並在頁面上宣告 /Tabs /R 來修正視覺順序,於是走訪 /Annots、無視宣告的檢視器就落錯位置;widget 索引與附註索引一文談 widget 序號與 /Annots 陣列的關係。在 PDFium Component 裡,宣告的政策只要能夠確立就贏,確立不了時,元件會告訴你為什麼

/Tabs 條目實際宣告了什麼?

/Tabs 是頁面物件裡的選用名稱(ISO 32000-2 §7.7.3.3;ISO 32000-1 為 Table 30),告訴處理器以什麼順序造訪該頁的附註,語意寫在 §12.5.1 的附註段落底下。PDF 1.5 引入三個值,PDF 2.0 再加兩個:

  • /R,列順序:由上而下一列一列,每列內沿檢視器的閱讀方向
  • /C,欄順序:沿閱讀方向一欄一欄,每欄內由上而下
  • /S,結構順序:邏輯結構樹參照附註的順序
  • /A,附註陣列順序:/Annots 本身的順序(PDF 2.0)
  • /W,widget 順序:widget 先按陣列順序,其餘附註在後(PDF 2.0)

讓手寫實作出錯的細節是繼承。/Tabs 只屬於頁面字典:ISO 32000-2 §7.7.3.4 把可繼承的頁面屬性限於 Resources、MediaBox、CropBox 與 Rotate,所以掛在 /Pages 節點上的 /Tabs 鍵沒有意義,沿父鏈往上找一個的讀取器是在發明作者從未設定的政策;PDFium Component 只從頁面本身讀 /Tabs,別無他處

用 FormFieldTabOrder 讀出宣告的順序

TPdf.FormFieldTabOrder 以 0 起算的 widget 序號回傳當前頁面的表單欄位,順序即宣告的 tab 順序;帶 out Status: TPdfFormTabOrderStatus 參數的 overload 會解釋這個順序是怎麼產出的。同一對方法也存在於 TPdfView,不帶狀態的 overload 用完全相同的政策,只是把狀態丟掉

uses
  PDFium;

const
  ModeNames: array[TPdfFormTabOrderMode] of string =
    ('unspecified', 'R', 'C', 'S', 'A', 'W', 'unsupported');

procedure DumpTabOrder(const FileName: string; PageNo: Integer);
var
  Pdf: TPdf;
  Order: TPdfIntegerArray;
  Status: TPdfFormTabOrderStatus;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.FormFill := True;          // 本來就是預設;欄位操作必需
    Pdf.Active := True;
    if not Pdf.Active or (Pdf.FormType <> ftAcroForm) then
      Exit;
    Pdf.PageNumber := PageNo;
    Order := Pdf.FormFieldTabOrder(Status);
    Writeln('requested ', ModeNames[Status.RequestedMode],
      ', applied ', ModeNames[Status.EffectiveMode]);
    if Status.UsedFallback then
      Writeln('fallback: ', Status.ErrorMessage);
    if Status.UnstructuredWidgetCount > 0 then
      Writeln(Status.UnstructuredWidgetCount, ' widgets not in the structure tree');
    for I := 0 to High(Order) do
      Writeln(I, ': field ', Order[I], ' ',
        string(Pdf.FormFieldInfo[Order[I]].Name));
  finally
    Pdf.Free;
  end;
end;

狀態記錄把一個沉默的排序決定變成可以寫進 log 的東西:RequestedMode 是頁面宣告的,EffectiveMode 是實際套用的,UsedFallback 為 true 代表宣告的政策無法被尊重、改回附註陣列順序,ErrorMessage 帶簡短原因;SnapshotAvailable 說明元件是否讀得到文件 metadata。缺 /Tabs 會被回報為退回到附註陣列順序,而不是冒充宣告的政策,這樣支援 log 才分得出「作者要求了這個順序」與「這頁什麼都沒要求」

頁面 /Tabs 政策值 R、C、S、A 與 W,以及 PDFium Component for Delphi 在套用宣告順序或退回時回報的狀態欄位
缺 /Tabs 是一個被回報的、退回到附註陣列順序的案例,不是冒充成宣告的政策

為什麼列與欄順序不能直接排序 /Rect 值?

列與欄順序必須在顯示幾何裡計算,不能在原始附註座標裡算,因為頁面的 /Rotate 與文件的閱讀方向都會改變「一列」的定義;把帶 /Rotate 90 頁面的 /Rect 陣列按 y 座標排序,得到的是檢視器眼裡的欄。元件因此把每個 widget 矩形透過頁面的顯示轉換映射——含先天旋轉——到 1/64 點解析度,再從那裡分組

閱讀方向來自 catalog 的 ViewerPreferences 字典(ISO 32000-2 §12.2):/Direction /R2L 會反轉列內順序,也反轉欄的順序,這正是阿拉伯文或希伯來文表單的期望;/Direction 不存在時用 L2R,值既非 L2R 也非 R2L 時,列與欄順序退回附註陣列順序並把原因寫進狀態——瞎猜方向比承認沒有方向更糟

分組正是各家處理器合理分歧的地方,所以確切規則值得知道:列順序取最上方未造訪的 widget 當錨點,垂直中心落在錨點上下界之間的每個 widget 都加入該列;欄順序把這套規則水平鏡像;平手按 widget 序號確定性地解決。在乾淨的格線上,這與任何講理的檢視器一致;在欄位大量重疊或交錯的版面上,用不同容差的另一個處理器可能分組不同,而 ISO 32000 沒有任何部分讓哪個答案算錯

Delphi PDFium Component:附註陣列順序直接跳到右欄,宣告的 /Tabs R 列順序則逐列造訪兩欄式表單
列順序是在 /Rotate 與閱讀方向之後的顯示幾何裡算出來的,不是原始 /Annots 順序

結構順序怎麼決定序列?

結構順序跟著結構樹裡的物件參照(OBJR)字典走,按 /K 陣列的深度優先走訪實際抵達它們的順序;它不跟 /ParentTree 的鍵順序,也不跟列舉 StructTreeRoot 碰巧產生的順序。單一產生器的檔案裡這些順序常常一致,編輯器重排樹之後就分道揚鑣——而這正是有 tag 表單的閱讀順序最要緊的時候;自己維護那棵樹的話,讓 /ParentTree 與 /StructParents 保持同步指南涵蓋必須與它一致的反向映射

結構樹從不參照的 widget,規格交給處理器自行處理;PDFium Component 把它們按附註陣列順序附在結構化 widget 之後,並在 UnstructuredWidgetCount 回報附加了幾個。在完全沒有結構樹的文件上宣告 /S 的頁面被當成空走訪:每個 widget 都落進那條尾巴,計數如實反映,結果不標記為完整退回。帶循環的結構樹、被參照兩次的 OBJR,或 /Pg 與擁有附註的頁面相矛盾的 OBJR,都會讓結構順序退回;這些缺陷讓 S 不可用,但不影響其他頁的 R 或 C

為什麼 tab 順序值不是附註索引?

FormFieldTabOrder 回傳陣列裡的值只計 widget,而 OBJR 識別的是住在完整 /Annots 陣列裡的附註物件;把兩個索引空間混用,是任何消費結構順序的程式碼裡最可能發生的 bug。拿一個 /Annots 為 [Link, Widget A, Widget B] 的頁面來說:Widget B 的 OBJR 指向附註索引 2,而 Widget B 是欄位序號 1;把 2 餵給 FocusFormField,不是聚焦到錯的欄位,就是什麼都沒聚焦,取決於頁上有幾個 widget

結構 OBJR 指向附註索引 2,而 PDFium Component 在 Delphi 的表單 API 計數 widget,所以 Widget B 是欄位序號 1
附註索引 2 不是欄位序號 1——呼叫 FocusFormField 前先明確轉換

元件內部就做了這個轉換,所以它的結果可以直接餵給 FocusFormField 與 FormFieldInfo;自己的程式碼讀 OBJR 目標或其他附註索引時,請在當成欄位用之前明確轉換:

// 把頁面附註索引轉換成 FocusFormField、FormFieldInfo 與
// FormFieldTabOrder 使用的 widget 序號;不是 widget 時回傳 -1
function AnnotationToFieldOrdinal(Pdf: TPdf; AnnotIndex: Integer): Integer;
var
  I: Integer;
begin
  Result := -1;
  if (AnnotIndex < 0) or (AnnotIndex >= Pdf.AnnotationCount) then
    Exit;
  if Pdf.Annotation[AnnotIndex].Subtype <> anWidget then
    Exit;                          // 連結、note 與 popup 都不是欄位
  Result := 0;
  for I := 0 to AnnotIndex - 1 do
    if Pdf.Annotation[I].Subtype = anWidget then
      Inc(Result);
end;

這個結果只在頁面保持載入且未編輯時有效;在 widget 前面新增或刪除任何附註都會同時移動兩個索引空間,編輯前快取的映射正是那種只在客戶檔案上重現的 off-by-one

用 FocusNextFormField 跨頁導覽

FocusNextFormField(AcrossPages: Boolean; WrapDocument: Boolean = True; MaxPagesToScan: Integer = 256) 走訪當前頁的 tab 順序,然後繼續走進後續頁面,而從 3.223.0 起,它造訪的每一頁都套用各自宣告的政策。跨頁 overload 在 3.220.0 到貨,所以表單欄位導覽一文裡「自行偵測迴繞並改 PageNumber」的建議,如今只適用於仍在單頁內循環的無參數 overload

procedure TFormViewer.HandleTabKey(Shift: TShiftState);
var
  Field: Integer;
begin
  if ssShift in Shift then
    Field := PdfView.FocusPreviousFormField(True)  // 跨頁,會迴繞
  else
    Field := PdfView.FocusNextFormField(True);
  if Field < 0 then
    Beep   // 掃描範圍內沒有合適欄位;頁面與焦點不變
  else
    UpdateFieldStatus(PdfView.PageNumber, Field);
end;

回傳值是焦點現在所在那頁的頁內欄位序號,所以要連同 PageNumber 一起讀。跨頁走訪會跳過使用者無法有意義地 tab 進去的 widget:hidden、invisible、NoView、唯讀、簽章、未知類型與空矩形的 widget。傳 WrapDocument = False 會在文件邊界以 -1 停下;MaxPagesToScan 必須落在 1 到 4096 之間,每個檢視過的頁面都計數,包括當前頁。掃描一無所獲,或原生引擎拒絕聚焦目標時,呼叫回傳 -1,前一個頁面與焦點原樣不動

宣告的順序在哪裡失效?

宣告的政策只在元件能可靠讀到頁面 metadata 時被尊重,每個做不到的案例都會以明確原因退回附註陣列 widget 順序。邊界具體到可以列出:

  • metadata 來自對當前原生文件的有界私有序列化,不是原始檔案位元組,因為記憶體內的頁面搬移與編輯會讓磁碟上的檔案過時;快照按內容修訂版快取,收到通知的編輯會使其失效,失敗的快照也會被快取
  • 加密文件,以及 page-object 編輯期間請求的快照,沒有快照可用,一律退回
  • 快照封頂在 64 MiB、65536 個作用中物件、4096 頁、每頁 4096 個附註、合計 65536 個附註與結構深度 64;更大的文件退回,不讓導覽卡死
  • 只涵蓋 AcroForm 文件;XFA 表單留在這個 profile 之外
  • 列與欄順序需要有限、非空的 widget 矩形與合理的頁面尺寸;退化幾何觸發退回
  • 這些 API 由你的程式碼驅動導覽;PDFium 自己對轉送給引擎的 Tab 鍵的處理不會被取代,想要宣告順序,就把鍵導進 FocusNextFormField

查詢會保留當前頁面、原生焦點與聚焦欄位裡擱置中的編輯文字,而且不執行任何文件或頁面生命週期動作;每個按鍵都呼叫 FormFieldTabOrder 因此是安全的,而且同一個內容修訂版上第一次呼叫之後,它很便宜

宣告的 tab 順序、狀態診斷與跨頁焦點導覽都是PDFium Component for Delphi and C++Builder 的一部分;產品頁連著完整 API 參考,包括 TPdfFormTabOrderMode 與 TPdfFormTabOrderStatus 條目