技術文章

用 PDFium Component 原子化批次填寫 PDF 表單

PDFium Component for Delphi 用 TPdf.SetFormFieldValues 原子化地填寫 AcroForm:你傳入 1 到 256 個以頁面與 widget 定址的型別化請求,元件要嘛在一次文件替換裡套用每一個值,要嘛一個都不套。某個請求無效時,TPdfFormValuesWriteReport.FailedRequest 會指名是哪一個請求弄沉了整批

這份保證在沒人盯著的工作裡最有價值。夜間匯入把 CRM 列推進理賠表單,或列印服務把保單資料疊上預印範本;逐欄執行時,第一個壞值——一個不是有效 radio 匯出值的代碼、一個超過欄位 MaxLen 的字串——會讓你手裡拿著一份半舊半新的 PDF;沒有任何東西崩潰,所以沒有任何東西驚動任何人。批次 API 的存在,就是為了讓失敗是一次乾淨的拒絕,而不是一份寫到一半的文件

為什麼逐欄填表對資料匯入很危險?

逐欄填寫危險,因為每次寫入都各自生效,迴圈中間出錯就留下一份既不像來源記錄、也不像原始範本的文件。經典模式是對 FormField[Index] 指定寫迴圈,每次都穿過 PDFium 原生表單填寫環境、一次一個 widget;值 1 到 17 落地,值 18 擲出例外,呼叫端現在必須精確知道哪些值已進去,才有辦法撤銷

多數呼叫端從不寫那段撤銷程式碼,寫了的通常也錯得很微妙:共用欄位已在它出現的每一頁上變更,核取方塊狀態已經翻轉;如果存檔跑在 finally 區塊裡,半填的檔案就以完整的樣子上了磁碟。對疊印工作來說,這比沒有檔案更糟——它會被印出來,然後有人會簽下去

另一個安靜的問題是焦點。經典 setter 驅動 PDFium 的互動式編輯器:殺掉既有焦點、聚焦目標 widget、全選文字、取代、再殺掉焦點。無頭工作無所謂,但在檢視器裡,這也會終結使用者在另一個欄位裡開著的編輯作業;PDFium Component 3.231.0 到貨的型別化寫入路徑完全不碰原生欄位焦點,3.233.0 把它從單一欄位擴展到跨頁批次

SetFormFieldValues 怎麼用一次替換套用整批?

TPdf.SetFormFieldValues 把每個請求對一份擷取下來的文件快照暫存,一趟寫完所有欄位字典,再以單一替換把完成的結果換上。原文件直到最後一步都保持活躍,所以該步之前的任何失敗都讓它毫髮無傷

PDFium Component for Delphi 五階段的原子化批次表單填寫序列:從預檢到驗證與替換,全有或全無
每個請求都對同一份快照暫存,活躍文件只在最後一步經過驗證的替換時才被碰動

這個序列同時解釋了保證與代價:

  1. 預檢最先執行:文件必須作用中、請求數必須在 1..256、不得有開著的 page-object 或 edit-journal 交易、不得有原生欄位持有焦點,而且文件必須授予 fill-forms 或 modify 權限
  2. 每個請求都驗證頁面範圍與輸入預算,FailedRequest 追蹤正在檢查的索引
  3. 元件擷取編輯快照、在副本裡正規化 AcroForm,然後依序套用每個請求:寫 /V,選擇項目寫 /I,共用該欄位的每個 widget 寫 /AS
  4. 變更以單一稀疏增量更新的形式(ISO 32000-1 §7.5.6 的意義)附加到快照上,不相干的物件逐位元組帶過
  5. 獨立的驗證器實體先載入候選位元組;只有它能乾淨開啟,元件才卸載活躍文件並載入新的,載入失敗則退回原始快照

內部替換步驟與元件編輯日誌快照用的是同一套機制,這正是復原與當機復原日誌與批次寫入不能重疊的原因:先把日誌交易完成或取消。文件替換期間自動生命週期動作被壓抑,所以 document-open script 無法在替換中途改寫欄位或搶焦點

