技術文章

在 Delphi 中設定已載入 PDF 的表單欄位值

在幾乎任何 PDF 檢視器的欄位清單裡,德語理賠表單的街道欄位顯示為 Straße。其 /T 條目背後的位元組講的是另一個故事:<FEFF005300740072006100DF0065>,一串帶位元組順序記號前置的十六進位 UTF-16BE 字串。拿欄位名稱去比對這些原始位元組的程式碼,會回報欄位不存在,即使使用者能直接點進去。同一份表單可能把核取方塊的 on 狀態存成 2 而不是 Yes,國家清單存成 [export display] 成對值——您寫入的值與檢視器畫出的值,就此有了三種分道揚鑣的方式

這三種變化出自同一處:表單是幾年前用客戶手邊的製作工具建的,如今您的 Delphi 應用程式得從資料庫填它、再交回一份在每個閱讀器裡渲染一致的檔案。表單不可能重做,所以填寫程式碼必須遵守表單當初的成文規則。ISO 32000-1 對三種情況各有一條規則:文字字串見 §7.9.2.2、按鈕外觀狀態見 §12.7.4.2.3、選擇項目見 §12.7.4.4 的表 231。建立尚不存在的欄位是另一件事,見 在 Delphi 為已載入 PDF 新增 AcroForm 欄位

找到欄位:HotPDF 先解碼 /T 再比對名稱

THotPDF.SetFormFieldValue 有兩個多載,一個收從零起算的欄位索引,一個收完整限定名,兩者都對照元件首次使用時從 AcroForm 樹建好的名稱表解析。建表的時候,每個部分名稱都過一道解碼器:十六進位 /T 字串先轉回位元組;若位元組以 FE FF 開頭且長度為偶數,其餘部分按 UTF-16BE 讀出、再編成 UTF-8。解碼後的部分名稱以句點接上父欄位,與 §12.7.3.1 的描述一致,所以名為 Address 的父欄位底下名為 City 的子欄位,會註冊成 Address.City。被解碼的只有查找鍵。檔案裡的 /T 物件保持十六進位原貌,存檔也不會動到您只填了值的欄位的身分

鍵進表之前先轉小寫,所以 'address.city' 找得到 Address.City。這超出了標準——標準把欄位名稱視為區分大小寫——也代表兩個只有大小寫不同的欄位會在查找時撞名。對從人工輸入的設定檔讀欄位名稱的程式碼來說,漏一個大寫比同一份表單裡兩個只差大小寫的欄位常見得多

HotPDF 解析非 ASCII AcroForm 名稱的方式:十六進位 /T 字串轉回位元組,FE FF 位元組順序記號按 UTF-16BE 解碼後重編為 UTF-8,限定名稱接上父欄位,Applicant.FullName 與名為 Straße 的欄位都進了查找表
改變的只有快取鍵:欄位字典保持十六進位編碼,查找時小寫正規化只是標準之外的便利,存檔永遠不會改寫您只填了值的欄位的身分

解碼後的名稱是裝在 AnsiString 裡的 UTF-8 位元組,而 Delphi 自己的字串轉換正好在這裡攪局。'Applicant.Straße' 這樣的字面值傳進 AnsiString 參數時,會經系統 ANSI 字碼頁轉換,ß 到了那頭變成單一位元組 DF,而不是 UTF-8 的 C3 9F,查找就落空。純 ASCII 名稱不受影響。其他情況請一次性列舉欄位、用 UTF8ToString 解碼每個名稱、走索引多載寫入:

// FindFieldIndex 是您自己加的輔助函式:拿解碼後的 UTF-8 名稱
// 與 Unicode 字串比對,字面值與查找之間就不會隔著
// ANSI 字碼頁
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII 名稱可以直接走 by-name 多載
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // 值一樣是 AnsiString,字面 'ß' 會看系統字碼頁臉色;
    // FEFF 十六進位(Hauptstraße 12)可避開
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // 同樣帶 FEFF 前置的 UTF-16BE 十六進位也能配 by-name
    // 多載(這個是 München);以十六進位字串寫入
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

SetFormFieldValue 寫了什麼,逐步看

找到欄位之後,兩個多載跑同一套流程:寫 /V、重建選擇索引、標記欄位已變更、切換按鈕外觀狀態、重建文字或選擇外觀,並把欄位記進髒集合。最後這步餵給無參數的 RecalculateLoadedFormFieldsIncremental,它只重跑讀到已變更欄位的計算腳本——直接讀,或經列項、小計、合計這類鏈條讀

