技術文章

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

HotPDF 讓多選清單方塊的值以陣列型態走完 FDF 與 XFDF 的來回轉換,從頭到尾不變。從 2.755.0 起,ExportLoadedFormToFDF、ExportLoadedInterchangeToFDF 與 ExportLoadedFormToXFDF 把每個選中選項寫成自己的 FDF 字串或 XFDF <value> 元素,對應的匯入方法先檢查每個值是否落在欄位選項裡、重建 /I 選擇索引,然後才動任何東西。過程中沒有任何東西被黏成一條字串

這個修掉的失敗很容易重現。拿一張帶產品選項多選清單方塊的訂購單,讓使用者勾兩項,把表單資料匯出給後台系統,再把編輯過的檔案匯回 PDF。改動之前,清單方塊回來時是空的或錯的。原因是有個匯出值裡帶了換行符,而舊路徑把所有選擇壓平成一條以換行分隔的字串。想從那條字串裡可靠地還原多個選擇,本來就沒戲,匯出值自己就含換行符時,更是完全不可能

用換行符串接多選值,為什麼會毀掉來回轉換?

把選擇串接成一條字串,等於扔掉值與值之間的邊界,而值本身可以包含分隔符,所以任何匯入器都沒辦法正確切回去。ISO 32000-1 §12.7.4.4 允許 choice 欄位的 /V 條目是單一文字字串或文字字串陣列,帶 MultiSelect 旗標(/Ff 的 bit 22)的清單方塊,選超過一項時就用陣列形式。同一節把 /I 定義為遞增排列的 0 基選項索引陣列,檢視器靠它區分兩個碰巧共用匯出值的選項。HotPDF 的純量 getter GetFormFieldValue 只讀字串形式,把陣列灌進去,匯出就退化成空字串;舊的 XFDF 匯入則用 LF 把重複的 <value> 元素串起來。想像一個匯出值是 Deep、換行、Blue:串接之後,Deep\nBlue\nRed 可以是兩個選擇,也可以是三個,檔案裡沒有任何東西能告訴您是哪個。修法就是徹底不讓純量出現在來回轉換的中間

HotPDF 舊的多選來回轉換:兩個被勾選的清單方塊選項、其中一個內嵌換行符,經純量的 GetFormFieldValue 路徑壓平成單一字串 Deep、換行、Blue、換行、Red,下游讀取器可以解析成兩個選擇,也可以解析成三個
把多選值串成一條字串摧毀了值的邊界,匯出值自己含換行符時,壓平的形式更是曖昧不明

匯出的 FDF 與 XFDF 檔案裡裝了什麼?

HotPDF 把多選值寫成 FDF 裡的型別化陣列、XFDF 裡每個選擇一個 <value> 元素,邊界在磁碟上看得見。FDF 裡每個項目保持它在來源 PDF 裡的拼寫:hex 字串以 hex 出去,literal 字串由單一輔助函式跳脫,CR 與 LF 變成 \r 與 \n。XFDF 的根帶 ISO 19444-1 要求的 xml:space="preserve",意思是文字元素裡的任何空白都算資料。所以 HotPDF 把每個 <value> 的開始標籤、跳脫後的文字與結束標籤一口氣寫出,縮排留在元素外面,CR、LF 與 TAB 編碼成字元參照,這樣做行尾正規化的 XML 解析器動不了原始位元組

HotPDF 自 2.755.0 起為多選清單方塊寫出的匯出形狀: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 內容建在記憶體裡(2.755.1 修正),所以無法匯出的值,例如陣列裡裝了字串以外的東西,會丟例外而不截斷既有檔案。第二,清單方塊同時提供空字串匯出值時,空選擇在 XFDF 裡是曖昧的,因為 <value/> 可以指什麼都沒選、也可以指選了空選項。ExportLoadedFormToXFDF 在這種情況下丟例外而不猜,而且丟在目標檔被打開之前。FDF 沒有這種曖昧,/V [] 與 /V [()] 是兩回事。兩個 FDF 匯出器也跳過沒有 /T 名稱的純介面元件終端,與 XFDF 匯出器一致,因為沒有任何匯入器能把那些條目對回欄位

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;

匯入時,HotPDF 怎麼驗證多選值?

