技術文章

PDF 表單欄位值為何「還原」:直接欄位陷阱

PDF 表單欄位值會「還原」,是因為欄位以直接字典的形式存放:PDFium 把頁面 /Annots 陣列裡的那份副本升級成間接物件,而 /Fields 卻留著第二份直接副本。PDFium Component for Delphi 處理型別化欄位寫入時,會先把兩個參照統一成同一個間接擁有者,然後才寫入

這個症狀讀起來通常像鬧鬼:寫入回傳 True,緊接著的下一次讀取卻交回舊值;存檔、重開,舊值還在;什麼都沒擲出,PDF 在你手邊每個檢視器裡都完全正常

症狀:一次不生效的成功寫入

失敗模式很窄,但重現性極高:只出現在欄位字典以行內形式(直接物件)而不是帶編號間接物件寫成的表單上。多數製作工具都發出間接欄位,所以問題可以藏好幾個月,然後出現在某一個特定產生器的檔案上——往往是把整個 /AcroForm 行內寫出的手工匯出器

兩個觀察合起來就指向原因:寫入回報成功,而透過表單層對欄位的原生讀取回傳前一個值;兩者同時出現時,別再懷疑字串編碼,開始查物件所有權

為什麼寫入成功之後 PDF 表單欄位值會還原?

PDF 表單欄位值會還原,是因為兩份各自獨立的字典都宣稱自己是同一個欄位,而寫入更新的是欄位樹不會去查的那份。欄位的值住在它的 /V 條目裡,ISO 32000-1 §12.7.3.1 定義了該條目在哪裡具權威性:在從 /AcroForm /Fields 抵達、沿 /Kids 走到終端欄位的欄位字典上,可繼承條目從 /Parent 解析

同一條款容許了一個帶來所有麻煩的便利做法:欄位只有一個 widget 附註時,欄位字典與 widget 附註字典可以合併成單一字典。在格式正確的檔案裡,這個合併字典是一個間接物件、被兩處參照——從 /Fields(或父代的 /Kids)與從頁面的 /Annots 陣列;兩個參照、一個物件、一個 /V

一份字典的兩個副本

ISO 32000-1 §7.3.10 寫得很明白:只有間接物件擁有可共享的身分。直接字典沒有物件編號,所以產生器在 /Fields 與 /Annots 兩處都把合併的欄位/widget 字典行內寫出時,檔案裡並不是一個物件被參照兩次,而是兩個恰好帶相同鍵與值、長得一模一樣的字典

PDFium 容忍這種檔案,而且這份容忍把分裂變成永久。需要附註的 handle 時,PDFium 可以把頁面 /Annots 陣列裡的直接 widget 字典升級成間接物件,但等價的直接副本仍留在 /AcroForm /Fields(或父代的 /Kids)裡原處;從那一刻起,文件為一個邏輯欄位持有兩個擁有者。在頁面附註上改 /V 的寫入器,在字典層級得到貨真價實的成功;而表單層與之後每個讀取器都當作事實來源的欄位樹,從來沒看到這次變更

這就是舊值能撐過存檔與重開的原因:新值被忠實地序列化進了一個欄位階層並不擁有的物件

Delphi PDFium Component:兩份直接字典宣稱同一欄位,寫入落在升級後的附註上,欄位樹仍讀到舊的 /Fields 副本
寫入被忠實地序列化進欄位階層不擁有的物件,所以舊值撐過存檔與重開

PDFium Component 怎麼還原單一欄位擁有者

PDFium Component 在每次型別化寫入前先正規化直接欄位所有權:把直接的 /AcroForm 與欄位字典升級成間接物件、把每個直接 widget 綁到對應的頁面附註,並改寫 /Fields、/Kids、/Parent 與 /Annots,讓每個參照都指向同一個間接物件;然後才動 /V,欄位值、選中的 widget 與存出的文件共享同一個擁有者

