技術文章

FDF 與 XFDF 來回轉換中的多選 PDF 欄位(Delphi)

由 Deep、一個換行與 Blue 組成的清單方塊匯出值是完全合法的 PDF,第二個叫 Red 的選項也一樣。兩個都選、把選取結果壓平成一份以 LF 分隔的字串塞進交換檔,對方收到的就是 Deep\nBlue\nRed:是兩個選取還是三個,位元組裡沒有任何線索。多選值要從 FDF 或 XFDF 完整往返,唯一的前提是全程保持陣列形態——從離開 PDF 的那一刻起,到寫回 PDF 的那一刻為止

為什麼用換行串接永遠載不動多選值?

串接後的字串弄丟了值與值之間的邊界,又因為值本身允許包含分隔符,任何匯入器都沒辦法把邊界還原回來。ISO 32000-1 §12.7.4.4 允許 choice 欄位的 /V 項目放單一文字字串或文字字串陣列;帶 MultiSelect 旗標(/Ff 的位元 22)的清單方塊,只要選了不只一個選項就用陣列形態。同一節還定義了 /I,一個按遞增排序、從零起算的選項索引陣列,檢視器靠它區分共用同一匯出值的兩個選項。這兩種結構都塞不進字串型別的 API。HotPDF 的 GetFormFieldValue 回傳 AnsiString 且讀的是 /V 的字串形態,所以任何建在它上面的匯出,每個欄位只能寫出一個純量

匯入端有鏡像的難題。XFDF 把每個選取寫成自己的 <value> 元素,把它們交給純量設定器的匯入器得先黏起來,通常用 LF。碰到上面的 Deep/Blue 選項,這道黏膠與資料無法區分。HotPDF 因此不讓純量進入往返的中段:ExportLoadedFormToFDF、ExportLoadedInterchangeToFDF 與 ExportLoadedFormToXFDF 直接從欄位讀陣列,對應的匯入方法則建出新陣列,全程不經過串接字串

天真的多選往返:兩個被選的清單方塊選項其中一個內嵌換行,經純量 GetFormFieldValue 路徑壓平成單一字串 Deep、換行、Blue、換行、Red,下游讀者可解析成兩個選取或三個
把多選值串接成一個字串破壞了值的邊界,而本身含換行的匯出值讓壓平後的形態產生歧義

匯出的 FDF 與 XFDF 檔裡有什麼

邊界在磁碟上保持可見。FDF 裡,多選欄位變成具型別的陣列,每個項目保留它在來源 PDF 裡的寫法:十六進位字串以 hex 寫出,字面字串以 literal 寫出,CR 與 LF 逸出為 \r 與 \n。ExportLoadedFormToFDF 寫出的檔案裡,欄位上的 rich-text /RV 或非零的本地 /Ff 也一併帶進同一個欄位字典。XFDF 的根元素宣告 xml:space="preserve",這是 ISO 19444-1 的要求,它讓文字元素裡的每個空白字元都算資料。這對美化排版是個陷阱。HotPDF 把每個 <value> 的開始標籤、逸出後的文字與結束標籤寫在同一行,縮排留在元素之外,並把 CR、LF 與 TAB 編碼成字元參照,讓套用行尾正規化的 XML 剖析器無法把 CR LF 對改寫成單一 LF

HotPDF 為多選清單方塊寫出的匯出形態:FDF 每個欄位帶一個具型別陣列 /V [(Deep 換行 Blue) (Red)] 與 hex 的 region 值,XFDF 在 xml:space preserve 之下每個選取一個 value 元素,空白算資料
邊界在磁碟上保持可見:FDF 把每個選取留成自己的陣列項目,XFDF 把每個選取寫進獨立的 value 元素,匯入器不必猜測
<!-- FDF:每個欄位一個型別化陣列 -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF:每個選擇一個 <value> -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