新 /V 的物件型別取決於欄位。核取方塊與單選鈕,SetFormFieldValue 一律寫名稱物件,因為 §12.7.4.2.3 把選中狀態存成名稱,而 PDF 名稱就是純位元組、沒有文字字串編碼。其餘欄位都拿字串物件,傳入的值先被檢查:以 FEFF 開頭、長度偶數、只含十六進位數字的字串,視為 §7.9.2.2 的 UTF-16BE 形式,序列化成 <FEFF...>。上面的 City 那行靠的就是這個。其他任何值都以字面字串儲存、位元組原樣照收,所以純拉丁文字就以純文字進去

為什麼核取方塊在值改變之後還留著舊的勾

對按鈕欄位來說,值決定不了畫什麼。核取方塊控件帶 /AS 條目,指明 /AP /N 裡目前顯示的是哪條串流,檢視器照 /AS 上色,不看 /V。/V 改成 Yes 而 /AS 還說 Off,檔案就自相矛盾:壓平器會把未勾選的框烤進頁面,表單資料卻聲稱已勾選。所以 SetFormFieldValue 會走訪按鈕欄位與 /Kids 陣列裡的每個條目,從每個控件的 /AP /N 讀出 on 狀態名稱,與欄位值相符就把 /AS 設成該名稱、不符就設 Off

只改 /V 時 HotPDF 核取方塊為什麼留著舊勾:檢視器從 /AS 外觀狀態畫進 /AP /N,所以 SetFormFieldValue 走訪欄位與每個子項,把 on 狀態名稱讀成 Off 以外的第一個鍵,相符就改寫 /AS,否則設 Off
單選鈕群組拿每個子項對照經 /Parent 鏈從父欄位繼承的值,所以把群組設成某個匯出值,恰好只點亮那個控件、熄掉所有兄弟

真實表單會在兩個地方彎折這條規則。off 外觀在標準裡叫 Off,製作工具卻常常根本不留它的串流、讓檢視器什麼都不畫,所以只有單一條目的 normal 外觀字典完全合法。on 狀態名稱則隨作者高興:Yes、On、2、表單自己語言裡的某個詞。所以 on 狀態取 /AP /N 裡第一個不是 Off 的鍵,與您的值比對時不分大小寫。單選鈕加上 §12.7.4.2.4 的安排:選中值住在父欄位的 /V,每個子控件通常沒有自己的 /V。每個子項對照的是經 /Parent 鏈繼承來的值

// 核取方塊:值必須等於 /AP /N 裡的 on 狀態鍵
// (這裡是 'Yes';表單設計者可能挑了 '2' 或 'On')
Pdf.SetFormFieldValue('Consent', 'Yes');

// 單選鈕群組:/V 以名稱形式寫在父項上;每個子控件
// 的 /AS 被設成它自己的 on 狀態名稱或 Off
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// 清除核取方塊:與任何 on 狀態都對不上的值會得到 /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

反面代價是打錯字不會丟例外。'yes ' 帶尾隨空格,什麼都不匹配,方塊落到 Off,檔案照樣完全有效。值來自資料庫欄位的話,呼叫前先 trim

選擇欄位:讓 /I 與 /V 保持同步

下拉選單與清單方塊把選擇記在兩處。/V 之外,表 231 把 /I 定義為指向 /Opt 的從零起算索引陣列,檢視器看到 /I 指向選項 0、/V 卻指名選項 3,可能會亮錯列。SetFormFieldValue 對選擇欄位的每次呼叫都重建 /I。它先把欄位自己的 /I 條目刪掉,絕不原地編輯舊陣列:間接陣列可能與另一個欄位共用,動它就連那個欄位的選擇一起動了。接著經 /Parent 鏈解析 /Opt——選項可能繼承而來——並走訪條目。裸字串選項直接比對,[export display] 成對值比對匯出元素,元素少於兩個的成對值跳過

HotPDF 讓選擇欄位保持一致的方式:SetFormFieldValue 動手前先刪掉本地 /I 陣列,經 /Parent 鏈解析 /Opt,以解碼文字比對每個選項的匯出半邊,首個相符處寫出單元素 /I,可編輯下拉的值沒有索引時什麼都不寫
裸字串選項直接比對,export display 成對值比匯出元素;/Opt 之外的值正確地不留下索引 — 過時的 /I 指錯列,比沒有更糟

