HotPDF 透過 HPDFEInvoiceValidator 驗證 EN 16931 電子發票商業規則。這是一個由函式庫自行實作、建構於 MSXML XPath 1.0 支援之上的 Schematron 斷言引擎,而不是授權的 XSLT 2.0 處理器。HPDFEInvoiceValidator 會剖析官方 Factur-X .sch 規則檔,評估所有能以 XPath 1.0 表達的斷言,並將其餘斷言標記為略過,而不讓不受支援的運算式在執行期間引發例外
本文範圍僅涵蓋該引擎:載入器如何將 Schematron XML 轉為規則項目、assert 與 report 的評估如何實際判定通過或失敗、如何偵測並略過 XPath 2.0 缺口,以及同一個單元如何在 Delphi 7 上編譯。PDF/A-3 嵌入、factur-x.xml / xrechnung.xml 容器機制,以及 ZUGFeRD 2.5 版本說明,均位於另一篇介紹使用 HotPDF 在 Delphi 中處理 ZUGFeRD 與 Factur-X 電子發票的配套文章,本文刻意不重複這些內容
HotPDF 為何自行建立 EN 16931 Schematron 引擎
HotPDF 自行建立 Schematron 引擎,是因為 EN 16931 規則檔宣告的繫結高估了其斷言實際需要的功能:檔案頂端設定了 queryBinding="xslt2",技術上要求完整的 XSLT 2.0 / XPath 2.0 處理器,但逐一閱讀斷言後可見,其中絕大多數只呼叫 string-length 與 substring-after 等 XPath 1.0 函式。Windows 內建的 MSXML DOM 是每個受支援 Delphi 安裝都能保證存在、且不必增加第三方相依性的 XML 引擎,恰好實作了 XPath 1.0 這個子集,因此可以建立原生引擎,而不必授權另一個 XSLT 2.0 執行階段。HPDFSchematronFileForProfile 會將偵測到的 Factur-X 相容性層級對應到本引擎可載入的五個隨附規則檔之一:MINIMUM、BASIC WL、BASIC、EN 16931 與 EXTENDED;它們全部只判定擷取出的發票 XML,從不判定外層 PDF。該 PDF 是否本身是結構有效的 PDF/A-3 檔案,則是另一個問題,交由函式庫其他位置的 HotPDF PDF/A、PDF/X 與 PDF/UA 相容性檢查回答
引擎如何將 .sch 檔轉為規則項目
THPDFMSXMLSchematronEngine.Load 會先呼叫 CoInitializeEx(nil, COINIT_MULTITHREADED),再建立任何物件,因為從未呼叫 Application.Initialize 的主控台或服務主機尚未建立 COM apartment,而圖形 VCL 主機已經建立。對於該呼叫在線程已位於 apartment 中時可能回傳的 S_FALSE 或 RPC_E_CHANGED_MODE,引擎會同樣視為正常,而不是錯誤。接著,它使用 MSXML 6.0 DOM 文件(CoDOMDocument60)剖析 Schematron 檔,並設定 setProperty('SelectionLanguage', 'XPath'),因為除非呼叫者明確選擇 XPath,否則 MSXML 預設使用較舊的 XSL-Pattern 方言。從這裡開始,載入器不會呼叫 selectNodes 走訪 .sch 檔自身的結構,而是透過 firstChild / nextSibling 手動走訪每個 <pattern>、<rule>、<assert> 與 <report> 元素,並將節點的區域名稱與命名空間 URI 和 literal 字串 http://purl.oclc.org/dsdl/schematron 比較
這種手動走訪方式是為了處理每個 Factur-X Schematron 檔案預先宣告的 <ns prefix="ram" uri="..."/> 繫結所造成的先有雞還是先有蛋問題。要根據這些繫結解析 ram:Name 這類帶前綴的 XPath 運算式,MSXML 的 SelectionNamespaces 屬性必須先持有這些繫結;但要先發現繫結,通常又得執行 //ns:ns 這類 XPath 查詢,而這本身也需要先設定 SelectionNamespaces。HPDFEInvoiceValidator 透過相同的手動子節點走訪,先收集所有 <ns> 元素,再接觸 selectNodes,藉此打破循環;然後將取得的前綴/URI 配對整合成一個 SelectionNamespaces 字串,供 .sch 結構走訪與之後的每次規則評估重複使用
// Schematron <ns> bindings must be known before any prefixed XPath can
// run, so this walk cannot itself use selectNodes -- it is done by hand.
ChildNode := Root.firstChild;
while ChildNode <> nil do
begin
if (ChildNode.baseName = 'ns') and
(ChildNode.namespaceURI = 'http://purl.oclc.org/dsdl/schematron') then
AddNamespace(AttrValue(ChildNode, 'prefix'), AttrValue(ChildNode, 'uri'));
ChildNode := ChildNode.nextSibling;
end;
Doc.setProperty('SelectionNamespaces', BuildSelectorNamespaces);
Assert 與 report:究竟什麼會觸發違規
Schematron 對 assert 與 report 採用相反的極性,引擎必須精確保留這個差異,否則違規計數便沒有意義。<assert test="X"> 宣告 X 必須對符合規則 context 路徑的每個節點成立,因此當測試運算式的結果節點集為空時,EvaluateAssert 會記錄違規;<report test="X"> 則相反,當 X 為 true 時標示問題,因此當測試結果為非空時,EvaluateReport 會記錄違規。兩個進入點底層共用相同的兩階段形狀:先使用 Doc.selectNodes(Entry.Context) 找出規則適用的每個節點,再依序對每個節點執行 ContextNode.selectNodes(Entry.Test),這正是實際 Schematron 處理器使用的 context 接著 test 模型,只是改用 MSXML 的 XPath 1.0 selectNodes,而不是具備 Schematron 感知能力的執行引擎
引擎如何略過 XPath 2.0 而不讓執行中斷
HPDFEInvoiceValidator 以兩層防護處理不受支援的 XPath 2.0 語法,第一層甚至不讓 MSXML 看見該運算式。在評估任何 assert 或 report 之前,XPath2Detected 會掃描原始測試運算式字串中的六個 literal token:xs:decimal、xs:integer、xs:string、upper-case、lower-case 與 exists(。只要出現其中任何一個 token,規則便立即以 stsInfo 嚴重性標記為 Skipped,理由是絕不應將已知會被 MSXML 拒絕的運算式交給 MSXML
const
// MSXML implements XPath 1.0 only; presence of any of these tokens marks
// the assertion as skipped instead of letting MSXML reject the expression.
XPATH2_TOKENS: array[0..5] of string = ('xs:decimal', 'xs:integer',
'xs:string', 'upper-case', 'lower-case', 'exists(');
function XPath2Detected(const TestExpr: string): Boolean;
var
Token: string;
begin
Result := False;
for Token in XPATH2_TOKENS do
if Pos(Token, TestExpr) > 0 then
Exit(True);
end;
第二層會捕捉靜態 token 清單遺漏的內容。context 的 selectNodes 呼叫與逐節點的測試 selectNodes 呼叫都在 try/except 區塊中執行;當 MSXML 對 token 掃描放行的運算式引發錯誤時——可能是六個已知 token 之外的結構,或是無法解析的 context 路徑——例外會被捕捉,規則會被記錄為 Skipped,而不是傳遞給呼叫者。正是這兩層設計,讓規則集中的 XPath 2.0 結構不會穿透 HPDFValidateEInvoice 引發例外:424 個斷言中的每一個都會被評估、失敗,或標記為略過;針對該規則檔的內部估算顯示,約有 350 個(共 424 個)可由 XPath 1.0 執行,因此部分評估值得採用,不必在單一 XPath 2.0 斷言出現時就退回只檢查容器
將 UTF-8 發票 XML 餵給 MSXML 而不破壞內容
THPDFMSXMLSchematronEngine.Validate 不會將擷取出的發票位元組交給 IXMLDOMDocument.loadXML,因為該方法要求 BSTR,也就是 UTF-16;無論文件自身的 <?xml encoding="UTF-8"?> 宣告為何,它都會在這種假設下重新解讀原始 UTF-8 位元組陣列。HPDFEInvoiceValidator 會改為將位元組複製到 GlobalAlloc 配置的 HGLOBAL,透過 CreateStreamOnHGlobal 將其包裝成 IStream,再透過 IPersistStreamInit.Load 載入該串流。這條路徑讓 MSXML 從位元組串流本身讀取編碼宣告,而不是一開始就假設為 UTF-16。同一方法會根據載入器剖析 .sch 檔時已取得的前綴繫結,重新建立 SelectionNamespaces,因此以 ram: 這類前綴撰寫的規則,在每次評估時都能正確對應到發票 XML 自身的命名空間,而不只是在首次剖析規則檔時有效
HMem := GlobalAlloc(GMEM_MOVEABLE, Length(XMLBytes));
P := GlobalLock(HMem);
Move(XMLBytes[0], P^, Length(XMLBytes));
GlobalUnlock(HMem);
CreateStreamOnHGlobal(HMem, True, Stream); // stream owns HMem from here
(Doc as IPersistStreamInit).Load(Stream); // honours the XML encoding declaration
讓同一個單元從 Delphi 7 編譯到現今版本
HPDFEInvoiceValidator.pas 必須在 HotPDF 支援的每個 Delphi 版本上編譯,包括完全沒有 XML 或 XPath 繫結的版本,因此其介面區段只公開一般值型別:記錄、動態陣列,以及唯一的介面 IHPDFESchematronEngine,其方法為 Load、Validate 與 LastSummary。每個 MSXML 特定型別——IXMLDOMDocument2、Winapi.msxml 匯入項目,以及 THPDFMSXMLSchematronEngine 本身——都位於實作區段的一個 {$IFDEF XE2+} 區塊內,對呼叫者與舊工具鏈上的編譯器同樣不可見
{$IFDEF XE2+}
function HPDFCreateSchematronEngine: IHPDFESchematronEngine;
begin
Result := THPDFMSXMLSchematronEngine.Create; // real MSXML-backed engine
end;
{$ELSE}
function HPDFCreateSchematronEngine: IHPDFESchematronEngine;
begin
Result := THPDFStubSchematronEngine.Create; // Delphi 7: reports itself unavailable
end;
{$ENDIF}
在 Delphi 7 與更早版本上,HPDFCreateSchematronEngine 會改為傳回 THPDFStubSchematronEngine:其 Load 一律以 ErrorText 傳回 False,指出真正的缺口——MSXML DOM 繫結需要 XE2 或更新版本——並在此期間指向 veraPDF、Mustang 或 ZUGFeRD 相容性工具等外部驗證器,以取得完整涵蓋範圍。其 Validate 會傳回一個合成結果,其中 RuleID 為 'ENGINE' 且設定 Skipped,因此逐一處理 BusinessRules 的程式碼不需要為「引擎無法執行」與「每條規則恰好都被略過」建立不同分支——對呼叫者而言兩者形狀相同。HPDFValidateEInvoice 也會將這種情況平順地納入判定:它傳回的布林值為 ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)),所以無法使用的引擎會將結果降級為僅檢查容器,而不會在原本就不可能執行 Schematron 規則的編譯器上強制產生硬失敗
HPDFEInvoiceValidator 的 XPath 1.0 引擎並不取代完整的 Schematron/XSLT 2.0 處理器,也從未打算取代:受限於 XPath 1.0 的引擎,始終會留下少量未評估的 EN 16931 斷言,而每個結果上的 Skipped 旗標正是用來讓這些情況可見,而不是加以隱藏。引擎帶來的是可在 HotPDF 已支援的任何環境中執行的商業規則回饋,不需要啟動外部處理程序,也不需要授權 XSLT 2.0 執行階段。此引擎隨附於適用於 Delphi 與 C++Builder 的 HotPDF PDF 元件,並與其所建構的容器層 Factur-X 與 PDF/A 工具一同提供