技術文章

Delphi 中 Factur-X XMP 的 PDF/A-3 延伸結構描述

您已建立好 Factur-X 發票,所有容器檢查都通過了。目錄帶有 /AF 陣列、EmbeddedFiles 名稱樹解析至正確的檔案規格、嵌入的 factur-x.xml 具有正確的 /AFRelationshipAlternative,而內建的 ValidateFacturXInvoice 也回傳 1。然後您將同一個檔案送入稅務入口所使用的參考檢查器 veraPDF,它卻判定整份文件不是有效的 PDF/A-3。結構是正確的,問題出在中繼資料上,而這個失敗是整個電子發票工作流程中最容易被忽略的失敗之一

這個原因值得完整理解,因為它解釋了一整類 PDF/A 缺陷 - - 這類缺陷與可見頁面或附件毫無關係,卻與 XMP 如何描述自身有一切關係。這就是隱藏在容器檢查通過背後的陷阱

讓檔案失敗的四個屬性

Factur-X 發票將四個自訂屬性寫入其 XMP 封包,以便下游軟體無需解析嵌入的 XML 即可讀取發票設定檔。它們位於 Factur-X 命名空間中,使用 fx 前綴:fx:DocumentFileNamefx:DocumentTypefx:Versionfx:ConformanceLevel。這四個屬性正是讀取器需要用來識別這份 PDF 帶有 1.0 版本、名為 factur-x.xml 的 EN 16931 發票的全部中繼資料

這四個屬性中沒有任何一個屬於 PDF/A 預先定義的 XMP 結構描述。都柏林核心、XMP Basic、PDF 以及 PDF/A 識別結構描述對符合規範的讀取器是已知的,但 fx: 並非如此。當 veraPDF 遍歷 XMP 並遇到它無法識別命名空間的屬性時,它會尋找能告知它該屬性含義的宣告。若該宣告不存在,它會根據 ISO 19005-3 第 6.6.2.3.1 條回報失敗 - - 該條要求每個非來自預定義結構描述的屬性都必須在 PDF/A 延伸結構描述中加以描述。四個未宣告的屬性,四種被拒絕的方式,而其中沒有任何一種在容器檢查中是可見的

為何 PDF/A 拒絕裸露的自訂屬性

這條規則看起來迂腐,直到您想起 PDF/A 的用途。這個格式之所以存在,是為了讓檔案能在數十年後、由從未被告知 2026 年慣例的軟體開啟並理解。符合規範的讀取器應當能夠單憑文件本身理解文件,而無需查詢任何外部登錄檔

自訂中繼資料打破了這個承諾,除非檔案本身攜帶其描述。面對一個裸露的 fx:ConformanceLevel 屬性,未來的讀取器無從得知 fx 前綴所綁定的命名空間 URI、該值是文字、日期還是整數,或者該屬性描述的是文件本身還是某個外部資源。PDF/A 延伸結構描述機制填補了這個空缺:它允許檔案在固定的 XMP 結構中宣告命名空間、前綴,以及每個屬性的值類型和 internalexternal 類別。一旦該宣告存在,屬性就能自我描述,第 6.6.2.3.1 條便得到滿足。沒有它,驗證器別無選擇,只能將屬性視為無法理解而讓檔案失敗。這裡的類別區分很重要:像這些發票屬性描述的是來自 PDF 處理器外部的資料,因此宣告為 external 而非 internal

延伸結構描述宣告的內容

該宣告是 XMP 封包中的一個 rdf:Description,使用三個 AIIM 定義的命名空間:pdfaExtensionpdfaSchemapdfaProperty。在 pdfaExtension:schemas bag 中有一個結構描述項目,用以命名 Factur-X 結構描述,提供其 pdfaSchema:namespaceURIpdfaSchema:prefix,然後在 pdfaSchema:property 序列中列出四個屬性。每個屬性帶有名稱、值為 TextpdfaProperty:valueType,以及值為 externalpdfaProperty:category。下方的示範標記展示了該區塊的結構

<rdf:Description rdf:about=""
    xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
    xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
    xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
  <pdfaExtension:schemas>
    <rdf:Bag>
      <rdf:li rdf:parseType="Resource">
        <pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
        <pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
        <pdfaSchema:prefix>fx</pdfaSchema:prefix>
        <pdfaSchema:property>
          <rdf:Seq>
            <rdf:li rdf:parseType="Resource">
              <pdfaProperty:name>DocumentFileName</pdfaProperty:name>
              <pdfaProperty:valueType>Text</pdfaProperty:valueType>
              <pdfaProperty:category>external</pdfaProperty:category>
              <pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
            </rdf:li>
            <!-- DocumentType, Version, ConformanceLevel declared the same way -->
          </rdf:Seq>
        </pdfaSchema:property>
      </rdf:li>
    </rdf:Bag>
  </pdfaExtension:schemas>
</rdf:Description>

命名空間 URI 和前綴並非固定字串,它們隨設定檔而定。Factur-X 文件使用 urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# 加上 fx 前綴,而透過 zugferd-invoice.xml 選定的 ZUGFeRD 2.0 檔案則在其自身的結構描述名稱下解析為不同的 URI。延伸結構描述必須宣告與屬性區塊實際使用的相同命名空間 URI,否則驗證器仍然無法將兩者連結起來。PDFlibPas 從您傳入的檔案名稱和版本中導出這兩個值,因此宣告與屬性區塊始終保持一致

