技術文章

用 HotPDF 在 Delphi 中建立 AcroForm 欄位與動作

一個 AcroForm 動作,是掛在某個介面元件上的字典,它告訴檢視器:當這個元件發生某件事時該做什麼。按一下按鈕,檢視器就去讀它的動作字典:URI 動作開啟一個網址,JavaScript 動作執行指令碼,SubmitForm 動作把蒐集到的欄位值送到某個端點,ResetForm 動作把它們清回預設值。動作是資料,不是烤進檔案裡的行為。ISO 32000-1 §12.6 定義了字典的形狀;引擎則由檢視器提供,由它來詮釋。這道切分很要緊,因為一個完美寫進 PDF 的動作,若對面那個閱讀器沒有對應的引擎,它依然什麼都不做,而許多 AcroForm 的苦惱追根究柢是這道落差,不是欄位寫壞了

HotPDF 直接從 Delphi 與 C++Builder 寫出那些字典,連同它們所依附的欄位介面元件。每一份互動表單裡都有兩套結構在運作:使用者在頁面上看到的介面元件,以及底下承載資料與接線的欄位加動作機制。它們是各自獨立編輯的,而其中一套錯了,另一套看起來還是好好的。下面幾節會走過欄位命名、按鈕動作本身、欄位層級的 JavaScript,以及那一類因為完全住在第二套結構裡、所以能通過目視檢查的缺陷

HotPDF 中的 AcroForm 介面元件層對映到底層的欄位值、提交動作字典,以及一處同意欄位的匯出值不符
使用者按的是介面元件層,而值走的是底下的欄位與動作層,不符之處就藏在那裡不見天日

欄位名稱是路由鍵,不是標題文字

每一個 AcroForm 欄位都帶著一個完整限定名稱。ISO 32000-1 §12.7.3 讓那個名稱、而不是看得見的標題文字,成為表單匯出或提交時該欄位的值所依附的鍵。從 VCL 設計走過來的開發者,往往把控制項名稱當成私有的程式碼識別字,但它在這裡不是。它是傳輸格式

由此推出的第一件事是:兩個擁有相同完整限定名稱的欄位,並不是兩個欄位。PDF 把它們當成同一個欄位的兩個 widget 註釋,共用同一個值,所以在其中一個裡打字,另一個當場跟著更新。當客戶姓名必須重複出現在合約每一頁時,那正是您要的。而當某個產生迴圈不小心跨三頁重複用了 'Field1' 時,它就是臭蟲。沒有任何目視檢查抓得到後面這種情況。每一頁照樣畫出自己的方框,那道連動只有在有人開始打字時才會現形

applicant.email 這種帶點的名稱會建出階層。父節點 applicant 把它的子節點分成一組,而這正是讓重設或提交只鎖定表單一部分的關鍵。一開始就這樣命名欄位一毛錢都不必付,而在接收系統第一次只要 applicant 那一組時,它就把本錢賺回來了

選項按鈕有它自己的規則。應該連動切換的按鈕必須共用同一個群組名稱。在 HotPDF 裡,傳入相同群組名稱的 AddRadioButton 呼叫,會把它們的介面元件掛到同一個父欄位上,而每個按鈕的匯出值('basic''full')標明所選的選項。給每個按鈕各自不同的名稱,您得到的就是一排彼此獨立的開關,而不是一組互斥的群組,它算繪起來一模一樣,行為卻是錯的

逐頁建立欄位集合

HotPDF 透過 THPDFPage 的方法放置欄位,所以每個欄位都屬於建立它的那個頁面物件。要當心的順序陷阱是 AddPage。它一傳回就立刻把 CurrentPage 重新指向新頁面,於是它之後的任何欄位呼叫都會落在新頁上,即使該欄位在邏輯上屬於您剛離開的那一頁。請先把每一頁完成,繪製內容與欄位一起,再去呼叫 AddPage

