Technical Article

在 Delphi 中將 XFA 富文字超連結扁平化為 PDF 連結

XFA(XML 表單架構)已遭棄用。ISO 32000-1 在 §12.7 中收錄了它,並附帶說明指出其已從 PDF 2.0 中移除,且現代檢視器正在逐一放棄其 XFA 引擎。這些都沒有清空封存庫。政府收件表單、保險申請書和銀行對帳單在過去近二十年的大部分時間裡都是以 XFA 撰寫的,且這些檔案如今仍在送達收件匣和檔案管線中。當過去用來演算它們的檢視器停止這樣做時,表單就會變成一個帶有「請在不同的閱讀器中開啟」預留位置的空白頁。持久的修正方法是將 XFA 扁平化為任何閱讀器都可以繪製的靜態 PDF 內容。

扁平化的困難部分不是欄位。文字方塊和核取方塊可以相當乾淨地對應到 AcroForm 小工具。困難的部分是 XFA 儲存在繪製元素內部 <exData contentType="text/html"> 區塊中的富文字。該區塊是帶有內嵌樣式且通常帶有錨點的 HTML 子集。將其呈現到頁面上意味著要重現樣式化文字和動態超連結,而超連結是大多數實作悄悄放棄的地方。

XFA 富文字實際上是什麼樣子

exData 主體是 XHTML 的一小部分。段落是 <p>;樣式化的字元範圍是 <span>(具有其專屬的內嵌 CSS,用於指定粗細、姿勢、顏色和大小);而超連結是包裝其可見文字的 <a href="...">。單行可以連續包含多個範圍(每個範圍具有不同的樣式),且其中一個可以是錨點。樣式並非可以丟棄的裝飾。因法律警告而以粗體紅色演算的條款在扁平化後必須保持粗體和紅色,否則扁平化後的檔案會曲解原始檔案。

So the flatten engine cannot treat the block as one string. It has to walk the inline structure, resolve each run's effective style by layering the span's inline CSS over the draw element's base font, and lay the runs out one after another across the line. HotPDF models each of these laid-out fragments as an internal TXFARichRun record. The record carries the run's text, its resolved style, its measured box, and, for an anchor, the Href it points at.

從左到右佈局執行單元

定位是富文字不再是剖析問題而變成排版問題的地方。這些執行單元共用一行,因此每個執行單元都從前一個執行單元結束的位置開始。沒有記錄這些位置的標記;它們必須進行測量。引擎內部的 LayoutRichText 常式使用稍後會繪製它的相同字型計量來測量每個執行單元,然後將執行單元的水平偏移量設定為所有先前執行單元寬度的累積總和。執行單元一從繪製方框原點開始,執行單元二從執行單元一的寬度開始,執行單元三從前兩個的組合寬度開始,依此類推跨越該行。

這與傳播相容。The layout pass measures advances; a separate render pass draws glyphs. If those two passes disagree about the font, the boxes the layout computed will not sit under the glyphs the renderer paints. HotPDF keeps them in step by mapping each run's resolved style onto a font specification, through the internal RunStyleToFontSpec helper, that matches the renderer's own defaults of Arial at 10 points. The measured advance and the drawn text then agree, and a run's computed box genuinely covers the characters a reader sees.

// Conceptual shape of one laid-out run. The engine builds an array of these
// internally; you never construct them yourself, but the fields explain how a
// link's hit box is derived from measured geometry rather than from text.
type
  TRichRunInfo = record
    Dx, Dy : Double;       // top-left, relative to the draw-box origin
    W, H   : Double;       // measured run box (width from the layout pass)
    Text   : AnsiString;   // the run's visible characters
    Href   : AnsiString;   // URI target for an <a> run, '' otherwise
  end;

從錨點執行單元到 PDF 連結註解

完成的 PDF 中的超連結不是頁面內容的一部分。它是一個獨立的物件,即 Link 註解(描述於 ISO 32000-1 §12.5.6.5)。該註解具有一個 /Rect(定義頁面上可按一下的矩形)以及一個在按一下該矩形時觸發的動作。對於外部連結,該動作是 URI 動作:/S /URI,其目標地址作為其 /URI 字串。下方可見的文字是普通的頁面內容;註解是覆蓋在它上面的隱形熱區。

扁平化路徑完全遵循此模型。當執行單元攜帶 Href 時,HotPDF 首先繪製樣式化文字,然後在執行單元的方框上建立 Link 註解。該註解的公用進入點是頁面方法 AddURILink,它建立帶有 /URI 動作的 /Type /Annot /Subtype /Link 物件並傳回註解字典。其矩形是執行單元的測量方框,從繪製元素的局部座標轉換為頁面座標。結果是一個精確落在錨點文字上而不在其他任何地方的連結。

