從 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 才分得出「作者要求了這個順序」與「這頁什麼都沒要求」
為什麼列與欄順序不能直接排序 /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 沒有任何部分讓哪個答案算錯
結構順序怎麼決定序列?
結構順序跟著結構樹裡的物件參照(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
元件內部就做了這個轉換,所以它的結果可以直接餵給 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 條目