procedure BuildClaimForm(Pdf: THotPDF);
begin
  // 第 1 頁:申請人區塊
  Pdf.CurrentPage.AddTextField('applicant.name', '', Rect(50, 700, 300, 722));
  Pdf.CurrentPage.AddTextField('applicant.email', '', Rect(50, 660, 300, 682));
  Pdf.CurrentPage.AddCheckBox('consent', 'Y', Rect(50, 620, 70, 640), False);
  Pdf.CurrentPage.AddRadioButton('coverage', 'basic', Rect(50, 580, 70, 600), True);
  Pdf.CurrentPage.AddRadioButton('coverage', 'full', Rect(90, 580, 110, 600), False);
  Pdf.CurrentPage.AddComboBox('plan', 'Standard',
    ['Basic', 'Standard', 'Premium'], Rect(50, 540, 200, 565));

  Pdf.AddPage;  // CurrentPage 現在指向第 2 頁
  Pdf.CurrentPage.AddListBox('riders', 'None',
    ['None', 'Flood', 'Earthquake'], Rect(50, 500, 200, 600));
end;

座標採用 PDF 的慣例,原點在頁面左下角。這跟 TextOut 繪製文字所用的是同一個原點,所以 Rect(50, 100, 200, 120) 會落在 Letter 頁面的底部附近,不是頂部。VCL 把 Y 放在頂端並往下增長,於是一張直接照搬過來的版面配置表,出來會是上下顛倒的,每個欄位都翻到頁面的錯誤那一端。請在一個共用的輔助函式裡做一次轉換,而不是在每個呼叫點各做一次,這樣改一處就修好整份表單

把按鈕接到 URI、JavaScript 與提交動作上

一個命令按鈕在動作掛上去之前是惰性的。HotPDF 透過 THPDFButtonAction 列舉(baURIbaJavaScriptbaSubmitURLbaResetFormbaHidebaShowbaNamed)公開 ISO 32000-1 §12.6.4 的各種動作型別,並提供兩個方法,在一次呼叫裡建立按鈕並繫上它的動作

Delphi 中的 HotPDF 命令按鈕動作型別:baURI 連結、baJavaScript 指令碼,以及帶明確格式旗標的 SubmitForm 送出
一次繫結呼叫就掛上這三種動作字典的任一種,而只有提交那一種帶著與接收端點之間的旗標契約
// 在系統瀏覽器中開啟說明頁
Pdf.CurrentPage.AddPushButtonWithAction('btnHelp', 'Help',
  'https://www.example.com/claims-help', Rect(320, 700, 420, 730), baURI);

// 執行檢視器端的 JavaScript
Pdf.CurrentPage.AddPushButtonWithAction('btnRecalc', 'Recalculate',
  'app.alert("Totals updated.");', Rect(320, 660, 420, 690), baJavaScript);

// 以 XFDF 提交,並把空欄位保留在酬載中
Pdf.CurrentPage.AddPushButtonWithSubmitAction('btnSubmit', 'Submit claim',
  'https://api.example.com/claims', Rect(320, 620, 420, 650),
  [sffXFDF, sffIncludeNoValueFields]);

提交旗標值得比一般人給它的更多思量。AddPushButtonWithSubmitAction 收一個 THPDFSubmitFormFlags 集合,而空集合會產生一次單純的 url 編碼 post,許多範例端點接受這種格式,許多生產端點則拒收。加上 sffXFDF 會把酬載切換成 XFDF。sffGetMethod 改變 HTTP 動詞。sffIncludeNoValueFields 把空欄位保留在酬載裡,而不是無聲地把它們丟掉,這在消費端要區分「不存在」與「空白」的那一刻就很要緊。這個旗標集合是您與接收端點之間介面契約的一部分,所以請跟解析送件的那個團隊把它敲定,而不是等第一批被退件之後才談

欄位層級的 JavaScript:keystroke、format、validate

按鈕點擊不是動作唯一的住處。HotPDF 也會把 JavaScript 掛到那些逐欄位事件上,具備指令碼能力的檢視器會在使用者輸入資料時觸發它們。共有三種觸發,而它們在輸入生命週期的不同時點觸發。keystroke 動作在每個字元抵達時執行,並在提交時再執行一次。format 動作在一次變更提交之後改寫顯示出來的值,純粹是為了呈現。validate 動作有最後的話語權,在提交的值成為欄位的值之前接受或拒絕它

