技術文章

HotPDF 在 Delphi 中的 ZUGFeRD 與 Factur-X 電子發票

HotPDF 能從 Delphi 與 C++Builder 產生、偵測並驗證 ZUGFeRD 與 Factur-X 電子發票。AddFacturXAssociatedFile 把 EN 16931 發票 XML 內嵌進 PDF/A-3 容器,DetectFacturXInvoice 辨識收進來的混合式發票,而 HPDFValidateEInvoice 則對內嵌的 XML 執行官方的 EN 16931 Schematron 商業規則。本文走過這三個步驟,並誠實說明驗證引擎到哪裡就停住了

推動這組功能的壓力來自法規,不是技術。德國與法國正逐步強制境內 B2B 交易採用結構化電子發票,而兩邊的規定都收斂到同一種混合模型:您的 ERP 本來就在產生的那份 PDF 發票,必須在自己肚子裡帶著一份機器可讀的 XML 雙胞胎。今天還在輸出純 PDF 發票的 Delphi 會計或 ERP 應用程式,有一個硬期限得開始輸出 Factur-X 或 ZUGFeRD,而這兩個名字之間的差別比行銷說法小得多——Factur-X 與 ZUGFeRD 是同一套法德標準,只是掛了兩個名號發表

怎樣的 PDF 才算是有效的 Factur-X 發票?

有效的 Factur-X 發票是一份 PDF/A-3 檔案(ISO 19005-3),它內嵌恰好一份結構化發票 XML 作為關聯檔案,從文件目錄連向它,並在 XMP 擴充中繼資料中宣告它。三個層次必須彼此一致。第一,容器必須符合 PDF/A-3,這個封存設定檔和 PDF/A-1 與 PDF/A-2 不同,允許內嵌任何型別的檔案。第二,發票 XML(標準設定檔命名為 factur-x.xml,德國 XRechnung 變體則命名為 xrechnung.xml)必須登錄在目錄的 /AF 陣列與 /Names /EmbeddedFiles 樹中,並帶著一個說明這份附件是什麼的 AFRelationship 條目:Alternative 表示這份 XML 是可見發票的等價表述,而這正是多數設定檔要求的語意。第三,Factur-X 的 XMP 擴充結構描述必須指名內嵌檔案、它的符合層級與文件型別,讓接收系統完全不必剖析 XML 就能把發票分類

HotPDF 說明有效 Factur-X 發票的三個層次:PDF/A-3 容器、內嵌的 factur-x.xml 關聯檔案,以及 XMP 擴充中繼資料
容器、附件登錄與 XMP 中繼資料三者一致,一份 PDF 才算得上 Factur-X 發票

XML 酬載本身遵循 EN 16931 語意模型,也就是定義核心發票的歐洲規範:買方、賣方、明細項目、稅額拆解、應付金額,每一項都帶著一個商業術語編號(發票號碼是 BT-1,幣別是 BT-5,依此類推)。因此 EN 16931 驗證發生在兩個截然不同的層次。XML 結構描述驗證檢查文件是否為良構的 CII,元素名稱是否正確。Schematron 商業規則則檢查內容是否自洽——BR-CO-15 是否成立、發票總額是否真的等於明細淨額加上加值稅、宣告的設定檔所要求的必填術語是否到齊。一份檔案可以通過結構描述驗證,卻仍是一張無效的發票,這正是商業規則層存在的理由

為什麼 ZUGFeRD 2.5 不需要新的容器

ZUGFeRD 2.5 在 PDF 容器層級什麼都沒改:準則 URN、XMP 結構描述與版本語彙基元,都和 ZUGFeRD 2.3 / Factur-X 1.0 完全相同。這件事讓多數實作者意外,所以值得引一下原文。Factur-X 1.09 規範寫明,PDF/A 擴充結構描述之 URI 中的版本號,與 XML 資料規範的版本號無關,而 Factur-X 發票執行個體的版本控管維持在 1.0——XMP 中的 fx:Version 與 factur-x.xml 的 BT-24 皆然。官方的 ZUGFeRD 2.5 樣本語料庫也印證了這點:每一份樣本都在 urn:factur-x.eu:1p0 準則 URN 底下內嵌 factur-x.xml,fx:Version 為 1.0。2.5 真正新增的東西住在 XML 資料模型裡——像是子發票明細與批次識別碼這類新元素——那是您的應用程式產生的商業層,不是 PDF 程式庫管理的容器層

HotPDF 直接把這項發現編進程式。AddFacturXAssociatedFile 接受一個 Version 引數,並在寫出 XMP 之前先透過 HPDFFacturXNormalizeVersion 正規化它,所以就算呼叫端傳入 2p5 這個別名,輸出的仍是符合規範的語彙基元,而不是憑空造出一個沒有驗證器會接受的 2p5 XMP 版本。偵測輔助函式在讀取時把 2p5 視為等價,讓往返保持對稱。如果某個程式庫或工具告訴您它會寫出特殊的 ZUGFeRD 2.5 容器,規範本身說的是:沒有這種東西