在 Delphi 建立型別化請求批次

批次是 TPdfFormValueRequest 記錄的動態陣列,每筆帶 1 起算的 Page、0 起算的頁內 FieldIndex,以及一個型別化的 TPdfFormValue。值記錄按種類帶 Kind(fvkText、fvkChoice、fvkCheckBox 或 fvkRadioButton),加上按種類使用的 Text、ChoiceIndices 與 Checked 成員

匯入資料通常按欄位名稱對映,所以第一件事是把名稱翻譯成頁面與 widget 位址。FieldIndex 按與 FormFieldCount、FormFieldInfo 相同的順序計數頁面上的 widget 附註,簡單掃描就行;幫手取它為名稱找到的第一個 widget,這對共用欄位有意義,下一節會解釋

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // 只取第一個 widget:每個欄位一個請求
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

注意 AddRequest 遇到未知名稱是擲出例外,不是跳過。批次驗證的是位址,不是你的對映,所以範本裡缺欄位必須在這裡失敗,否則永遠不會失敗。位址解析完,建值與提交就很短

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // 要選的按鈕匯出值
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // 欄位 Opt 陣列的 0 起算索引
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

型別化的種類會在任何寫入發生前對照欄位類型檢查:文字值射向核取方塊、radio 值射向普通核取方塊、或對按鈕的寫入,一律拒絕。choice 索引必須有效、唯一且遞增;超過一個索引需要 MultiSelect 旗標,choice 欄位裡的自由文字只有可編輯的下拉式方塊接受。超過欄位 MaxLen 的文字值被拒而不是被截斷,唯讀欄位直接拒絕;帶 NoToggleToOff 旗標的 radio 群組,空匯出值(意即「清空該群組」)也拒絕

為什麼同一個共用欄位寫兩次會讓整批失敗?

TPdf.SetFormFieldValues 拒絕解析到同一個終端欄位的兩個請求,因為它們都會寫同一個值,而元件拒絕讓一個請求悄悄蓋掉另一個。檢查跑在欄位擁有者上,不是 widget 上,所以同一欄位在不同頁面上的兩個不同 widget 仍算重複

原因在 ISO 32000-1 §12.7.3.1 的欄位模型:終端欄位擁有值,widget 附註只是值被顯示的地方。出現在每頁頁首的 PolicyNumber 是一個欄位帶多個 widget,每個 widget 顯示同一個 /V;radio 群組同理,每顆按鈕是 widget,群組是欄位。透過第 1 頁的 widget 寫 PolicyNumber、再透過第 9 頁的 widget 寫一次,是一個欄位被寫兩次,任何「選一個贏家」的規則都會藏住對映裡的 bug

所以批次把它當錯誤處理,並在 FailedRequest 指名第二個請求。實務上的修法在上游:把每個欄位名稱解析到恰好一個位址(如 FindFieldAddress 做的),讓元件把值傳播到其他 widget;它在寫入時就這麼做,為共用核取方塊或 radio 群組的每個 widget 設 /AS,讓外觀狀態與欄位值一致,這個分工在核取方塊欄位值與 widget 狀態一文裡有講

一個終端欄位擁有值,兩頁上的 widget 顯示它,重複請求讓 PDFium Component for Delphi 的整批失敗
每個終端欄位一個請求——元件只寫一次 /V,並為共用它的每個 widget 設 /AS

讀懂失敗報告

失敗的批次回傳 False、不擲出例外,原文件與每個欄位值原封不動;TPdfFormValuesWriteReport 接著回報 Succeeded = False、UpdatedFields 與 UpdatedWidgets 皆為零、出事請求的 0 起算索引在 FailedRequest,以及 ErrorMessage 裡人類可讀的原因

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