寫呼叫端程式時有兩個匯出邊界情況要留意。ExportLoadedFormToFDF 先在記憶體裡組好完整的 FDF 本體,然後才建立目標檔案,所以無法匯出的值(例如陣列裡混了非字串的東西)會在磁碟上既有的 .fdf 仍完好時拋出。第二個情況是 XFDF 本身真正的缺口:清單方塊同時提供空字串匯出值時的空選取。<value/> 可以指「什麼都沒選」,也可以指「選了空字串選項」,格式沒有第三種寫法。ExportLoadedFormToXFDF 在這種情況下、於目標檔案開啟之前就拋出例外。FDF 沒有這個問題,因為 /V [] 與 /V [()] 是不同的物件。三個匯出器都會跳過沒有 /T 名稱的純 widget 端點,因為任何匯入器都無法把這種條目對回欄位

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // 多選清單方塊寫成 /V [(...) (...)]
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // 空選擇加空匯出選項:XFDF 分不出來,
          // 既有 .xfdf 檔原封不動
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

匯入的選取如何對照 /Opt 驗證?

HotPDF 只在目標是設了 MultiSelect 旗標的 choice 欄位、且每個值都能對上欄位 /Opt 陣列裡的匯出值(雙元素選項對取第一元素)時,才接受傳入陣列。每個選項槽只能用一次。兩個選項都匯出 b 的清單,會把 [<62> <62>] 當成兩個不同的選取接受,並拒絕第三個 b,因為沒有多餘的槽給它。重建的 /I 依 /Opt 順序排列,不論值抵達的先後,索引因此維持 §12.7.4.4 要求的遞增。新的 /V 與 /I 以游離物件建好,所有值都通過後才指派,被拒的值無法在欄位上留下半個陣列或過期索引

指派之後欄位會拿到全新的外觀串流。手寫匯入器最容易漏掉這一步,症狀又很迷惑:檢視器畫的是儲存的 /AP 而非 /V,資料明明變了,舊選取卻留在螢幕上與列印裡。清單方塊也會加入變更欄位集合,這代表無參數呼叫 RecalculateLoadedFormFieldsIncremental 時,會精準重跑讀取它的那些計算。從 FDF 抵達的 hex 寫法在存檔時仍是 hex。若你只需要設定一個純量值,在已載入 PDF 中設定單一表單欄位值一文講的就是那條路,它刻意不處理多選

HotPDF 對多選值的匯入驗證:目標必須是 /Ff 設了 MultiSelect 的 choice 欄位,每個傳入值都須對上一個 /Opt 匯出值且每槽只用一次,/I 按 /Opt 順序遞增重建,游離的 /V 與 /I 在全部值通過之後才指派
任何東西寫下之前,每個傳入值先對照欄位選項檢查,被拒的值不會留下半個陣列或過期的選擇索引

有一種輸入形態值得特別處理。PDF 可以把純 ASCII 匯出值存成不帶位元組順序記號的十六進位字串,Abr 寫成 <416272>,而逐位元組照抄 PDF 字串本體的匯出器,會產出元素文字是原始數位 416272 而非解碼文字的 XFDF。帶著原始數位 hex 匯出值的 XFDF 無法按拼寫對上 /Opt:嚴格比對找不到叫 416272 的選項,匯入失敗。HotPDF 的 XFDF 匯入器收到這種檔案時,會把未匹配的值以 hex 負載解碼再重試一次。重試刻意收得很窄:只對完全沒有精確匹配的值啟動;每個精確匹配都先保留槽位,被救回的拼寫絕不可能搶走後面精確拼寫的值需要的選項槽;存進 /V 的是選項自己的 PDF 字串,/V 與 /I 描述同一個選取。這是剛好的寬容:只在匯入原本會拋例外的地方觸發,已經匹配的值絕不會被重新解讀。匹配本身走單一 Unicode 解碼器,認得 PDFDocEncoding、帶任一位元組順序記號的 UTF-16 與 UTF-8,混用編碼的文件裡同一份邏輯文字也對上同一個選項

FDF 剖析的兩個陷阱:hex 結束符與外部物件號

第一個陷阱在詞彙層。在 << /T (region) /V <416273>>> 裡,第一個 > 收掉 hex 字串,接下來兩個才收掉字典。只找 >> 配對的掃描器會提早一個字元收掉字典、弄丟欄位。HotPDF 的檔案層級 FDF 匯入器在切 /Fields 陣列時追蹤自己是否處於 hex 字串或字面字串之中,而 ImportLoadedInterchangeFromFDF 背後的串流讀取器把整個檔案當 PDF 語法斷詞,所以天真的掃描看到兩個結尾括號的地方,兩者都看到三個

