技術文章

Delphi 動態 XFA 表單執行環境:HotPDF 交易

HotPDF 在 Delphi 中透過 TXFAWidgetRuntime 填寫動態 XFA 表單——這是一個與宿主無關的 widget 層,把每次欄位編輯視為一筆交易:快照、驗證、計算、重排,然後整批發佈或整批回滾。它在您自己的 VCL 或 FMX 宿主內單執行緒運作,不需要安裝 Acrobat,並在配置任何東西之前先強制執行每一項預算

這個情境對把文件軟體送進政府或保險業的人來說再熟悉不過。一張理賠表或報稅表以 PDF 的形式抵達,其頁面內容只有一則「Please wait... if this message is not eventually replaced」通知,而每個真正的欄位都活在只有 Adobe Acrobat 會算繪的 XFA 封包裡。您的使用者想在您的應用程式裡填寫它。您也無法靠點陣化繞過去,因為表單會隨資料輸入增長列數,第三列之後的版面已經不是隨檔案出貨的那個版面

為什麼動態 XFA 仍是值得解決的問題

動態 XFA 持續存在,因為已部署的表單比承載它們的格式長壽。ISO 32000-1 §12.7.8 把 XFA 描述為 AcroForm 字典上持有 XDP 封包串流的 /XFA 項目,而 ISO 32000-2 棄用了整個機制;棄用把它移出了路線圖,卻沒有把它移出現場,依 XFA 3.3 規格製作的表單仍在簽發、仍具法律效力。靜態 XFA 可以退化為普通 widget 註解,HotPDF 在您呼叫 ApplyXFAAsAcroForm 時就是這麼做的,其取捨見把 XFA 表單壓平成 AcroForm 欄位。動態 XFA 是另一種動物:它的 occur 範圍、可增長文字與 calculate 腳本讓欄位集成為資料的函式,所以在使用者打完字之前根本沒有一份固定的註解清單可壓平。這正是 TXFAWidgetRuntime 填補的空隙:讓 XFA DOM 保持存活,在每次被接受的編輯後重算版面,並交給您的宿主一個平坦的、帶定位的 widget 陣列供繪製與點擊測試

執行環境交給宿主應用程式什麼?

它交給您幾何與狀態,以及沒有任何假定 UI 工具包的東西。TXFAWidgetRuntime 暴露 WidgetCountWidgets[I],後者是帶有 IDNameKindPageIndex、以 PDF point 為單位的 BoundsValueEditValue 以及 FocusedEditingReadOnlyValid 旗標的 TXFAWidgetState 記錄,而繪製、插入符描繪與鍵盤路由留在您的程式碼裡。Widget 身分是穩定且序數化的:每個 widget 得到形如 name[n]ID,其中 n 計算該欄位名在版面順序中先前的出現次數,所以重複子表單的第二列是 amount[1]。這個身分是在重建後存活的東西,也是 FocusWidgetBeginEditDispatchEventHitTest 共同使用的語言。對已開啟於 THotPDF 實例中的文件,CreateLoadedXFAWidgetRuntime 取出 XDP 封包、以第一頁的頁框作為版面頁面尺寸,並在檔案完全不含 XFA 時回傳 nil

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // 沒有 /XFA 時為 nil
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // 頁面空間點擊測試,最上層 widget 優先
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

提交欄位時什麼必須是原子的?

編輯能碰到的一切,而那遠不止欄位值。CommitEdit 在寫入任何東西之前呼叫 CaptureSnapshot,該快照涵蓋四樣東西:來自 TXFADocument.SaveToBytes 的序列化 XFA DOM、完整的 TXFAWidgetState 互動記錄陣列、LastCalculationPassesLastReflowPasses 計數器,以及目前的 Warnings.Count。只存節點值是誘人的捷徑,而它是錯的,因為 calculate 腳本或未解析的繫結可以呼叫 EnsureValueNode,把編輯開始時不存在的資料節點實體化;只還原值的回復沒有辦法移除它們,所以一筆被拒的編輯會在 datasets 封包裡留下永久性的結構殘渣。提交序列本身是嚴格的——寫入候選值、對被編輯欄位執行 validate、執行 calculate 到不動點、然後重排到版面穩定——任何階段的任何失敗都經由 FailAndRestore 收斂,它把快照位元組載入全新的 TXFADocument、重建 widget 清單、重新套用記錄下的互動狀態、重置計數器並把 Warnings 截斷回快照長度。失敗時 LastDiagnostic 持有原因,而在回復本身也拋出例外的病態情況下持有字面訊息 XFA transaction rollback failed

