HotPDF validates EN 16931 electronic-invoice business rules through HPDFEInvoiceValidator, a Schematron assertion engine the library implements itself on top of MSXML's XPath 1.0 support rather than a licensed XSLT 2.0 processor. HPDFEInvoiceValidator parses the official Factur-X .sch rule files, evaluates every assertion it can express in XPath 1.0, and marks the rest skipped instead of letting an unsupported expression raise an exception mid-run
The scope here stays inside that engine: how the loader turns Schematron XML into rule entries, how assert and report evaluation actually decides pass or fail, how the XPath 2.0 gap gets detected and skipped, and how the same unit still compiles on Delphi 7. PDF/A-3 embedding, the factur-x.xml / xrechnung.xml container mechanics, and the ZUGFeRD 2.5 versioning story live in the companion article on ZUGFeRD and Factur-X e-invoices in Delphi with HotPDF, which this piece deliberately does not repeat
Why HotPDF built its own EN 16931 Schematron engine
HotPDF built its own Schematron engine because the EN 16931 rule file's declared binding overstates what its assertions actually need: the file sets queryBinding="xslt2" at the top, technically asking for a full XSLT 2.0 / XPath 2.0 processor, but reading through the assertions themselves shows the great majority call only XPath 1.0 functions such as string-length and substring-after. Windows' built-in MSXML DOM — the one XML engine guaranteed to exist on every supported Delphi installation without adding a third-party dependency — happens to implement exactly that subset, XPath 1.0, which is what made a native engine practical instead of licensing a separate XSLT 2.0 runtime. HPDFSchematronFileForProfile maps a detected Factur-X conformance level to one of five shipped rule files this engine can load — MINIMUM, BASIC WL, BASIC, EN 16931, and EXTENDED — and every one of them judges only the extracted invoice XML, never the surrounding PDF; whether that PDF is itself a structurally valid PDF/A-3 file is a separate question answered through HotPDF's PDF/A, PDF/X, and PDF/UA conformance checks elsewhere in the library
How does the engine turn a .sch file into rule entries?
THPDFMSXMLSchematronEngine.Load opens by calling CoInitializeEx(nil, COINIT_MULTITHREADED) before creating anything, because a console or service host that never called Application.Initialize has no COM apartment yet, while a GUI VCL host already does; the engine treats the S_FALSE or RPC_E_CHANGED_MODE result that call can return on a thread already inside an apartment as equally fine rather than as an error. It then parses the Schematron file with an MSXML 6.0 DOM document (CoDOMDocument60) and setProperty('SelectionLanguage', 'XPath'), since MSXML defaults to its older XSL-Pattern dialect unless a caller opts into XPath explicitly. From there, though, the loader never calls selectNodes to walk the .sch file's own structure — every <pattern>, <rule>, <assert>, and <report> element is found by walking firstChild / nextSibling by hand, comparing each node's local name and namespace URI against the literal string http://purl.oclc.org/dsdl/schematron
That manual-walk approach exists because of a chicken-and-egg problem in the <ns prefix="ram" uri="..."/> bindings every Factur-X Schematron file declares up front. Resolving a prefixed XPath expression like ram:Name against those bindings requires MSXML's SelectionNamespaces property to already hold them, but discovering the bindings in the first place would normally mean running an XPath query such as //ns:ns — which itself needs SelectionNamespaces set first. HPDFEInvoiceValidator breaks that cycle by collecting every <ns> element through the same manual child-node walk before touching selectNodes at all, then folds the harvested prefix/URI pairs into one SelectionNamespaces string that both the .sch structure walk and every later rule evaluation reuse
// 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 versus report: what actually fires a violation?
Schematron gives assert and report opposite polarity, and the engine has to preserve that distinction exactly or its violation counts mean nothing. A <assert test="X"> declares that X must hold for every node matching the rule's context path, so EvaluateAssert records a violation when the test expression's result node-set comes back empty; a <report test="X"> is the mirror image, flagging a problem when X is true, so EvaluateReport records a violation when the test result is non-empty instead. Both entry points share the same two-stage shape underneath — Doc.selectNodes(Entry.Context) first, to find every node the rule applies to, then ContextNode.selectNodes(Entry.Test) against each one in turn — which is exactly the context-then-test model a real Schematron processor uses, just driven by MSXML's XPath 1.0 selectNodes instead of a Schematron-aware execution engine
How does the engine skip XPath 2.0 without crashing the run?
HPDFEInvoiceValidator defends against unsupported XPath 2.0 syntax in two layers, and the first never lets MSXML see the expression at all. Before evaluating any assert or report, XPath2Detected scans the raw test-expression string for six literal tokens — xs:decimal, xs:integer, xs:string, upper-case, lower-case, and exists( — and if any of them is present the rule is marked Skipped with stsInfo severity immediately, on the reasoning that MSXML should never be handed an expression it is already known to reject
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;
The second layer catches whatever the static token list misses. Both the context selectNodes call and the per-node test selectNodes call run inside a try/except block; when MSXML raises on an expression the token scan let through — a construct outside the six known tokens, or a context path it cannot resolve — the exception is caught and the rule is recorded as Skipped rather than propagated to the caller. That two-layer design is why an XPath 2.0 construct anywhere in the rule set never raises past HPDFValidateEInvoice: every one of its 424 assertions either evaluates, fails, or gets marked skipped, and an internal estimate against that rule file put the XPath-1.0-executable share at roughly 350 of the 424 — enough that partial evaluation is worth doing rather than falling back to a container-only check the moment a single XPath 2.0 assertion shows up
Feeding UTF-8 invoice XML to MSXML without mangling it
THPDFMSXMLSchematronEngine.Validate does not hand the extracted invoice bytes to IXMLDOMDocument.loadXML, because that method expects a BSTR — UTF-16 — and would reinterpret a raw UTF-8 byte array under that assumption regardless of what the document's own <?xml encoding="UTF-8"?> declaration says. HPDFEInvoiceValidator instead copies the bytes into a GlobalAlloc'd HGLOBAL, wraps it in an IStream through CreateStreamOnHGlobal, and loads that stream via IPersistStreamInit.Load, a path MSXML honours by reading the encoding declaration from the byte stream itself instead of assuming UTF-16 up front. The same method rebuilds SelectionNamespaces from the prefix bindings the loader already harvested while parsing the .sch file, so a rule written against a prefix like ram: resolves correctly against the invoice XML's own namespace on every evaluation, not just when the rule file was first parsed
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
Keeping one unit compiling from Delphi 7 to today
HPDFEInvoiceValidator.pas has to compile on every Delphi version HotPDF supports, including releases with no XML or XPath binding at all, so its interface section exposes only plain value types: records, dynamic arrays, and a single interface, IHPDFESchematronEngine, with Load, Validate, and LastSummary methods. Every MSXML-specific type — IXMLDOMDocument2, the Winapi.msxml import, THPDFMSXMLSchematronEngine itself — sits inside one {$IFDEF XE2+} block in the implementation section, invisible to callers and to the compiler on older toolchains alike
{$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}
On Delphi 7 and earlier, HPDFCreateSchematronEngine hands back THPDFStubSchematronEngine instead: its Load always returns False with an ErrorText that names the real gap — MSXML DOM binding requires XE2 or later — and points toward an external validator such as veraPDF, Mustang, or a ZUGFeRD conformance tool for full coverage in the meantime. Its Validate returns a single synthetic result with RuleID 'ENGINE' and Skipped set, so code that iterates BusinessRules does not need a separate branch for "the engine could not run" versus "every rule happened to be skipped" — both look the same shape to the caller. HPDFValidateEInvoice folds that gracefully into its verdict too: the boolean it returns is ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), so an unavailable engine downgrades the result to a container-only check instead of forcing a hard failure on a compiler that was never going to run Schematron rules in the first place
HPDFEInvoiceValidator's XPath 1.0 engine does not replace a full Schematron/XSLT 2.0 processor, and it was never meant to: an engine confined to XPath 1.0 will always leave a handful of EN 16931 assertions unevaluated, which is exactly what the Skipped flag on each result exists to make visible rather than hide. What the engine does buy is business-rule feedback that runs anywhere HotPDF already runs, with no external process to shell out to and no XSLT 2.0 runtime to licence. This engine ships as part of the HotPDF PDF component for Delphi and C++Builder, alongside the container-level Factur-X and PDF/A tooling it builds on