從 Delphi 內嵌發票 XML

THotPDF.AddFacturXAssociatedFile 只用一次呼叫,就把一趟普通的 PDF/A-3 產生流程變成 Factur-X 發票:它把 XML 位元組內嵌成關聯檔案,在目錄的 /AF 陣列與 /Names /EmbeddedFiles 樹中登錄 factur-x.xml,並輸出對應的 Factur-X XMP 擴充中繼資料。人眼可讀的發票頁面,您照常用一般的 HotPDF 畫布 API 繪製;容器這部分的工作,只是 BeginDoc 與 EndDoc 之間的一行程式

var
  Pdf: THotPDF;
  InvoiceXML, UBLXML: TBytes;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice-2026-0042.pdf';
    Pdf.PDFACompliance := '3B';            // PDF/A-3b 容器
    Pdf.BeginDoc;
    // 把 EN 16931 CII 發票 XML 內嵌為 factur-x.xml
    InvoiceXML := BuildInvoiceXML;         // 由您的商業層產生
    Pdf.AddFacturXAssociatedFile(InvoiceXML, 'EN 16931');
    // 選用:附上一份 UBL 檢視作為補充表述
    UBLXML := BuildUBLXML;
    Pdf.AddUBLSupplementaryFile(UBLXML);   // factur-xubl.xml,Alternative
    // ... 在這裡繪製可見的發票頁面 ...
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

各項預設值都依循規範:檔名 factur-x.xml、關係 Alternative、版本 1.0。AddUBLSupplementaryFile 實作的是 Factur-X 1.09 規範第 6.4 節,該節允許同一張發票的 UBL 表述以第二份附件的形式隨行,檔名為 factur-xubl.xml,MIME 型別為 application/xml,關係為 Alternative。這裡的順序有意義,而 HotPDF 落實了規範所隱含的限制:UBL 檔案是補充檢視,永遠不是主要發票,所以它必須在 AddFacturXAssociatedFile 登錄好 CII 主檔之後才加入。請注意,繪製出來的頁面與內嵌的 XML 必須陳述同一張發票——一份顯示某個總額、卻編碼另一個總額的 PDF,正是混合式發票要防堵的失效模式。這套流程中 PDF/A-3 的那一面,包含 OutputIntent 與字型內嵌,在談 Delphi 中 PDF/A、PDF/X 與 PDF/UA 驗證的姊妹篇裡有更深入的說明

怎麼偵測收進來的 PDF 是不是 Factur-X 發票?

THotPDF.DetectFacturXInvoice 回應的是這項規定的接收端:載入任何一份 PDF,一次呼叫就知道它是不是混合式發票、宣告了哪個設定檔、帶了哪些附件。這個函式回傳一筆 THPDFFacturXInvoiceInfo 記錄,內含內嵌 XML 的檔名、XMP 宣告的文件檔名與型別、版本語彙基元、符合層級、準則 URN,以及 AFRelationship 的值。另外兩個欄位回報補充檢視:出現 factur-xubl.xml 附件時會填入 UBLFileName,有 EDIFACT 檢視隨行時則填入 EDIFACTFileName——兩者都不影響偵測結論,因為補充表述不可能在沒有主要 CII 發票的情況下存在

Delphi 中 Factur-X 發票偵測的示意圖,DetectFacturXInvoice 填好一筆 THPDFFacturXInvoiceInfo 記錄並回傳結論
偵測讀的訊號和相容驗證器讀的一樣,然後回報設定檔與每一份宣告過的附件
var
  Reader: THotPDF;
  Info: THPDFFacturXInvoiceInfo;
begin
  Reader := THotPDF.Create(nil);
  try
    if Reader.LoadFromFile('incoming-invoice.pdf') <= 0 then
      raise Exception.Create('No pages loaded.');
    if Reader.DetectFacturXInvoice(Info) then
    begin
      Writeln('Invoice XML:    ', string(Info.FileName));
      Writeln('Conformance:    ', string(Info.ConformanceLevel));
      Writeln('Guideline URN:  ', string(Info.GuidelineID));
      Writeln('Relationship:   ', string(Info.AFRelationship));
      if Info.UBLFileName <> '' then
        Writeln('UBL view:       ', string(Info.UBLFileName));
    end
    else
      Writeln('Not a Factur-X / ZUGFeRD hybrid invoice.');
  finally
    Reader.Free;
  end;
end;

偵測讀的是相容驗證器會讀的同一批訊號——XMP 擴充結構描述與關聯檔案登錄——而不是單靠檔名猜測。同一個已載入的文件物件也把 XMP 與 Info 字典中繼資料開放出來供檢視或修正,這套流程記載於談如何編輯已載入 PDF 文件中繼資料的那篇文章