比對在解碼後的文字上進行,所以以 PDFDocEncoding 儲存的選項,配得上以 UTF-16BE 十六進位寫入的值,反之亦然。純量值最多產生一個索引;欄位先前帶的多重選擇一律被取代。全都對不上時,完全不寫 /I。對可編輯下拉,這正是正確結果:§12.7.4.4 允許使用者輸入清單外的值,這種值沒有索引可指

下拉選單最常見的抱怨,根源就是同一條規則:呼叫成功、/V 帶著新文字,檢視器卻顯示沒有選中項。通常是呼叫者把顯示標籤(Canada)傳給了以 [CA Canada] 成對值建出來的清單。元件不能把這當錯誤,否則可編輯下拉就廢了,所以檢查得靠您自己的程式碼。GetFormFieldOptionValue 回傳每個選項的匯出元素,防線寫起來很短:

// SetChoiceByExport 是您自己的防線,不是元件的一部分:
// 對 /Opt 裡沒有匯出元素相符的值一律拒絕
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt 是 [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' 對上匯出元素,/I 變成 [1]
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

// 可編輯下拉配自由文字:直接寫。/V 設好、
// /I 移除,不虛構索引
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

沒有 /NeedAppearances,新值也看得到嗎?

文字與選擇欄位看得到。檢視器畫的是儲存的 /AP 串流,不看 /V,所以新值配舊外觀,螢幕上與列印出來的都是舊文字——除非 AcroForm 字典帶 /NeedAppearances true(§12.7.3.3)且檢視器願意買帳。壓平器與縮圖產生器經常無視那個旗標。SetFormFieldValue 在呼叫過程中就為文字與選擇欄位重建外觀串流,用的是與公開的 EnsureLoadedFieldAppearanceStream 相同的建構器。串流從繼承的 /DA 字串、/Q 對齊、comb 欄位的 /MaxLen 與值本身建出。/DA 點名的字型經 AcroForm 的 /DR 資源解析,所以 Type0 字型保住自己的字形,不會退回 Helvetica。按鈕保留設計者畫的狀態外觀;對它們,動的只有 /AS

SetFormFieldValue 是程序,不告訴您到底畫了沒有。沒有可用 /Rect 的控件拿不到串流,而帶孤兒控件或缺矩形控件的壞表單確實存在。渲染結果要緊時,設完值自己呼叫 EnsureLoadedFieldAppearanceStream。至少一個控件拿到串流就回 True。GetFormField 給您一個 THPDFLoadedFormField,其 Index 屬性正是呼叫要的;那個物件歸您管,用完要釋放

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False 代表這個欄位沒有任何控件有矩形可畫;
    // 值存進去了,但不會有任何東西顯示它
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

檢查 /V 改了沒,幾乎證明不了使用者會看到什麼。有意義的測試是渲染前後的頁面,確認控件矩形內的像素變了、矩形外的沒變。我們堅持這道檢查,是因為外觀位元組曾經騙過我們:某條串流每個位元組都對,卻有一個照辦 /NeedAppearances true 的檢視器從 /V 重建顯示,什麼也沒顯示出來

讀回數值,以及接下來常做的事

GetFormFieldValue 回傳欄位繼承的 /V。以 FEFF 前置十六進位儲存的值回來時已解碼成 UTF-8,所以上面的 City 欄位讀回來是 München 的 UTF-8 位元組,UTF8ToString 把它變成 Delphi 字串。字面字串帶著儲存時的位元組回來。往返測試請兩側都比解碼後的文字,別比您傳入的拼法

表單與外部系統之間整批搬欄位資料、而非一欄一呼叫,是 Delphi 的 XFDF 匯入與匯出的主題。把完成的表單鎖到沒人改得動,是 在 Delphi 壓平 AcroForm 與 XFA 欄位,它把這裡描述的 /AS 狀態與外觀串流精確變成靜態頁面內容。那個時間點上不一致的東西永遠不一致,所以先填、再查、最後壓平

SetFormFieldValue、EnsureLoadedFieldAppearanceStream 與漸進式重算 API 都是 HotPDF Delphi Component 的一部分,旁邊還有從 Delphi 或 C++Builder 重新命名、移除與壓平已載入文件欄位的各個呼叫