技術文章

在 Delphi 中使用 HotPDF 建立自動計算的 PDF 表單

在發票或費用 PDF 中,項目會自動相加且日期會自動格式化,這需要在其 AcroForm 上附加三樣東西,而 HotPDF 在 Delphi 中為載入的檔案提供了這三種功能:用於 AFSimple_Calculate 加總的 ApplyLoadedFieldCalculation、用於 Acrobat AF 格式和驗證協助工具的 ApplyLoadedFieldValidation,以及用於寫入檢視器實際繪製值的 EnsureLoadedFieldAppearanceStream

HotPDF 是適用於 Delphi 和 C++Builder 的原生 VCL PDF 元件,這三個方法作用於您透過 LoadFromFile 開啟的文件,而非從頭建立的文件。每個方法都接收以 0 為起始的 AcroForm /Fields 陣列欄位索引(與 GetLoadedFormFieldNames 傳回的順序相同),並直接寫入欄位字典,因此整個流程就是載入、索定、附加、SaveLoadedDocument。建立欄位集本身是一項獨立的工作,已在在 Delphi 中向載入的 PDF 新增 AcroForm 欄位中介紹,而本頁面主要是關於為現有欄位集賦予算術和格式功能

為什麼 PDF 表單需要指令碼來加總自己的欄位?

因為 PDF 沒有電子試算表層。文字欄位包含的是字串而非公式,除非附加指令碼,否則檔案中沒有任何內容會在項目變更時重新計算總額。該機制是欄位的附加動作字典 /AA(ISO 32000-1 §12.7.5),它將觸發事件對應至動作:字元輸入時的按鍵觸發(K)、用於顯示的格式觸發、提交時的驗證觸發(V),以及表單上任何欄位變更時都會觸發的計算觸發(C)。每個動作都是 JavaScript 動作 /S /JavaScript(ISO 32000-1 §12.6.4.14),而它執行的指令碼正是實際進行加總或重新格式化的部分。Acrobat 隨附了一個現成的協助工具函式庫——AFSimple_CalculateAFNumber_FormatAFDate_FormatEx 等等——因此附加的指令碼通常是呼叫該函式庫的一行程式碼,而非自訂程式碼

有一個細節決定了多步驟表單是否能給出正確答案:計算順序。Acrobat 會執行每個帶有計算動作的欄位,並且按照 AcroForm /CO 陣列所列的順序執行,而不是按頁面順序。ApplyLoadedFieldCalculation 將計算動作附加至您指定的欄位;當一個計算欄位作為另一個欄位的輸入時(例如稅收行讀取的加總小計,以及同時讀取這兩者的總計),您必須確認 /CO 順序將每個輸入列在取用它的欄位之前,否則總計將會讀取過期的小計,並在編輯時落後一步

如何使 PDF 表單自動計算總額?

ApplyLoadedFieldCalculation 接收目標欄位索引、運算,以及它應該彙總的來源欄位名稱。HotPDF 會將 AFSimple_Calculate("SUM", ["lineTotal.1", "lineTotal.2", ...]) 寫入目標欄位的計算動作中,並透過 THPDFFieldCalcOp 列舉來選擇功能:fccSumfccAveragefccProductfccMinimumfccMaximum 對應至 Acrobat 的 SUM、AVG、PRD、MIN 和 MAX。來源名稱是 AcroForm 欄位名稱,即 GetLoadedFormFieldNames 傳回的相同字串,因此對三個項目和運費進行加總只需一次呼叫

var
  Pdf: THotPDF;
  Names: THPDFAnsiStringArray;
  i, TotalIdx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    Names := Pdf.GetLoadedFormFieldNames;
    TotalIdx := -1;
    for i := 0 to High(Names) do
      if Names[i] = 'grandTotal' then TotalIdx := i;
    if TotalIdx >= 0 then
    begin
      Pdf.ApplyLoadedFieldCalculation(TotalIdx, fccSum,
        ['lineTotal.1', 'lineTotal.2', 'lineTotal.3', 'shipping']);
      Pdf.EnsureLoadedFieldAppearanceStream(TotalIdx);
    end;
    Pdf.SaveLoadedDocument('invoice-live.pdf');
  finally
    Pdf.Free;
  end;
end;

在總計欄位上呼叫 EnsureLoadedFieldAppearanceStream 並非只是裝飾。計算是在檢視器內部執行的,因此在支援指令碼的閱讀器開啟檔案之前,總計欄位本身是沒有轉譯內容的。寫入外觀串流可以給予其一個臨時顯示的數值,這正是印表機排程或縮圖產生器在不執行指令碼時所能讀取到的內容

使用 AF 協助工具套件格式化和驗證欄位