商業規則還是結構描述:EN 16931 驗證到底在驗什麼?

HPDFValidateEInvoice 是 HPDFEInvoiceValidator 單元中的獨立函式,它依序跑完兩個驗證層:先透過 ValidateFacturXInvoice 做容器結構檢查,再對擷取出的發票 XML 執行 EN 16931 Schematron 商業規則。HotPDF 在 Lib/resources/Schematron/ 底下出貨五份官方的 Factur-X 1.09 規則檔——MINIMUM、BASIC WL、BASIC、EN 16931 與 EXTENDED——而 HPDFSchematronFileForProfile 會挑出符合偵測到之符合層級的那一份,所以一張 EN 16931 發票會自動拿 Factur-X_1.09_EN16931.sch 來檢查。唯有容器有效且沒有任何商業規則被觸發時,函式才回傳 True

HotPDF 的 EN 16931 電子發票驗證示意圖:先跑容器結構檢查,再跑 Schematron 商業規則,並回報被略過的 XPath 2.0 規則
容器檢查先跑,Schematron 規則後跑,報告則明確說出這次到底演練了規則集的多少比例
uses HPDFEInvoiceValidator;

var
  Reader: THotPDF;
  Report: THPDFEInvoiceValidationReport;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.LoadFromFile('incoming-invoice.pdf');
    if HPDFValidateEInvoice(Reader, SchematronDir, Report) then
      Writeln('Container and business rules: OK')
    else
      Writeln('Validation found issues.');
    if Report.BusinessRulesEvaluated then
    begin
      Writeln(Report.BusinessSummary.Evaluated, ' rules evaluated, ',
        Report.BusinessSummary.Skipped, ' skipped (XPath 2.0)');
      for I := 0 to High(Report.BusinessRules) do
        if (not Report.BusinessRules[I].Skipped) and
           (Report.BusinessRules[I].Severity = stsError) then
          Writeln(string(Report.BusinessRules[I].RuleID), ': ',
            string(Report.BusinessRules[I].Message));
    end;
  finally
    Reader.Free;
  end;
end;

BusinessRules 中的每一筆條目都帶著從斷言文字剖析出的規則識別碼——BR-45、BR-CO-17 以及 EN 16931 命名體系中的其餘成員——外加規則的內容脈絡 XPath、測試運算式,以及一個對映 Schematron flag 屬性的嚴重性。這讓報告可以據以行動:您拿到的不是光禿禿的通過或失敗,而是究竟哪個商業術語不一致,那正是客戶問「為什麼這張發票被退回」時,您的支援團隊需要的東西。已經在為封存 PDF 產生結構化符合性報告的團隊,可以把這些結果併進談 PDF 印前檢查報告自動化的那篇文章所描述的同一條流水線

誠實地說,驗證器到哪裡就停住了

Schematron 引擎建構在 MSXML 之上,而它評估的是 XPath 1.0。EN 16931 規則檔宣告 queryBinding="xslt2",帶著 424 條斷言,其中大多數只用到 string-length 與 substring-after 這類 XPath 1.0 函式——那些原生就跑得動。少數則仰賴 xs:decimal 轉型或 exists() 這類 XPath 2.0 構件;HotPDF 會先掃描每條測試運算式,找出已知的 XPath 2.0 語彙基元,把那些規則標成 Skipped 並給予資訊層級的嚴重性,而不是硬去做一次會悄悄失準的評估。摘要記錄把評估過與略過的數量分開回報,所以您的記錄檔永遠明確寫著這次到底演練了規則集的多少比例。在 MSXML 繫結不可用的 Delphi 7 上,這個單元會編譯出一個殘根,回報引擎不可用,同時不干擾容器的判定結果;完整引擎需要 Delphi XE2 或更新版本

第二條界線更要緊。HPDFValidateEInvoice 給出綠燈,只代表容器結構相容、而且沒有任何被評估過的商業規則被觸發——它不是稅務合規的背書。各國的擴充規則、接收方特有的要求,以及圍繞發票內容的法律義務,都坐在 EN 16931 核心之上,也落在任何程式庫之外。請把驗證器當成一道閘門:它擋住結構壞掉的發票不讓它離開您的寄件匣,也在收件匣裡標出不一致的那些;當認證有此要求時,請在發行流水線中另跑一套完整的 XPath 2.0 驗證器;至於合規問題,就交給您的稅務顧問承擔。本文示範的內嵌、偵測與驗證 API,都是適用於 Delphi 與 C++Builder 的標準版 HotPDF Delphi 元件的一部分,並附有涵蓋全部七種 ZUGFeRD 與 Factur-X 設定檔的完整電子發票範例