FailedRequest 為 -1 代表拒絕屬於交易本身而非某個請求:原生焦點作用中、文件已簽章、表單是 XFA,或權限檢查失敗。非負值指向某一筆;聚合預算超支時,指向把整批推過上限的那個請求,它未必是錯的那個。在 Requests 旁邊維護一份來源欄位名稱的平行陣列,你就能把索引 14 直接翻成「第 2087 列,ShipDate 欄」寫進匯入 log

成功時,UpdatedFields 等於請求數,UpdatedWidgets 計數每個被碰到的 widget——共用的也算,是對範本的廉價健全性檢查

你會撞到的前置條件與限制

檢視器應用程式裡最常見的拒絕是原生焦點:使用者正在欄位裡打字時,SetFormFieldValues 在擷取快照之前就拒絕,擱置中的編輯器緩衝完好無缺;元件不會背著你丟掉焦點,因為那會提交或弄丟使用者還沒打完的文字。請刻意呼叫 ClearFormFieldFocus——效果等同點擊欄位外——然後再試

預算是固定且寫進文件的,匯入規模可以照著規劃:

  • 每批 1 到 256 個請求;更大的記錄拆成多批,並接受原子性此時按批成立
  • 每個請求至多 262,144 個 UTF-16 單位的文字與 4,096 個 choice 索引
  • 整批合計至多 1,048,576 個 UTF-16 單位的文字與 16,384 個 choice 索引
  • 快照解析器的一般文件預算照樣適用,包括 64 MiB 來源與 65,536 個物件

成功的替換也會使呼叫之前取得的 native handle、搜尋與選取失效,因為底下的文件是新的;頁面與檢視啟用狀態、邏輯欄位游標會保留,其他你快取過的東西——例如附註或 page-object handle——必須重新取得

這與 XFDF 匯入有何不同?

TPdf.ImportXFDF 與 TPdf.SetFormFieldValues 都把資料放進欄位,但 XFDF 匯入是寬容的合併,批次是嚴格的交易。XFDF 匯入按名稱把值配到欄位、跳過找不到的名稱、忽略重複,並回報套用了幾筆;對合併審閱者的註解這種部分結果仍有價值的場景,恰恰合適

批次在每個面向都反過來:它明確定址 widget、拒絕未知或打錯的目標、把重複當錯誤,且沒有部分結果。資料是來自其他工具的交換檔時用 XFDF;你的程式碼自己擁有對映、而半套用的記錄就是缺陷時,用 SetFormFieldValues

PDFium Component for Delphi 中 XFDF 匯入與原子化 SetFormFieldValues 批次的決策矩陣:定址方式、未知與重複目標、結果與適用場景
XFDF 寬容地合併交換檔,批次則拒收未知名稱、重複與部分結果

批次寫入不涵蓋什麼

批次是一個有邊界的 AcroForm profile,越界時它選擇說不而不是猜。XFA 表單拒絕,需要它專屬的編輯器;已簽章文件拒絕,因為在既有簽章下改寫欄位字典屬於簽章工作流程,不是填表操作;加密快照同樣在 profile 之外。畸形或歧義的按鈕匯出陣列拒絕,符合多顆按鈕的 radio 匯出值被退回,除非群組設了 RadiosInUnison。交易還會在寫入前正規化直接欄位字典,欄位值看似還原背後的那個案例就是它

外觀值得一句提醒。對變更過的文字與 choice widget,批次丟掉過時的 /AP 而不是出貨一份顯示舊值的外觀,並關掉 NeedAppearances,避免 PDFium 把表單裡每個不相干的 widget 重建一遍;PDFium 渲染時會補生缺失的外觀。填好的 PDF 若要進一條從不合成外觀的嚴格列印管線,先透過 PDFium 渲染或扁平化,並用自己的範本測那條路

SetFormFieldValues 與型別化值記錄隨PDFium Component for Delphi and Lazarus 出貨,產品頁列出完整的表單填寫與 AcroForm API,外加渲染、編輯與檢視器控制項