// The same public API the flatten path uses for each anchor run. It produces
// an ISO 32000-1 12.5.6.5 Link annotation: /Subtype /Link with a /URI action
// over the given rectangle. The optional description fills /Contents so a
// screen reader can announce the target.
var
  LinkRect: TRect;
  Annot: THPDFDictionaryObject;
begin
  LinkRect := Rect(72, 690, 268, 706);  // page-space hit box for the run
  Annot := Pdf.CurrentPage.AddURILink(LinkRect,
    'https://www.example.gov/appeal', 'File an appeal online');
end;

為什麼點擊區域必須來自測量的寬度

人們很容易想像透過在頁面中搜尋其可見文字並在找到的任何內容周圍繪製矩形來定位連結。這行不通,原因在於扁平化文字的儲存方式是根本性的。樣式化執行單元是用嵌入的子集字型繪製的。子集字型會對其保留的字形重新編號,因此頁面內容資料流包含十六進位 CID 碼,而不是原始字元碼。頁面上的位元組不是人類閱讀的字母,且它們不能作為文字進行搜尋。搜尋錨點的標題找不到任何內容,因為該標題在資料流的任何地方都不作為字面文字存在。

矩形唯一可靠的錨點是配置階段已經產生的幾何圖形。每個執行單元的偏移量和測量寬度是在流動行時(在對任何字形重新編號之前)計算的,它們描述了文字將在物理上出現的位置。因此,HotPDF 直接從執行單元放置的方框中獲取連結矩形,而不是從任何文字查閱中獲取。因為測量使用了轉譯字型,所以無論子集化如何,方框都是正確的。幾何圖形在編碼中倖存下來;文字則不然。這就是測量寬度定位的全部論據,也是為什麼試圖透過文字搜尋來加回連結的扁平化器會產生漂移或消失的點擊區域的原因。

從您的程式碼驅動扁平化

對於已包含 XFA 封包的 PDF,進入點是 FlattenLoadedXFA。載入檔案、呼叫該方法並儲存結果。Editable 參數決定表單欄位會發生什麼:傳遞 True 以將它們保留為可填寫的 AcroForm 小工具,或傳遞 False 將每個小工具標記為唯讀,以便輸出是凍結的記錄。富文字繪製區塊(及其樣式化執行單元和連結註解)都會產生。該函式傳回它發送的小工具數量。

var
  Pdf: THotPDF;
  Emitted, i: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('xfa_appeal_form.pdf');
    // True keeps fields fillable; False freezes them read-only.
    Emitted := Pdf.FlattenLoadedXFA(True);

    // Anything the engine could not map is reported, not raised.
    for i := 0 to Pdf.XFAFlattenWarnings.Count - 1 do
      Writeln('XFA warning: ', Pdf.XFAFlattenWarnings[i]);

    Pdf.SaveLoadedDocument('appeal_form_flat.pdf');
    Writeln('Widgets emitted: ', Emitted);
  finally
    Pdf.Free;
  end;
end;

在呼叫後務必讀取 XFAFlattenWarnings。該清單在每次扁平化開始時都會被清除,並為引擎拒絕演算的每個元素累加一行:不受支援的欄位種類、無法解碼的繪製影像、沒有可用範圍的 exData 區塊。這些都不會引發異常,因此空的警告清單是您證明一切都已對應的證據,而非空的警告清單則精確告訴您要檢查哪些原始檔案。當您將原始 XFA 保留為 XDP 位元組而不是載入的 PDF 時,同級方法 ApplyXFAAsAcroForm 會直接接受這些位元組,並共用相同的程式碼路徑和相同的警告行為。互補的 AddXFAPacket 方法則相反,它將 XFA 封包嵌入到您正在建立的檔案中。

在閱讀器中確認結果

在 Acrobat 或任何目前的檢視器中開啟扁平化後的檔案,並檢查兩件事。首先,富文字的轉譯樣式完好無損:粗體執行單元是粗體、彩色執行單元帶有其顏色,且範圍在行中按正確的順序排列,而不是重疊或超出方框。其次,超連結是動態的。將滑鼠懸停在錨點上,狀態列應顯示目標地址;按一下它,URI 動作應開啟它。使用檢視器的註解檢查器來確認每個都是真正的 /Link 註解,其 /Rect 緊貼錨點文字,位於現在是普通繪製字形而非表單演算 XFA 的內容之上。這種組合(樣式化靜態文字加上右側矩形上的真實 Link 註解)使得扁平化後的檔案比它不再需要的 XFA 引擎更長壽。

扁平化欄位本身(圍繞此富文字的文字方塊、核取方塊和選擇清單)在我們關於將 XFA 表單扁平化為 AcroForm 小工具的逐步解說中介紹。對於手動建立和放置 Link 註解(超出扁平化路徑產生的註解)的更廣泛故事,請參閱在 HotPDF 中使用 PDF 註解。兩者都建立在與適用於 Delphi 和 C++Builder 的 HotPDF 元件 一起出貨的相同註解和表單模型之上。