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 已然一致
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,錯位的熱區就還在。逼出這個修正的迴歸測試,先把倖存的一列編輯成非預設值,再要求那個值出現在欄位的新位置——因為用預設值重建的列,會看起來像通過
TPdfView 怎麼重載頁面,才不會從 PDFium 腳下抽走 handle?
v3.126.2 起,TPdfView 把 XFA 版面變更之後的頁面重載,延到原生呼叫堆疊完全退淨之後。頁面事件通常在 PDFium 還在處理輸入時觸發:使用者按了 Add Row 按鈕,點擊跑了腳本,腳本改了實例數,版面就在同一個原生呼叫裡跑完。那一刻關掉再重開頁面 handle,會釋放呼叫端還在用的物件。v3.126.2 之前,檢視器只讓自己失效重繪,畫面上那個頁面 handle 可能繼續指著版面前狀態;若使用者當時正停在消失掉的最後一頁,選中頁碼就直接越界了
延後刷新分幾個小步完成,宿主看到的行為都能由此解釋:
- 頁面事件回呼把檢視器標記為有待 XFA 版面刷新,並投遞一則私用視窗訊息;訊息送達之前重複到的事件合併成一次刷新
- 還沒有視窗 handle 的檢視器保留待辦旗標,等
CreateWnd再投遞訊息;換文件、停用或銷毀檢視器都會清掉旗標 - 訊息送達時,檢視器清掉文字選取、搜尋高亮與聚焦欄位索引——三者指向的都是舊版面
- 選中頁碼被夾限到新的
PageCount;頁碼有變就走正常的換頁流程,否則重載當前頁,並重新套用縮放模式 - 版面若一頁不剩,檢視器卸載舊的頁面 handle,而不是去畫一個已不存在的頁面
同樣的約束也適用您自己的程式碼。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 產品頁