難的是身分判定。內容完全相同的兩份直接字典並不能證明它們是同一個 widget——表單完全可能合法地含兩個長相相同的 widget。正規化器因此拒絕猜測,要求每個直接 widget 帶有明確且唯一的 /NM 附註名稱(ISO 32000-1 §12.5.2)。寫入路徑套用的規則很嚴:

  • 直接 widget 需要非空、至多 1024 個 UTF-16 單位的 /NM,且同一個名稱只能出現在恰好一個頁面附註上
  • 直接副本與頁面附註必須在 /Parent 與 PDFium 重建的 /AP 之外序列化結果完全相同;任何其他差異都拒絕寫入
  • 兩條父連結分開檢查,所以頁面附註上正確的 /Parent 遮不住原始直接副本上衝突的那條
  • 缺 /Parent 只有在欄位樹所有權能讓答案毫無歧義時才補上,等價的直接父副本則重綁到升級後的擁有者
  • 重複的全限定欄位名稱、循環或多重擁有的欄位樹、簽章欄位,在任何東西發布之前拒絕
  • 不相干的附註(例如同一頁上的連結)與不相干的 /AcroForm 條目原樣保留

正規化器還在硬預算下運作:4096 次欄位樹拜訪、頁面或陣列成員,巢狀深度 64,256 個新間接物件,每個升級字典 1 MiB,升級位元組總計 4 MiB,檢視過的字典位元組 128 MiB,完整欄位名稱每個 4096 個 UTF-16 單位、整份表單 262144 個。懷有敵意或損壞的檔案會撞上預算並以錯誤訊息失敗,而不是把一次表單填寫變成阻斷服務

整個操作是交易式的。TPdf.SetFormFieldValues 擷取快照、建立含正規化物件與新值的增量更新、載入該候選,只有候選通過驗證才替換活躍文件;正規化拒收檔案,或批次裡某個後續請求失敗時,原文件、它的值與邏輯欄位游標原樣不動

Delphi PDFium Component 如何統一直接 AcroForm 欄位所有權:快照、NM 身分檢查、參照統一、值寫入與經驗證的發布,含明確拒絕路徑
直接欄位字典在 /V 變更之前就升級並重綁到單一間接擁有者,所有權有歧義時乾淨拒絕

在 Delphi 寫入並驗證直接欄位值

安全的模式是:寫入,然後透過原生表單層讀回,兩者不符就當失敗——即使寫入回傳了 True。TPdf.SetFormFieldValue 以當前頁面上的索引定址欄位,收一個型別化的 TPdfFormValue 並填 TPdfFormValueWriteReport;TPdf.FormField[Index] 透過 PDFium 的表單層讀回值——它解析欄位樹,而不是信任 widget

Delphi 搭配 PDFium Component 的 PDF 表單欄位寫入與驗證模式:型別化 SetFormFieldValue、經 FormField 讀回,再加存檔與重開檢查
光是回傳 True 只證明字典變了——要透過欄位樹讀回,並測存檔與重開
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue 以當前頁面為目標
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // 對欄位樹驗證,不對我們自己的變數驗證
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

在所有權無法解析的檔案上——例如直接 widget 沒有 /NM——SetFormFieldValue 回傳 False、Report.UpdatedWidgets 為零、Report.ErrorMessage 指名問題,例如「Direct widgets require an explicit unique NM identity」;這正是你要的行為:清楚的拒絕,而不是無聲的半次寫入。還有一個前置條件值得知道:型別化寫入只動附註、不取得原生焦點,所以原生欄位編輯器仍持有焦點時,整個交易在擷取快照之前就拒絕;請刻意先呼叫 Pdf.ClearFormFieldFocus,不要用迴圈硬試

多個欄位、尤其跨頁時,用批次形式。TPdf.SetFormFieldValues 收 1 到 256 個請求,把它們全部套到一份候選上,然後全部發布或全部不發布;TPdfFormValuesWriteReport.FailedRequest 會告訴你是哪個請求弄壞了整批

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2;Batch.UpdatedWidgets 計數每個同步過的 widget
end;