輔助方法如何同時寫入兩個部分

在 PDFlibPas 中,您不必手動組裝這段 XML。您只需將文件置於 PDF/A-3 模式並呼叫一個方法。首先要確定的是符合旗標,因為 Factur-X 需要 PDF/A-3。呼叫 SetPDFAMode(7) 可選取 PDF/A-3u 層級,這會在識別結構描述中將 pdfaid:part 設為 3,並將 pdfaid:conformance 設為 U。在加入任何發票中繼資料之前,XMP 封包現在已帶有正確的部分和符合性

var
  FileID: Integer;
begin
  PDF.SetPDFAMode(7);            // PDF/A-3u: pdfaid:part=3, conformance=U
  PDF.NewDocument;
  // draw the human-readable invoice page here

  FileID := PDF.AddFacturXAssociatedFileFromString(
    InvoiceXML,                  // raw UTF-8 XML bytes
    'EN16931',                   // ConformanceLevel
    'factur-x.xml',              // embedded file name
    'Factur-X invoice XML',      // /Desc text
    'Alternative',               // /AFRelationship
    '1.0',                       // profile version
    '');                         // optional country code
  if FileID = 0 then
    Exit;                        // not PDF/A-3, or XML/profile mismatch

  PDF.SaveToFile('factur-x.pdf');
end;

一次呼叫 AddFacturXAssociatedFileFromString 就能完成失敗檔案所缺少的工作。它以您所命名的關係類型將 XML 嵌入為 PDF/A-3 關聯檔案,並記錄四個 fx 屬性,以及所選設定檔的結構描述名稱、命名空間 URI 和前綴。當文件儲存時,一個名為 ApplyFacturXMetadata 的內部步驟會將屬性區塊和相符的 pdfaExtension:schemas 宣告注入 XMP 封包,因此自訂屬性到達時已附帶描述。若文件不在 PDF/A-3 模式下,或 XML 與宣告的設定檔不符,此方法會回傳 0,這也是阻止格式不正確的發票進入檔案的相同防護措施

容器檢查看不到的盲點

這部分需要直白說明,因為這正是問題隱藏的原因。ValidateFacturXInvoice 檢查容器:它確認目錄有 /AF 條目、EmbeddedFiles 名稱樹存在、發票 XML 存在、嵌入的檔案名稱與設定檔相符、XML 中的指導方針 ID 與符合層級一致,以及 /AFRelationship 是 PDF/A-3 所允許的值。這些都是真實的檢查,能捕捉真實的缺陷。GetFacturXValidationIssues 以名稱回報這些問題,帶有識別碼如 MissingCatalogAFNotPDFA3ConformanceGuidelineMismatchInvalidAFRelationshipInvalidFileNameProfile

但它不檢查 XMP 延伸結構描述是否存在且正確。一份容器完美無缺但 fx 屬性未宣告的檔案,會通過所有問題檢查並回傳 1,因為清單中沒有任何項目會檢查 pdfaExtension:schemas 區塊。這正是為什麼手工建立的發票,或由寫入了屬性區塊卻未附宣告的處理管線所產出的發票,能通過內建驗證器的一切檢查,卻仍然因第 6.6.2.3.1 條而被 veraPDF 拒絕。容器驗證器與 PDF/A 中繼資料驗證器回答的是不同問題,只有完整的 PDF/A 檢查器才能回答第二個問題

讀取問題以了解哪個層次發生了故障

由於這兩個層次的失敗是獨立的,正確的診斷習慣是先讀取容器問題,並將乾淨的結果視為僅關於容器的說明,而非關於 PDF/A 中繼資料的說明。執行內建驗證、收集問題清單,並在使用外部工具之前先對其採取行動

var
  Issues: WideString;
begin
  if PDF.ValidateFacturXInvoice = 0 then
  begin
    Issues := PDF.GetFacturXValidationIssues('|');
    // container-level identifiers, for example:
    //   MissingCatalogAF, NotPDFA3, MissingEmbeddedFilesNameTree,
    //   ConformanceGuidelineMismatch, InvalidAFRelationship
    WriteLn('Container issues: ', Issues);
  end
  else
    WriteLn('Container OK; verify XMP extension schema with a PDF/A checker.');
end;

當該呼叫回傳問題名稱時,故障在容器中,訊息告訴您是哪個部分。當它回傳乾淨的結果而 veraPDF 仍然拒絕檔案時,故障幾乎總是 XMP 延伸結構描述,修正方法是讓 AddFacturXAssociatedFileFromString 寫入中繼資料,而非自行建構屬性區塊。在您自己的思維中將這兩個問題分開,才能將令人困惑的拒絕轉化為一行診斷:容器問題透過問題清單浮現,結構描述宣告問題只透過 PDF/A 驗證器浮現,混淆兩者正是讓問題得以隱藏的原因

更廣泛的 PDF/A 和 PDF/UA 合規性全貌,包括如何在檔案離開建置環境前執行預檢,在PDF/A 與 PDF/UA 預檢逐步說明中有所介紹。如果您的發票也必須符合無障礙要求,PDF/A-3a 和標記 PDF 所依賴的結構樹是標記 PDF 無障礙文章的主題。此處所描述的延伸結構描述處理作為 PDFlibPas Delphi PDF Library 的一部分提供,並附有本部落格各處所記載的 Factur-X、ZUGFeRD 和 XRechnung 設定檔支援