HotPDF validiert Geschäftsregeln für elektronische Rechnungen nach EN 16931 über HPDFEInvoiceValidator, eine Schematron-Assertion-Engine, die die Bibliothek selbst auf Basis der XPath-1.0-Unterstützung von MSXML implementiert, statt einen lizenzierten XSLT-2.0-Prozessor zu verwenden. HPDFEInvoiceValidator parst die offiziellen Factur-X-.sch-Regeldateien, wertet jede Assertion aus, die sich in XPath 1.0 ausdrücken lässt, und markiert den Rest als übersprungen, statt einen nicht unterstützten Ausdruck mitten im Lauf eine Exception auslösen zu lassen
Der Umfang bleibt hier innerhalb dieser Engine: wie der Loader Schematron-XML in Regeleinträge umwandelt, wie die Assert- und Report-Auswertung tatsächlich über Bestehen oder Scheitern entscheidet, wie die XPath-2.0-Lücke erkannt und übersprungen wird, und wie dieselbe Unit weiterhin unter Delphi 7 kompiliert. PDF/A-3-Einbettung, die Container-Mechanik von factur-x.xml / xrechnung.xml und die Versionierungsgeschichte von ZUGFeRD 2.5 finden sich in dem begleitenden Artikel zu ZUGFeRD- und Factur-X-E-Rechnungen in Delphi mit HotPDF, den dieser Beitrag absichtlich nicht wiederholt
Warum HotPDF eine eigene EN-16931-Schematron-Engine gebaut hat
HotPDF hat eine eigene Schematron-Engine gebaut, weil die deklarierte Bindung der EN-16931-Regeldatei übertreibt, was ihre Assertionen tatsächlich benötigen: Die Datei setzt oben queryBinding="xslt2", fragt damit technisch nach einem vollständigen XSLT-2.0-/XPath-2.0-Prozessor, aber ein Blick durch die Assertionen selbst zeigt, dass die große Mehrheit nur XPath-1.0-Funktionen wie string-length und substring-after aufruft. Der eingebaute MSXML-DOM von Windows – die eine XML-Engine, die auf jeder unterstützten Delphi-Installation garantiert vorhanden ist, ohne eine Drittanbieter-Abhängigkeit hinzuzufügen – implementiert zufällig genau diese Teilmenge, XPath 1.0, was eine native Engine praktikabel machte, statt eine separate XSLT-2.0-Laufzeitumgebung zu lizenzieren. HPDFSchematronFileForProfile bildet ein erkanntes Factur-X-Konformitätsniveau auf eine von fünf mitgelieferten Regeldateien ab, die diese Engine laden kann – MINIMUM, BASIC WL, BASIC, EN 16931 und EXTENDED –, und jede von ihnen beurteilt nur die extrahierte Rechnungs-XML, nie das umgebende PDF; ob dieses PDF selbst eine strukturell gültige PDF/A-3-Datei ist, ist eine separate Frage, die an anderer Stelle in der Bibliothek über HotPDFs Konformitätsprüfungen für PDF/A, PDF/X und PDF/UA beantwortet wird
Wie verwandelt die Engine eine .sch-Datei in Regeleinträge?
THPDFMSXMLSchematronEngine.Load beginnt damit, CoInitializeEx(nil, COINIT_MULTITHREADED) aufzurufen, bevor irgendetwas erzeugt wird, denn ein Konsolen- oder Dienst-Host, der nie Application.Initialize aufgerufen hat, hat noch kein COM-Apartment, während ein GUI-VCL-Host bereits eines hat; die Engine behandelt das Ergebnis S_FALSE oder RPC_E_CHANGED_MODE, das dieser Aufruf auf einem bereits in einem Apartment befindlichen Thread zurückgeben kann, als ebenso in Ordnung wie ein voll erfolgreiches Ergebnis, nicht als Fehler. Anschließend parst sie die Schematron-Datei mit einem MSXML-6.0-DOM-Dokument (CoDOMDocument60) und setProperty('SelectionLanguage', 'XPath'), da MSXML standardmäßig auf seinen älteren XSL-Pattern-Dialekt zurückfällt, sofern ein Aufrufer nicht ausdrücklich XPath wählt. Von da an ruft der Loader jedoch nie selectNodes auf, um die eigene Struktur der .sch-Datei zu durchlaufen – jedes <pattern>-, <rule>-, <assert>- und <report>-Element wird gefunden, indem firstChild / nextSibling von Hand durchlaufen und der lokale Name sowie die Namespace-URI jedes Knotens mit dem wörtlichen String http://purl.oclc.org/dsdl/schematron verglichen werden
Dieser manuelle Durchlauf existiert wegen eines Henne-Ei-Problems bei den <ns prefix="ram" uri="..."/>-Bindungen, die jede Factur-X-Schematron-Datei vorab deklariert. Um einen präfixierten XPath-Ausdruck wie ram:Name gegen diese Bindungen aufzulösen, muss MSXMLs Eigenschaft SelectionNamespaces sie bereits enthalten, aber die Bindungen überhaupt erst zu entdecken würde normalerweise bedeuten, eine XPath-Abfrage wie //ns:ns auszuführen – die selbst zuerst ein gesetztes SelectionNamespaces benötigt. HPDFEInvoiceValidator durchbricht diesen Kreis, indem es jedes <ns>-Element über denselben manuellen Kindknoten-Durchlauf sammelt, bevor es überhaupt selectNodes anfasst, und faltet dann die geernteten Präfix/URI-Paare in einen einzigen SelectionNamespaces-String, den sowohl der .sch-Strukturdurchlauf als auch jede spätere Regelauswertung wiederverwenden
// 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: Was löst tatsächlich eine Verletzung aus?
Schematron gibt Assert und Report entgegengesetzte Polarität, und die Engine muss diese Unterscheidung exakt bewahren, sonst bedeuten ihre Verletzungszähler nichts. Ein <assert test="X"> erklärt, dass X für jeden Knoten gelten muss, der auf den Kontextpfad der Regel passt, sodass EvaluateAssert eine Verletzung protokolliert, wenn die Ergebnis-Knotenmenge des Testausdrucks leer zurückkommt; ein <report test="X"> ist das Spiegelbild, es markiert ein Problem, wenn X wahr ist, sodass EvaluateReport stattdessen eine Verletzung protokolliert, wenn das Testergebnis nicht leer ist. Beide Einstiegspunkte teilen sich darunter dieselbe zweistufige Form – zuerst Doc.selectNodes(Entry.Context), um jeden Knoten zu finden, auf den die Regel zutrifft, dann ContextNode.selectNodes(Entry.Test) nacheinander gegen jeden davon – was genau dem Kontext-dann-Test-Modell entspricht, das ein echter Schematron-Prozessor verwendet, nur angetrieben von MSXMLs XPath-1.0-selectNodes statt einer Schematron-bewussten Ausführungs-Engine
Wie überspringt die Engine XPath 2.0, ohne den Lauf abstürzen zu lassen?
HPDFEInvoiceValidator verteidigt sich gegen nicht unterstützte XPath-2.0-Syntax in zwei Schichten, und die erste lässt MSXML den Ausdruck überhaupt nie sehen. Vor der Auswertung jeder Assert- oder Report-Regel durchsucht XPath2Detected den rohen Testausdrucks-String nach sechs wörtlichen Tokens – xs:decimal, xs:integer, xs:string, upper-case, lower-case und exists( – und ist eines davon vorhanden, wird die Regel sofort mit dem Schweregrad stsInfo als Skipped markiert, nach der Überlegung, dass MSXML niemals ein Ausdruck übergeben werden sollte, von dem bereits bekannt ist, dass er abgelehnt wird
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;
Die zweite Schicht fängt auf, was die statische Token-Liste übersieht. Sowohl der Kontext-selectNodes-Aufruf als auch der Test-selectNodes-Aufruf pro Knoten laufen innerhalb eines try/except-Blocks; wenn MSXML bei einem Ausdruck eine Exception auslöst, den der Token-Scan durchgelassen hat – eine Konstruktion außerhalb der sechs bekannten Tokens, oder ein Kontextpfad, den es nicht auflösen kann –, wird die Exception abgefangen und die Regel als Skipped protokolliert, statt an den Aufrufer weitergegeben zu werden. Dieses zweischichtige Design ist der Grund, weshalb eine XPath-2.0-Konstruktion irgendwo im Regelsatz nie über HPDFValidateEInvoice hinaus eine Exception auslöst: Jede der 424 Assertionen wird entweder ausgewertet, schlägt fehl oder wird als übersprungen markiert, und eine interne Schätzung gegen diese Regeldatei bezifferte den mit XPath 1.0 ausführbaren Anteil auf etwa 350 der 424 – genug, dass eine partielle Auswertung sich lohnt, statt in dem Moment, in dem eine einzelne XPath-2.0-Assertion auftaucht, auf eine reine Container-Prüfung zurückzufallen
UTF-8-Rechnungs-XML an MSXML übergeben, ohne sie zu verstümmeln
THPDFMSXMLSchematronEngine.Validate übergibt die extrahierten Rechnungs-Bytes nicht an IXMLDOMDocument.loadXML, weil diese Methode einen BSTR erwartet – UTF-16 – und ein rohes UTF-8-Byte-Array unter dieser Annahme neu interpretieren würde, unabhängig davon, was die eigene <?xml encoding="UTF-8"?>-Deklaration des Dokuments sagt. HPDFEInvoiceValidator kopiert die Bytes stattdessen in ein per GlobalAlloc alloziertes HGLOBAL, umschließt es über CreateStreamOnHGlobal mit einem IStream und lädt diesen Stream über IPersistStreamInit.Load – ein Pfad, den MSXML respektiert, indem es die Encoding-Deklaration aus dem Byte-Stream selbst liest, statt von vornherein UTF-16 anzunehmen. Dieselbe Methode baut SelectionNamespaces aus den Präfix-Bindungen wieder auf, die der Loader bereits beim Parsen der .sch-Datei geerntet hat, sodass eine gegen einen Präfix wie ram: geschriebene Regel bei jeder Auswertung korrekt gegen den eigenen Namespace der Rechnungs-XML aufgelöst wird, nicht nur, als die Regeldatei erstmals geparst wurde
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
Eine Unit von Delphi 7 bis heute kompilierbar halten
HPDFEInvoiceValidator.pas muss auf jeder von HotPDF unterstützten Delphi-Version kompilieren, einschließlich Versionen ganz ohne XML- oder XPath-Bindung, sodass ihr Interface-Abschnitt nur einfache Werttypen freilegt: Records, dynamische Arrays und ein einziges Interface, IHPDFESchematronEngine, mit den Methoden Load, Validate und LastSummary. Jeder MSXML-spezifische Typ – IXMLDOMDocument2, der Winapi.msxml-Import, THPDFMSXMLSchematronEngine selbst – sitzt innerhalb eines einzigen {$IFDEF XE2+}-Blocks im Implementierungsabschnitt, unsichtbar sowohl für Aufrufer als auch für den Compiler auf älteren Toolchains
{$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}
Unter Delphi 7 und älter gibt HPDFCreateSchematronEngine stattdessen THPDFStubSchematronEngine zurück: Dessen Load gibt immer False zurück mit einem ErrorText, der die eigentliche Lücke benennt – die MSXML-DOM-Bindung erfordert XE2 oder neuer – und verweist in der Zwischenzeit auf einen externen Validator wie veraPDF, Mustang oder ein ZUGFeRD-Konformitätswerkzeug für vollständige Abdeckung. Sein Validate gibt ein einzelnes synthetisches Ergebnis mit RuleID 'ENGINE' und gesetztem Skipped zurück, sodass Code, der BusinessRules durchläuft, keinen separaten Zweig für "die Engine konnte nicht laufen" gegenüber "jede Regel wurde zufällig übersprungen" braucht – beides sieht für den Aufrufer gleich aus. HPDFValidateEInvoice fügt das ebenfalls elegant in sein Urteil ein: Der zurückgegebene boolesche Wert ist ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), sodass eine nicht verfügbare Engine das Ergebnis auf eine reine Container-Prüfung herabstuft, statt einen harten Fehlschlag auf einem Compiler zu erzwingen, der ohnehin nie Schematron-Regeln ausführen sollte
HPDFEInvoiceValidators XPath-1.0-Engine ersetzt keinen vollständigen Schematron-/XSLT-2.0-Prozessor, und das war nie ihr Anspruch: Eine auf XPath 1.0 beschränkte Engine wird immer eine Handvoll EN-16931-Assertionen unbewertet lassen, und genau das ist es, was das Skipped-Flag an jedem Ergebnis sichtbar machen soll, statt es zu verbergen. Was die Engine liefert, ist Geschäftsregel-Feedback, das überall läuft, wo HotPDF ohnehin bereits läuft, ohne externen Prozess, an den ausgelagert werden muss, und ohne XSLT-2.0-Laufzeitumgebung, die lizenziert werden müsste. Diese Engine wird als Teil der HotPDF-PDF-Komponente für Delphi und C++Builder ausgeliefert, zusammen mit dem Container-Level-Factur-X- und PDF/A-Tooling, auf dem sie aufbaut