ApplyLoadedFieldValidation 可以在一次呼叫中附加格式、按鍵和驗證指令碼,且您傳遞的 THPDFFieldValidationKind 決定了它所連線的 Acrobat 協助工具。傳入 fvkCurrency,HotPDF 會寫入 AFNumber_Format(2,0,0,0,"$ ",true) 作為格式指令碼,並搭配對應的 AFNumber_KeystrokefvkNumber 使用 "." 作為分隔符號引數,fvkPercentage 輸出 AFPercent_Format,而 fvkDate 輸出 AFDate_FormatEx("dd/mm/yyyy")(除非您在 DateFormat 引數中傳入自己的遮罩)。特殊格式——fvkZipCodefvkSSNfvkPhone——會對應至 AFSpecial_Format,並使用 Acrobat 為各自保留的代碼,而 fvkArbitraryMask 則透過 Mask 引數接收遮罩字串,用於處理那些預設值未涵蓋的任何內容。對於數值類型,HotPDF 還會附加 AFRange_Validate 指令碼,以便在提交時拒絕超出範圍的輸入

// 將發票日期格式化為 ISO、金額格式化為貨幣,並遮罩稅務識別碼
Pdf.ApplyLoadedFieldValidation(DateIdx,   fvkDate,          'yyyy-mm-dd', '');
Pdf.ApplyLoadedFieldValidation(AmountIdx, fvkCurrency,      '', '');
Pdf.ApplyLoadedFieldValidation(TaxIdIdx,  fvkArbitraryMask, '', '999-99-9999');
Pdf.EnsureLoadedFieldAppearanceStream(AmountIdx);

AF 協助工具涵蓋了常見的情況,因此您無需手動為其撰寫 JavaScript。當欄位需要任何預設值均未表現的邏輯時(例如總和檢查碼、跨欄位規則、自訂 Regex),請直接使用欄位層級的動作方法自行附加原始指令碼(已在使用 HotPDF 建立 AcroForm 欄位與動作中介紹),該方法可直接接收 JavaScript 字串。這兩種路徑都寫入相同的 /AA 字典中,因此同一個文件中可以並存一個欄位上的預設貨幣格式,以及另一個欄位上手動編寫的驗證

為什麼必須重新產生外觀串流?

因為在 PDF 中,值字串與檢視器繪製的像素是兩回事,修改其中一個並不會改變另一個。欄位將其值儲存在 /V 中,但閱讀器轉譯的內容來自欄位外觀串流 /AP /N。若僅透過物件圖編輯 /V,嚴格的檢視器仍會顯示舊的外觀,直到進行重新產生。表單字典帶有 /NeedAppearances 旗標,要求檢視器在開啟時重建每個外觀,Acrobat 也遵循此旗標——但瀏覽器內嵌的轉譯器、行動裝置閱讀器、列印伺服器和縮圖產生器經常忽略它,僅繪製已存在的外觀串流,甚至什麼都不繪製。EnsureLoadedFieldAppearanceStream 透過根據欄位 /DA/V/Rect 自行建立 /AP /N Form XObject 來解決此差距,使轉譯後的值隨檔案一起傳輸,而不是依賴檢視器來重新繪製它

// 儲存前為每個欄位寫入外觀,以便在所有地方轉譯值
Names := Pdf.GetLoadedFormFieldNames;
for i := 0 to High(Names) do
  Pdf.EnsureLoadedFieldAppearanceStream(i);
Pdf.SaveLoadedDocument('invoice-flat-appearance.pdf');

AF 指令碼停止作用之處

每個 AF* 指令碼都依賴檢視器擁有 JavaScript 引擎,而大多數檢視器並沒有。Acrobat 和少數桌上型產品可以執行這些協助工具;Chrome、Edge、Firefox 內建的 PDF 檢視器以及主要的行動平台,連同伺服器端的點陣化器和列印管線,完全不執行任何表單 JavaScript。在這些閱讀器中,計算永遠不會觸發,格式也永遠不會套用——欄位僅顯示上次儲存時的外觀串流,這正是寫入 /AP /N 的重要性所在。請將 AF* 層視為針對支援指令碼的閱讀器使用者子集的便利措施,而不能將其視為保證。任何必須在伺服器上確保正確的總額(例如您將據以請款的發票金額、您將申報的稅額)都必須在表單收回後在您那一端重新計算,因為您不能假設填寫表單的閱讀器執行了您的任何一行指令碼

還有一個情況值得提及:以 XFA 形式傳入的表單。動態 XFA 表單在其 XML 負載中攜帶了其專屬的計算引擎,而 AF* 協助工具對此一無所知,因此將 AFSimple_Calculate 附加至 XFA 欄位毫無作用。請先將 XFA 表單扁平化為一般的 AcroForm(如在 Delphi 中進行 XFA 至 AcroForm 扁平化中介紹),然後此處的三個方法就能像處理任何其他載入的表單一樣,套用至產生的 AcroForm 欄位

建立自動計算和自行格式化的 AcroForm 歸結為對載入文件進行三次呼叫——附加計算、附加格式和驗證、寫入外觀——然後是 SaveLoadedDocument。此處介紹的載入表單 API 是適用於 Delphi 和 C++Builder 的 HotPDF 元件 的一部分,該元件說明了完整的 THPDFFieldValidationKindTHPDFFieldCalcOp 列舉以及其餘的 AcroForm 介面