技术文章

使用 HotPDF 在 Delphi 中实现 EN 16931 Schematron 规则引擎

HotPDF 通过 HPDFEInvoiceValidator 验证 EN 16931 电子发票业务规则,这是一个由库自行实现的 Schematron 断言引擎,基于 MSXML 的 XPath 1.0 支持运行,而不是依赖获得许可的 XSLT 2.0 处理器。HPDFEInvoiceValidator 会解析官方 Factur-X .sch 规则文件,评估它能够用 XPath 1.0 表达的每一条断言,并将其余断言标记为跳过,而不是让不受支持的表达式在运行过程中途引发异常

本文范围仅限于该引擎:加载器如何将 Schematron XML 转换为规则条目,断言和报告的评估实际如何决定通过或失败,如何检测并跳过 XPath 2.0 的缺口,以及同一单元如何继续在 Delphi 7 上编译。PDF/A-3 嵌入、factur-x.xml / xrechnung.xml 容器机制,以及 ZUGFeRD 2.5 的版本说明,均位于关于在 Delphi 中使用 HotPDF 处理 ZUGFeRD 和 Factur-X 电子发票的配套文章中,本文不再重复这些内容

HotPDF 为什么构建自己的 EN 16931 Schematron 引擎

HotPDF 构建自己的 Schematron 引擎,是因为 EN 16931 规则文件声明的绑定高估了其断言实际需要的能力:文件顶部设置了 queryBinding="xslt2",从技术上要求完整的 XSLT 2.0 / XPath 2.0 处理器,但逐条查看断言可以发现,绝大多数只调用 string-lengthsubstring-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 单元,而 GUI VCL 宿主已经拥有;如果线程已经处于某个 COM 单元中,该调用可能返回 S_FALSERPC_E_CHANGED_MODE,引擎会将这两种结果同样视为正常,而不是错误。随后,它使用 MSXML 6.0 DOM 文档(CoDOMDocument60)解析 Schematron 文件,并设置 setProperty('SelectionLanguage', 'XPath'),因为除非调用方明确选择 XPath,否则 MSXML 默认使用较旧的 XSL-Pattern 方言。不过从这里开始,加载器不会调用 selectNodes 遍历 .sch 文件自身的结构——每个 <pattern><rule><assert><report> 元素,都是通过手动遍历 firstChild / nextSibling 找到的,并将每个节点的本地名称和命名空间 URI 与字面量字符串 http://purl.oclc.org/dsdl/schematron 进行比较

采用手动遍历,是因为每个 Factur-X Schematron 文件预先声明的 <ns prefix="ram" uri="..."/> 绑定存在先有鸡还是先有蛋的问题。要根据这些绑定解析类似 ram:Name 的带前缀 XPath 表达式,需要 MSXML 的 SelectionNamespaces 属性已经包含这些绑定,但要发现这些绑定,通常又要运行类似 //ns:ns 的 XPath 查询——而这本身要求先设置 SelectionNamespacesHPDFEInvoiceValidator 通过相同的手动子节点遍历收集每个 <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);

断言与报告:究竟什么会触发违规

Schematron 为断言和报告规定了相反的极性,引擎必须准确保留这种区别,否则违规计数就没有意义。<assert test="X"> 声明对于匹配规则上下文路径的每个节点,X 都必须成立,因此当测试表达式返回的结果节点集为空时,EvaluateAssert 会记录违规;<report test="X"> 则相反,当 X 为真时标记问题,因此当测试结果非空时,EvaluateReport 会记录违规。两个入口在底层共享相同的两阶段结构——首先执行 Doc.selectNodes(Entry.Context) 查找规则适用的每个节点,然后依次对每个节点执行 ContextNode.selectNodes(Entry.Test)——这正是实际 Schematron 处理器采用的上下文再测试模型,只是由 MSXML 的 XPath 1.0 selectNodes 驱动,而不是由 Schematron 感知的执行引擎驱动

引擎如何跳过 XPath 2.0 而不使运行过程崩溃

HPDFEInvoiceValidator 通过两层防护处理不受支持的 XPath 2.0 语法,第一层甚至不会让 MSXML 看到表达式。在评估任何断言或报告之前,XPath2Detected 会扫描原始测试表达式字符串中的六个字面量标记——xs:decimalxs:integerxs:stringupper-caselower-caseexists(——如果发现其中任意一个,规则就会立即以 stsInfo 严重性标记为 Skipped,因为 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;

第二层会捕获静态标记列表遗漏的内容。上下文的 selectNodes 调用和逐节点的测试 selectNodes 调用都在 try/except 块中运行;当 MSXML 因标记扫描放行的表达式——例如六个已知标记之外的构造,或无法解析的上下文路径——引发异常时,异常会被捕获,规则会被记录为 Skipped,而不是传递给调用方。这种两层设计确保规则集中的任何 XPath 2.0 构造都不会越过 HPDFValidateEInvoice 引发异常:其 424 条断言中的每一条都会被评估、失败或标记为跳过,而对该规则文件的内部估算显示,约有 350 条(共 424 条)可以用 XPath 1.0 执行,因此值得进行部分评估,而不是在出现一条 XPath 2.0 断言时立即退回到仅检查容器

向 MSXML 提供 UTF-8 发票 XML 而不破坏内容

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,其中包含 LoadValidateLastSummary 方法。每个 MSXML 专用类型——IXMLDOMDocument2Winapi.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 始终返回 False,并通过 ErrorText 说明实际缺口——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 工具