HotPDF 欄位層級 JavaScript 事件的生命週期,從 keystroke 到 validate 再到 format,下方附上伺服器端驗證的警語
keystroke 與 validate 指令碼可以拒絕輸入,而 format 只是替顯示補個妝,沒有任何指令碼能在缺少 JavaScript 引擎的閱讀器裡存活
// 拒絕那些看起來不像電子郵件地址的已提交值
Pdf.AttachFieldKeyStrokeAction('applicant.email',
  'if (event.willCommit && !/^[\w.-]+@[\w.-]+\.\w+$/.test(event.value)) event.rc = false;');

// 把美國電話號碼顯示成 (NNN) NNN-NNNN
Pdf.AttachFieldFormatAction('applicant.phone',
  'event.value = event.value.replace(/(\d{3})(\d{3})(\d{4})/, "($1) $2-$3");');

// 在提交時拒絕未滿 18 歲的申請人
Pdf.AttachFieldValidateAction('applicant.age',
  'if (parseInt(event.value) < 18) event.rc = false;');

在 keystroke 或 validate 指令碼裡設定 event.rc = false,就是告訴檢視器拒絕這次輸入。難處在於,除非檢視器附帶 JavaScript 引擎,否則這一切都不會執行。Acrobat 與少數幾款桌面產品有。多數行動閱讀器、瀏覽器內嵌的算繪器與列印管線沒有,而且它們會一聲不吭地把指令碼扔在地上。所以欄位指令碼只為那一小群閱讀器跑得動它們的使用者改善資料品質,而它們就只做這件事。它們不是安全邊界。每一個提交上來的值抵達之後都仍然必須在伺服器上驗證,因為您不能假設用戶端檢查過任何東西

通得過目視審查的缺陷

最難抓的 AcroForm 缺陷,是那些住在資料結構而不是算繪裡的,因為把檔案打開來看什麼都看不出來。有四種常見到值得點名,而每一種都有一項能在發行前找到它的機械化測試

  • 匯出值漂移。AddCheckBox('consent', 'Yes', ...) 建立的核取方塊送出的是 Yes。一個以 Y 來比對的消費端會拒絕每一次送件,而頁面看起來完美無瑕。請把表單填好、從 Acrobat 匯出成 XFDF,再把值跟消費端真正期待的綱要比對差異
  • 意外的值連動。兩個共用完整限定名稱的欄位會合併成一個。症狀出現在資料輸入的時候,從不出現在產生的時候,所以測試方法是往表單裡打字,不是把它算繪出來用眼睛看
  • 下拉值落在選項清單之外。當傳給 AddComboBox 的目前值不在所列的選項之中時,各家檢視器對於要顯示它、清空它,還是把它標出來,意見不一致。把預設值留在清單之內,這種分歧就消失了
  • 流程結束後欄位仍可編輯。HotPDF 沒有針對 AcroForm 欄位的外觀壓平呼叫。凍結一份已完成表單的受支援做法,是建立欄位時帶上 ffReadOnly 旗標,它透過欄位自己的外觀串流讓值保持可見,同時拒絕編輯。該欄位仍然是一個活的表單物件,而那正是下游的組版與簽署工具預期會找到的東西

有一項檢視器端的行為值得記進迴歸筆記,即使沒有任何程式碼變更能處理它。企業版的 Acrobat 部署可以依政策停用 JavaScript 或限制提交目標,於是一個在每一版開發建置裡都能動的動作,在一台鎖緊的客戶桌機上可能毫無反應。請為按鈕什麼都不做的情況規劃一條看得見的退路,即使那條退路只是一句印在紙上、告訴使用者改怎麼做的說明

表單工作與文件其餘部分的接點

簽章欄位本身就是一種 AcroForm 欄位型別。一份日後要被認證或會簽的表單,在產生時就先保留那個欄位,會比事後補進去好,而位元組層面的理由在 數位簽章與 HotPDF PAdES 簽署的姊妹篇裡。以 XFA 封包而非原生 AcroForm 形式送進來的輸入是另一種狀況:把 XFA 壓平成 AcroForm 欄位是它自己的工作流程,有它自己的損失模型,因為這兩種表單技術無法共存於同一個檔案

這裡展示的欄位、動作與觸發方法,都屬於標準 HotPDF Delphi Component 給 Delphi 與 C++Builder 的 API;產品頁連結了完整參考,含欄位旗標的多載與完整的提交旗標列舉