技術文章

PDFium Component Dynamic XFA:頁數其實是差值

Delphi 檢視器裡的動態 XFA 表單增減頁面時,PDFium Component 從 v3.126.1 起會透過 TPdf.PageCount 與 TPdf.OnXfaPageCountChanged 回報新的總數,因為原生頁面事件帶的是增減差值、不是總數。v3.126.1 的 Windows V8 函式庫同時讓輸入熱區跟著搬家的欄位移動,v3.126.2 則在版面回呼返回之後重載過期的頁面 handle。點燃這一切的 bug 回報是一張請款單:按兩下「Add Row」,表單長到兩頁,頁碼指示器卻神氣地寫著「第 1 頁,共 1 頁」。對搬到第 2 頁的欄位打字,擊鍵落在看不見的地方。這些在人人一開始就拿來測的固定長度範例表單上全都現形不了——若您要內嵌表單檢視器,箇中原因值得知道

動態 XFA 表單重新分頁時會發生什麼?

動態 XFA 表單沒有固定的頁面清單,頁數是版面的輸出,使用者每次改資料都可能變。XFA 3.3 把表單描述成一棵 subform 樹;重複的 subform 由 instanceManager 控制,_Row.addInstance() 這類腳本再克隆一列。版面處理器接著把內容重新流入頁面區域——可能加一頁、減一頁,或把既有欄位擠到另一頁。ISO 32000-1 §12.7.8 只定義 XFA 封包怎麼搭 PDF 的便車;之後的一切都歸 XFA 引擎管,在 PDFium Component 裡就是跑在宿主行程裡的 PDFium 自家 XFA 版面。Delphi 檢視器因此面對的是一份頁數、頁面尺寸與 widget 位置全部都是活狀態的文件。宿主若不作此想,有三件事會出錯:

  • 宿主為導航、捲動範圍與頁碼旋鈕快取的頁數會過期,更糟的是被錯的數字更新
  • 搬家的欄位邊框出現在新位置,文字編輯器與滑鼠熱區卻留在舊座標
  • 檢視器留著版面已汰換的頁面 handle,點擊與繪製於是送往這份表單裡已不存在的頁面

列編輯在存檔與重開之間的持久化是另一個有自己的規則的問題;本文只談檢視器裡執行期發生的事

動態 XFA 需要哪個 PDFium 執行期?

PDFium Component 裡的動態 XFA 需要原生函式庫的 V8/XFA 建置,由 PDFium 單元裡的全域變數 EnableV8Engine 在第一份文件載入前選定。行程在任何 TPdf 第一次載入函式庫時就綁定單一 DLL,而純 PDFium 建置根本跑不了 XFA 引擎。文件開啟時,TPdf 確實會偷看檔案有沒有 XFA 標記、自動切到 V8 建置,但前提是該行程尚未載入過純版函式庫。若綁定已經走錯邊,TPdf.OnXfaRuntimeMissing 會觸發一次,讓宿主告訴使用者重啟。啟動時明確設好這個旗標,就不用猜。承載 XFA 事件的 FPDF_FORMFILLINFO 回呼結構也必須與 DLL 相符,背景知識見FPDF_FORMFILLINFO 第 2 版與 XFA 回呼 ABI;打開檢視器之前怎麼分辨表單類型,偵測 XFA 表單與讀取其封包一文有完整說明

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // 在第一個 TPdf 載入原生函式庫之前決定:
  // 行程事後無法從 pdfium.dll 換到 pdfium.v8.dll
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

兩頁的表單,PageCount 為什麼回報 1?

v3.126.1 之前,PDFium Component 把原生頁面事件的 page_count 引數當成文件總數存放,而那個引數其實是新舊頁數的絕對差。PDFium 在一輪版面跑完後觸發 FFI_PageEvent,事件類型是「頁面新增」或「頁面移除」;內部它先更新自己存放的頁數,再傳出 abs(new - old)。初始版面時舊頁數是零,差值恰好等於總數,三頁的靜態範例如實回報三頁——固定長度測試表單之所以從來暴露不了這個 bug,原因正在這裡。動態表單頭一回從一頁長到兩頁,差值是 1,包裝層便把 TPdf.PageCount 與 OnXfaPageCountChanged 的 NewCount 參數一起設成 1。從三頁表單刪一列,則在另一個方向鬧出同款笑話

把差值累加到前一個值上也不是安全的修法。初始化與版面回呼的先後順序,意味著包裝層無法永遠信任自己稍早的頁數當基準,累計值可能漂移。v3.126.1 起,回呼不再把引數當頁數,改為呼叫文件的 FPDF_GetPageCount,從剛剛完成的版面讀總數;接著清掉快取的頁面場景、把那個總數存成 TPdf.PageCount 背後的 XFA 頁數覆寫,然後才觸發 OnXfaPageCountChanged。您的處理器執行時,NewCount 與 FPdf.PageCount 已然一致