HotPDF 把一次 XFA 欄位提交視為一筆交易,在驗證、計算與重排之前擷取序列化 DOM、每個 widget 狀態、回合計數器與警告計數,然後把四者一起發佈或一起還原
CommitEdit 在寫入任何東西之前快照四類狀態,所以失敗的 validate、calculate 或 reflow 不會留下結構殘渣
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // 唯讀,或無此 widget
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // 範圍錯誤,或切斷了代理對
    Exit;
  end;
  Result := Runtime.CommitEdit;             // 全有或全無
  if not Result then
    // 文件、widget、計數器與警告都已回到編輯前狀態;
    // 焦點 widget 只是被標記為無效
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection 值得單獨一提,因為這裡是拒絕畸形輸入最便宜的地方。它拒絕切斷 UTF-16 代理對的選取範圍、拒絕含有不成對高或低代理項的替換文字,並拒絕任何長於 MaxValueChars 的結果。在按鍵層攔下這些,意味著交易機制永遠不必回捲一個寫到一半的星層平面字元

重建到私有清單,一次交換發佈

widget 重建絕不能被觀察到半成品狀態,所以 RebuildWidgets 建構一個完全獨立的自有 TObjectList,並在最後以單次賦值交換到位。原因不是美觀:TXFALayoutEngine.ComputeLayout 在重建進行中執行,並透過您提供的 MeasureText 函式回呼宿主程式碼,而且在碰到 widget 上限時可能拋出 EXFAWidgetRuntimeError。如果執行環境原地修改它的現用清單,任一條路徑都會讓宿主握著一份一半是舊版面、一半是新版面的清單,其中的 DataNode 指標還指向一份即將被回滾的文件。重排收斂則由 LayoutSignature 判定——這是一個由 widget 數量加上每個 ID、頁面索引與四捨五入到小數四位的邊界框構成的字串:CommitEdit 重建、比對簽章、重複,直到連續兩個簽章相符或回合預算耗盡。當簽章根本沒變時,LastReflowPasses 停留在 0,這是您區分純值編輯與真正讓表單增長的編輯的方法;互動狀態透過 widget ID 跨越每次重建延續,所以焦點與進行中的編輯在插入一列後依然存活

HotPDF XFA 執行環境在版面執行並回呼宿主量測程式碼期間,把 widget 清單重建到一份獨立的自有清單中,然後以單次賦值發佈完成的清單,宿主絕不會觀察到半成品
重建發生在私有清單中,因為 ComputeLayout 可能在半途拋出例外,而 LayoutSignature 判定連續兩次重排何時已收斂

為什麼繫結欄位會讀到錯誤的記錄?

因為腳本在沒有資料情境的情況下執行。一個帶明確 <bind match="dataRef" ref="$record.actual"/> 的欄位,與一個恰好以該資料節點命名的欄位,是指向同一個值的兩個不同 widget;而帶 <occur max="2"/> 的重複子表單會產生數個共用一個名字、僅在所屬資料列上不同的 widget。對著文件根評估驗證與計算時,它們每一個都把 this 解析為整個 datasets 封包中第一個相符節點,於是第二列靜靜地驗證了第一列。HotPDF 的避免方式是:在版面產生 widget 項目時把解析好的 DataNode 存在其上,然後把那個節點穿進兩個 HPDFXFAEvaluateFieldScript 呼叫——xfskValidatexfskCalculate 皆然。同一個情境也決定當計算瞄準一個尚不存在的繫結時 EnsureValueNode 要對哪個節點建值,而當沒有任何繫結可解析時,提交以 XFA calculation target is not bound 乾淨地失敗,而不是寫進錯誤的列。這些腳本背後的 FormCalc 語意呼應 AcroForm 文件從AcroForm format 與 calculate 腳本得到的東西,但這裡的解析規則是 XFA 範圍而非欄位名範圍