第二個陷阱關於參照。FDF 檔是一份帶自己物件編號的小型 PDF 語法文件(ISO 32000-1 §12.7.7),所以 /V [11 0 R] 指的是 FDF 檔的物件 11,永遠不是被填寫 PDF 的物件 11。拿目標文件去解析它,會悄悄抓進那邊恰好掛著該編號的任何物件。在 Delphi 2009 及之後的建置裡,interchange 讀取器把 FDF 解碼成完整的物件圖,在圖內解析參照,像對待內嵌陣列一樣驗證結果字串,FDF 裡沒有物件 11 就拋例外。檔案層級的 ImportLoadedFormFromFDF 不帶 FDF 的物件表、逐一讀取欄位字典;由參照組成的陣列過不了純字串檢查,該欄位被跳過

選一條匯入路線:跳過、拋出,或復原

三條匯入路線用同一套方式驗證多選值,差別在值被拒之後的處置。ImportLoadedFormFromFDF 跳過每個失敗的欄位、回傳實際套用的欄位數,所以回傳數低於送入的條目數是唯一的出事訊號。ImportLoadedFormFromXFDF 與 ImportLoadedInterchangeFromFDF 在第一個被拒欄位就拋出例外,而且每次呼叫在已載入文件上是一筆交易:例外離開方法時,呼叫已經改過的每個欄位(interchange 路線還包括每個註解)全部復原。回傳 False 的 interchange 匯入同樣復原,Status 與 info 記錄一併清空

經人審過再送回的資料,用會拋例外的路線。套用一半的訂單表單比被拒的更糟,因為存好的 PDF 裡沒有任何跡象能分辨哪些清單方塊來自審過的檔案、哪些留著舊值。ImportLoadedFormFromFDF 的跳過並計數行為適合另一種工作:拿一份可能為略微不同版次的表單所產生的 FDF,盡力預填

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // 只匯入欄位。落在 /Opt 之外的值,或對單選清單送進
      // 多個值的情況,會拋出例外並復原整個匯入
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
      begin
        // 只重跑讀取這批匯入欄位的計算
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

把自家的資料儲存綁到 XFDF 陣列回呼

在 XFDF 與任何能用回呼描述的欄位儲存之間轉換的 HPDFXFDF 單元,把陣列支援放進獨立的記錄 THPDFXFDFArrayAccess,以及 HPDFXFDFExportFields 與 HPDFXFDFImportFields 的額外多載。往既有 THPDFXFDFAccess 記錄尾端塞新槽位更省事,但是錯的。呼叫端常把那個記錄宣告成區域變數、只填自己知道的槽位,記錄尾端新增的函式指標就會握著堆疊垃圾,程式庫會把它誤當回呼。獨立記錄讓既有程式碼保住版面與多載,那些多載內部傳遞的是全 nil 的陣列記錄

面對重複的 <value> 元素,兩個匯入多載行為不同。原本的純量多載仍以 LF 串接並呼叫 SetFormFieldValue,既有呼叫端依賴這個約定。支援陣列的多載絕不串接:把清單交給 SetFormFieldValueArray,只有當你的回呼對單一值說不時才退回純量設定器,你的回呼對兩個以上的清單說不就拋例外。匯出時,從 Default(THPDFXFDFArrayAccess) 出發,沒指派的槽位(包括選用的 SetFormFieldRV)保持 nil。任何清單型欄位都從 GetFormFieldValueArray 回傳 True,包括一個都沒選的;回傳 False 則交給純量回呼處理。空陣列會以單一空 <value/> 寫出,所以提供空字串選項的儲存應在回呼裡拒掉這種情況,與 ExportLoadedFormToXFDF 同一套作法

uses HPDFXFDF;

// 純函式指標,不是 "of object":Context 帶你自己的儲存。
// TFormStore 與其方法是你的程式碼,不是 HotPDF 的一部分
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // 你既有的純量綁定
  ArrayAccess := Default(THPDFXFDFArrayAccess); // 每個未用的槽位都是 nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

以上全部假設清單方塊已經存在、/Ff 裡的 MultiSelect 位元已經設好。建立 choice 欄位與設定旗標位元在為已載入 PDF 加入 ListBox 等 AcroForm 欄位一文;經 XFDF 的 <annots> 部分傳送的註解標記則在HotPDF 的 XFDF 註解匯入與匯出一文。這裡每個呼叫的方法參考,加上能對你自己的表單實測的試用版,都在HotPDF Delphi PDF component 頁面