PDFium Component 的動態 XFA 示意圖:新增一列讓一頁表單重排成兩頁,FFI_PageEvent 以 abs(new - old) 傳出差值,舊包裝層因此回報 TPdf.PageCount 為 1,v3.126.1 則讀 FPDF_GetPageCount、回報正確總數
原生頁面事件回報的是增減差值、不是總數,v3.126.1 因此無視該引數,先讀取已完成的版面再觸發 OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+:NewCount 是已完成版面的總數,絕不是差值。
  // 這裡跑在 PDFium 的版面回呼內:只更新宿主 UI 狀態,
  // 不要從這裡關閉文件或重載頁面
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // 每次頁面重載後觸發,包括延後的 XFA 刷新
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

這個事件只為執行期版面會變動的 Full XFA 表單觸發。Static XFA 與 AcroForm 文件從不引發它,所以兩種都處理的檢視器可以留著同一個處理器。不指派也安全:TPdf.PageCount 背後的覆寫照樣生效,事件存在只是讓宿主有機會刷新自己快取的東西

欄位搬家後,輸入框為什麼留在舊頁?

邊框動了、編輯器沒動,是因為原生 XFA 通知器拿矩形跟自己比。版面改變已載入 widget 的幾何時,PDFium 本該注意到新矩形並對該 widget 呼叫 PerformLayout,重新擺放文字編輯器與熱區。那個檢查卻是拿 GetWidgetRect() 跟 RecacheWidgetRect() 比——兩個函式回傳的都是同一個成員的 const 參照,重刷快取又原地覆寫那個成員,比較結果於是永遠相等,已載入的 widget 全都跳過重排版

症狀是在某個測試改了 subform 高度、讓既有欄位跨到下一頁時浮現的。兩種 V8 架構上,欄位邊框畫在新位置,輸入的文字與滑鼠熱區卻留在先前的 Y 座標。明確觸發重排版救不了它,重載頁面也一樣——widget 仍相信自己的幾何是最新的。v3.126.1 隨附的 Windows V8 函式庫,在重刷快取之前先把舊矩形按值複製一份、改比那份複本,搬家的 widget 於是乖乖重排版,編輯的值正好出現在邊框所在。這是原生層的修正:它跟著 DLL 走,只更新 Pascal 單元、留著舊的 pdfium.v8.dll,錯位的熱區就還在。逼出這個修正的迴歸測試,先把倖存的一列編輯成非預設值,再要求那個值出現在欄位的新位置——因為用預設值重建的列,會看起來像通過

PDFium Component 的 widget 重排版示意圖,對照舊的自比較——GetWidgetRect 與 RecacheWidgetRect 回傳同一個共享成員,搬家的 widget 於是跳過 PerformLayout——與 v3.126.1 Windows V8 的按值複製檢查,後者把編輯器與滑鼠熱區重新擺到重繪的邊框上
矩形跟自己比永遠不會失敗,邊框於是獨自搬家,輸入的文字與點擊留在原地——直到檢查改成先按值存一份複本

TPdfView 怎麼重載頁面,才不會從 PDFium 腳下抽走 handle?

v3.126.2 起,TPdfView 把 XFA 版面變更之後的頁面重載,延到原生呼叫堆疊完全退淨之後。頁面事件通常在 PDFium 還在處理輸入時觸發:使用者按了 Add Row 按鈕,點擊跑了腳本,腳本改了實例數,版面就在同一個原生呼叫裡跑完。那一刻關掉再重開頁面 handle,會釋放呼叫端還在用的物件。v3.126.2 之前,檢視器只讓自己失效重繪,畫面上那個頁面 handle 可能繼續指著版面前狀態;若使用者當時正停在消失掉的最後一頁,選中頁碼就直接越界了

延後刷新分幾個小步完成,宿主看到的行為都能由此解釋:

  1. 頁面事件回呼把檢視器標記為有待 XFA 版面刷新,並投遞一則私用視窗訊息;訊息送達之前重複到的事件合併成一次刷新
  2. 還沒有視窗 handle 的檢視器保留待辦旗標,等 CreateWnd 再投遞訊息;換文件、停用或銷毀檢視器都會清掉旗標
  3. 訊息送達時,檢視器清掉文字選取、搜尋高亮與聚焦欄位索引——三者指向的都是舊版面
  4. 選中頁碼被夾限到新的 PageCount;頁碼有變就走正常的換頁流程,否則重載當前頁,並重新套用縮放模式
  5. 版面若一頁不剩,檢視器卸載舊的頁面 handle,而不是去畫一個已不存在的頁面