定址同一個終端欄位的兩個請求會以重複擁有者的身分被拒,而不是讓最後一筆寫入獲勝,來源文件因此保得住。核取方塊與 radio 值遵循同樣規則;按鈕的 /V 與 /AS 分工收錄在PDFium 的核取方塊欄位值與 widget 狀態

證明值撐得過存檔與重開

同一個 session 裡的讀回,證明活躍文件內部一致;存檔加重開,證明檔案本身一致,而這個檢查便宜到可以放進你自己的整合測試

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

另外兩個位元組層級的陷阱:Trim 與檔尾 /ID

正規化直接欄位意味著重讀原始字典位元組,這帶出兩個與任何碰 PDF 語法的 Delphi 程式碼相關的較小陷阱

第一個是對 AnsiString 裡原始 PDF 位元組跑 SysUtils.Trim。選項文字這類字面字串可以合法地含帶 FE FF 位元組順序記號與內嵌 NUL 位元組的 UTF-16BE 文字;在 Unicode Delphi 裡 SysUtils.Trim 吃 string,所以這個呼叫會用當前 ANSI 字碼頁把 AnsiString 悄悄轉成 UTF-16 再轉回來,隨字碼頁不同,這趟往返可能改寫高位元組、弄壞 BOM,甚至波及後面的分隔符。安全的做法是讓語彙分析器留在位元組上:按位置跳過 PDF 空白(ISO 32000-1 §7.2.2 列出 NUL、tab、LF、FF、CR 與空格),用 Copy 切片,直到確知某個值是 PDF 文字字串才解碼

// 對原始 PDF 位元組有風險:AnsiString -> string -> AnsiString(W1057/W1058)
Token := Trim(Token);

// 位元組安全:按位置跳過 PDF 空白,再切片
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

第二個陷阱坐在增量更新本身。ISO 32000-1 §7.5.5(Table 15)與 §14.4 要求檔尾 /ID 是兩個位元組字串組成的陣列;組出稀疏更新識別碼的內部幫手只回傳兩個字串的內容,不帶 /ID 鍵也不帶陣列方括號,檔尾必須明確把它包成 /ID [ ... ]。PDFium 寬鬆的載入器與之後的完整重存都會藏住缺包裹的問題,所以可靠的檢查是拿獨立的嚴格解析器去解析候選檔與存出檔,找兩條 16 位元組字串。自己寫增量更新的人同樣會撞上;加密 PDF 的增量更新一文更深入地談檔尾鏈

在自己的 Delphi 表單程式碼裡該怎麼做?

  • 寫入後一律用 FormField[Index] 讀回驗證,測試裡再加存檔、重開、再讀
  • 把 False 加 ErrorMessage 當資訊:指名 NM、父衝突或重複名稱的拒絕,代表檔案的所有權真的有歧義
  • 自己產生 PDF 的話,把欄位與 widget 發成間接物件、給每個 widget 唯一的 /NM,從源頭移除歧義
  • 多欄位或跨頁填寫用 SetFormFieldValues,晚到的失敗就不會留下半張更新過的表單
  • 絕不對 AnsiString 裡的原始 PDF 位元組跑 Trim 或其他 string RTL 幫手

邊界也要講清楚:已簽章文件、XFA 表單、加密快照,以及 widget 所有權無法唯一解析的檔案,都留在宣告的 profile 之外並予以拒絕,不硬塞過關;工作流程會在文件之間搬移欄位的話,所有權問題更棘手,跨 PDF 文件嫁接 AcroForm 欄位處理的正是那個案例

PDFium Component 把這種型別化、交易式的表單寫入帶給 Delphi、C++Builder 與 Lazarus 應用,與渲染、文字擷取與文件編輯並列;版本歷史、授權與下載見PDFium Component for Delphi 產品頁