HotPDF 只有在目標是設了 MultiSelect 旗標的 choice 欄位、且陣列裡每個值都對得上欄位 /Opt 陣列裡某個匯出值時,才接受匯入的陣列。每個選項槽只能用一次,所以一個有兩個選項共用匯出值 b 的清單,接受 [<62> <62>] 當兩個不同的選擇,拒絕第三個 b。重建的 /I 跟 /Opt 順序走、不跟傳入值的順序,因為 §12.7.4.4 要求索引遞增。HotPDF 把新的 /V 與 /I 建成游離物件,全部值過了驗證才指派,所以被拒的值不會留下半個陣列或過期索引。副本寫進正在匯入的欄位、而不是共用的上層陣列,從 FDF 來的 hex 拼寫一路保持 hex 到存檔,計算依賴這個清單方塊的欄位被標記待重算。如果只需要設單一值,在已載入 PDF 中設定單一表單欄位值走純量路徑,那條路徑設計上就不處理多重選擇

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

有些其他工具把純 ASCII 的匯出值寫成不帶位元組順序標記的 hex 字串,例如 <416272>,然後把那些 hex 數位當文字寫出去匯出 XFDF。回程上嚴格的 literal 比對就失敗,匯入中止。2.755.1 加了一次重試:值比不上任何選項時,HPDFHexSpellingText 把那段文字當 hex 負載解碼、再比一次。重試只作用在本來會丟例外的輸入,已經比得上的值永遠不會被改動。同一版也讓純量與陣列路徑用同一個 Unicode 解碼器,認得 PDFDocEncoding、帶任一種位元組順序標記的 UTF-16 與 UTF-8。在此之前,混用編碼的文件裡,同一個邏輯值可能在這條路徑比得上、在那條路徑失敗

合法的 FDF 檔案為什麼還是可能在剖析時丟欄位?

不追蹤 hex 字串的 FDF 掃描器,會在一個 hex 值緊貼著字典終結符結束時,把欄位字典攔腰切斷。<< /T (region) /V <416273>>> 裡,第一個 > 關掉 hex 字串,天真的掃描器卻把它跟下一個 > 一起讀成字典結尾,悄悄丟掉整個欄位。檔案層級的 FDF 匯入器本來就追蹤自己是否在 hex 字串裡,2.755.1 讓 ImportLoadedInterchangeFromFDF 背後的陣列與字典掃描器也照做。第二個問題涉及間接參照。FDF 檔是一份帶自己物件編號的小型 PDF 語法文件(ISO 32000-1 §12.7.7),所以 /V [11 0 R] 指的是 FDF 檔的物件 11,不是您正在填的 PDF 的物件 11。HotPDF 的簡化 FDF 解析器不解析檔內參照,所以它拒收這種陣列,而不是去讀目標文件裡碰巧叫物件 11 的那個東西

檔案、串流與 XFDF 匯入的錯誤回報方式不同

三條匯入路徑的驗證相同,但回報失敗的方式不同,值得刻意挑一條。ImportLoadedFormFromFDF 跳過任何驗證失敗的欄位、回傳實際套用的欄位數,所以數字比預期少是唯一的問題跡象。ImportLoadedInterchangeFromFDF 與 ImportLoadedFormFromXFDF 在第一個被拒欄位就丟例外。每個欄位各自提交,所以例外之前處理過的欄位保有新值。別把其中任何一個當成對整個交換檔的交易:需要全有或全無,就在例外發生時丟棄已載入的文件,別存它

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
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
    except
      on E: Exception do
        ShowMessage('Import rejected, nothing saved: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

擴充 XFDF 回呼,而不弄壞既有呼叫端

底層 XFDF 單元裡的陣列支援住在一個獨立記錄 THPDFXFDFArrayAccess 裡,以及 HPDFXFDFExportFields 與 HPDFXFDFImportFields 的新重載裡,而不是往既有 THPDFXFDFAccess 記錄的尾部加欄位。理由是二進位相容。把 THPDFXFDFAccess 當區域變數填的程式碼,常常只設自己知道的槽、其餘不清,新函式指標加進那個記錄就會裝著堆疊殘渣,函式庫還當它是真回呼。用獨立記錄,舊呼叫端保持舊版面與舊重載,那些重載內部傳一個全 nil 的陣列記錄。原始的純量匯入重載為相容性照舊用 LF 串接重複值,只有認得陣列的重載把它們分開。綁自己的資料儲存時,從 Default(THPDFXFDFArrayAccess) 起步。任何清單型欄位都讓 GetFormFieldValueArray 回傳 True,包括沒選任何東西的;回傳 False 則退回純量回呼

uses HPDFXFDF;

// 普通函式指標,不是 "of object":Context 帶您自己的儲存
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 註解匯入與匯出。完整 API 參考與試用版下載在 HotPDF Delphi PDF component 頁面