PDFium Component TPdfView 的延後 XFA 刷新示意圖:原生版面呼叫堆疊內的頁面事件只標記待辦刷新並投遞視窗訊息,訊息稍後清掉過期的選取狀態、把頁碼夾限到新的 PageCount,再重載或卸載頁面 handle
重載等原生呼叫堆疊退淨才動手:投遞的訊息合併重複事件,檢視器接著夾限頁碼、重載頁面並觸發 OnPageChange

同樣的約束也適用您自己的程式碼。OnXfaPageCountChanged 跑在那個原生版面回呼裡,把它當通知對待:在那裡更新標籤、旋鈕範圍與工具列狀態,重一點的事——關文件、開另一份——用投遞訊息排隊,等回呼返回之後再跑。TPdfView.OnPageChange 會告訴您檢視器實際重載完頁面的時機,那一刻讀 PdfView1.PageNumber 拿到的就是夾限後的值。Tab 鍵巡遊與表單檢視器開檔時的 FormType 檢查,見PDFium Component 的 PDF 表單欄位導航

點擊 Full XFA 欄位為什麼丟出「Cannot open text page」?

Full XFA 頁面沒有 PDF 文字頁,v3.126.2 之前,檢視器預設的文字選取與連結偵測卻硬要去載一個。TPdfView.AllowUserTextSelection 在預設 True 時,滑鼠懸停會向文字層查游標下的字元,滑鼠放開的點擊會對頁面文字跑自動 URL 探測。Full XFA 頁面開不出文字頁,尋常一次點進欄位就可能以 Cannot open text page 例外收場。v3.126.2 起,當 TPdf.FormType 為 ftXfaFull 且 XFA 執行期可用時,兩條內部路徑都直接回報無結果——預設設定照常運作,欄位輸入保持可用

對 Full XFA 文件關掉 AllowUserTextSelection 仍是合理的 UI 選擇——頁面沒有文字可選,拖曳手勢也不該啟動選取模式。但它不能代替升級:較早版本裡,點擊觸發的 URL 探測不看那個屬性,選取關了照樣可能撞上同一個例外

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType 讀的是已開啟的文件,請在 FPdf.Active := True 之後呼叫
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Full XFA 頁面沒有 PDF 文字層;欄位保持可編輯
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

打字在 v3.126.2 也需要自己的修補。原生 XFA 文字編輯器收到字元時不會取代選取範圍:FORM_OnChar 在游標處插入,Backspace 一次刪一個字元——選起一個值再打字蓋過去,新舊文字就並肩排排站。PDFium Component 現在會記住點擊落在 XFA 文字欄位上,只要存在選取範圍、且文件授予填表或修改權限,就把輸入字元、Backspace 與 Delete 改道 FORM_ReplaceSelection。唯讀 XFA 欄位能不能改仍由原生編輯器決定,表單裡標了唯讀的欄位,即使在允許填寫的文件裡也保住原值。把 TPdfView.AllowFormEvents 設成 False 會一併停掉這條鍵盤改道,唯讀檢視器於是始終唯讀

速查:Delphi 檢視器裡的動態 XFA

症狀原因修正版本
表單長到兩頁後頁數仍顯示 1原生頁面事件傳的是增減差值,不是總數v3.126.1(包裝層)
欄位邊框移動,輸入文字與熱區留在原地已載入 widget 經自比較後跳過重排版v3.126.1(Windows V8 函式庫)
檢視器繪製或導流輸入到版面前的頁面狀態重新分頁後頁面 handle 未重載v3.126.2(延後刷新)
點進欄位丟出 Cannot open text page對無文字層的頁面做文字選取與 URL 探測v3.126.2
對選取值打字變成附加而非取代原生 XFA 編輯器在游標處插入v3.126.2
  • 在任何文件載入前把 EnableV8Engine 設為 True,並處理 OnXfaRuntimeMissing,以防純版函式庫先被載入
  • 總數從 TPdf.PageCount 或 OnXfaPageCountChanged 的 NewCount 參數讀;永遠不要自己加減頁數
  • OnXfaPageCountChanged 處理器保持輕量,它跑在原生版面回呼裡
  • 當前頁指示器在 TPdfView.OnPageChange 裡同步——它在延後重載夾限頁碼之後觸發
  • v3.126.1 以上的 Windows V8 DLL 與單元一起部署;widget 重排版的修正住在原生程式碼裡
  • 用真的會變頁數、且會把編輯過的欄位擠過分頁點的表單來測——固定長度範例會把這份清單上的每個 bug 都藏起來

動態 XFA 把頁數與欄位幾何都變成活值,檢視器只有在向已完成的版面取值、並在安全時機重載頁面時才守得住正確。這兩件事 PDFium Component 都在 TPdf 與 TPdfView 裡辦掉了,宿主只要聽。細節與下載見 PDFium Component for Delphi 產品頁