預算在副作用之前檢查,而非之後

執行環境中的每個上限都是前置條件,因為在配置已經發生之後才執行的預算不算預算。TXFAWidgetRuntimeOptions.Default 出貨值為 MaxWidgets 10000、MaxValueChars 1048576、MaxCalculationPasses 16、MaxReflowPasses 4,而預設 TXFAFormScriptOptionsMaxOperations 100000 與 MaxElapsedMilliseconds 500。在底下,XFA DOM 套用自己的 TXFADOMLimits:展開後輸入與輸出各 128 MB 上限、最多 1024 個封包拼接、1000000 個節點、巢狀深度 256。有兩個細節比數字本身更重要。第一,腳本預算是整筆交易共用的,而非每個腳本一份:CommitEdit 播下一個剩餘運算計數器與一個單調截止時間,每次 validate 與 calculate 調用都從同一個計數器支取,並只拿到還剩的毫秒數,所以一張有兩百個計算欄位的表單不能把完整的 500 ms 花上兩百次。第二,截止時間來自可注入的 MonotonicMilliseconds 函式,這讓耗時行為在測試套件中可重現,而不是在繁忙的建置代理機上擲硬幣

HotPDF XFA 執行環境的預算分層,從 widget 與值的上限,穿過腳本運算與時間上限,直下 XFA DOM 上限,且一個運算計數器與一個截止時間由一筆交易中的每次呼叫共享
腳本預算是整筆交易共用而非每個腳本一份,所以兩百個計算欄位不能各自領一份新的 500 ms
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // 預設 10000
  Options.MaxCalculationPasses := 8;                       // 預設 16
  Options.MaxReflowPasses := 2;                            // 預設 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // 整筆交易
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // 只在重排真的移動了 widget 時觸發
      end;
    // ... 驅動表單 ...
  finally
    Runtime.Free;
  end;
end;

執行環境在哪裡停下,以及為什麼它大聲說出來

這個執行環境刻意不是通用的 XFA 腳本引擎。DispatchEvent 原生處理 enterexit 活動——以移動焦點的方式——而對每個帶腳本的其他活動,它用具體且穩定的診斷拒絕,而不是假裝支援:提到 addInstanceremoveInstanceinstanceManager 的腳本回傳 XFA runtime does not support event-driven instance mutation,碰到 .presence 的腳本回傳 presence 版本的對應訊息,其餘一律回傳 XFA runtime does not support this event script。一個可預測、可以寫分支處理的拒絕,勝過一個在您的範例檔上堪用、在客戶檔案上走樣的部分模擬

執行緒模型同樣直截了當:一個執行環境實例屬於一個執行緒,沒有內部鎖,因為版面引擎會回探宿主的量測回呼,而把那圈進鎖裡是一個等著重繪的死結。欄位內的富內容遵循函式庫其他地方的同一條保守路線,exData 負載按XFA exData 富文字與超連結所述處理,而簽章與按鈕 widget 以 ReadOnly 回傳,不受支援的 UI 種類以 xwkUnsupported 浮現,而不是以一個會靜靜遺失資料的可編輯文字框浮現

合在一起,這就是 Delphi 中動態 XFA 的可行答案:讓 DOM 保持存活、讓每次編輯成為一筆要麼完整落地要麼不留痕跡的交易、為每個回合設限,並明確說出什麼在範圍之外。如果您正為理賠、稅務或給付工作流程評估這項能力,XFA 執行環境隨 HotPDF Delphi PDF 元件提供,與那些專案通常一併需要的 AcroForm、壓平與算繪路徑在一起