Technisch artikel

EN 16931 Schematron-regelengine in Delphi met HotPDF

HotPDF valideert EN 16931-bedrijfsregels voor elektronische facturen via HPDFEInvoiceValidator, een Schematron-assertie-engine die de bibliotheek zelf implementeert bovenop de XPath 1.0-ondersteuning van MSXML in plaats van een gelicentieerde XSLT 2.0-processor. HPDFEInvoiceValidator parseert de officiële Factur-X .sch-regelbestanden, evalueert elke assertie die het in XPath 1.0 kan uitdrukken, en markeert de rest als overgeslagen in plaats van een niet-ondersteunde expressie halverwege een uitzondering te laten opwerpen

De reikwijdte hier blijft binnen die engine: hoe de loader Schematron-XML omzet in regelvermeldingen, hoe assert- en report-evaluatie daadwerkelijk beslist over slagen of falen, hoe het XPath 2.0-gat wordt gedetecteerd en overgeslagen, en hoe dezelfde unit nog steeds compileert vanaf Delphi 7. PDF/A-3-inbedding, de containermechaniek van factur-x.xml / xrechnung.xml, en het ZUGFeRD 2.5-versiebeleid staan in het bijbehorende artikel over ZUGFeRD- en Factur-X-e-facturen in Delphi met HotPDF, wat dit stuk bewust niet herhaalt

Waarom HotPDF zijn eigen EN 16931 Schematron-engine bouwde

HotPDF bouwde zijn eigen Schematron-engine omdat de gedeclareerde binding van het EN 16931-regelbestand overdrijft wat de asserties ervan daadwerkelijk nodig hebben: het bestand zet bovenaan queryBinding="xslt2", wat technisch gezien om een volledige XSLT 2.0- / XPath 2.0-processor vraagt, maar het doorlezen van de asserties zelf laat zien dat de overgrote meerderheid alleen XPath 1.0-functies aanroept zoals string-length en substring-after. Windows' ingebouwde MSXML-DOM — de ene XML-engine die gegarandeerd bestaat op elke ondersteunde Delphi-installatie zonder een externe afhankelijkheid toe te voegen — implementeert toevallig precies die subset, XPath 1.0, wat een native engine praktisch maakte in plaats van een aparte XSLT 2.0-runtime te licentiëren. HPDFSchematronFileForProfile mapt een gedetecteerd Factur-X-conformiteitsniveau naar een van de vijf meegeleverde regelbestanden die deze engine kan laden — MINIMUM, BASIC WL, BASIC, EN 16931, en EXTENDED — en elk daarvan beoordeelt alleen de geëxtraheerde factuur-XML, nooit de omringende PDF; of die PDF zelf een structureel geldig PDF/A-3-bestand is, is een aparte vraag die elders in de bibliotheek wordt beantwoord via HotPDF's PDF/A-, PDF/X- en PDF/UA-conformiteitscontroles

Hoe zet de engine een .sch-bestand om in regelvermeldingen?

THPDFMSXMLSchematronEngine.Load begint met het aanroepen van CoInitializeEx(nil, COINIT_MULTITHREADED) voordat er iets wordt aangemaakt, omdat een console- of servicehost die nooit Application.Initialize heeft aangeroepen nog geen COM-apartment heeft, terwijl een GUI-VCL-host dat al wel heeft; de engine behandelt het resultaat S_FALSE of RPC_E_CHANGED_MODE dat die aanroep kan teruggeven op een thread die al in een apartment zit, als even prima in plaats van als fout. Vervolgens parseert het het Schematron-bestand met een MSXML 6.0-DOM-document (CoDOMDocument60) en setProperty('SelectionLanguage', 'XPath'), aangezien MSXML standaard zijn oudere XSL-Pattern-dialect gebruikt tenzij een aanroeper expliciet voor XPath kiest. Van daaruit roept de loader echter nooit selectNodes aan om de eigen structuur van het .sch-bestand te doorlopen — elk <pattern>-, <rule>-, <assert>-, en <report>-element wordt gevonden door met de hand firstChild / nextSibling te doorlopen, waarbij de lokale naam en namespace-URI van elk knooppunt wordt vergeleken met de letterlijke string http://purl.oclc.org/dsdl/schematron

Die handmatige doorloopaanpak bestaat vanwege een kip-en-ei-probleem in de <ns prefix="ram" uri="..."/>-bindingen die elk Factur-X-Schematron-bestand vooraf declareert. Het oplossen van een geprefixte XPath-expressie zoals ram:Name tegen die bindingen vereist dat de SelectionNamespaces-eigenschap van MSXML deze al bevat, maar het ontdekken van de bindingen zelf zou normaal gesproken betekenen dat er een XPath-query wordt uitgevoerd zoals //ns:ns — wat op zijn beurt vereist dat SelectionNamespaces al is ingesteld. HPDFEInvoiceValidator doorbreekt die cyclus door elk <ns>-element te verzamelen via dezelfde handmatige kindknooppunt-doorloop voordat selectNodes ook maar wordt aangeraakt, en vouwt de geoogste prefix/URI-paren vervolgens in één SelectionNamespaces-string die zowel de .sch-structuurdoorloop als elke latere regelevaluatie hergebruikt

// 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: wat activeert daadwerkelijk een schending?

Schematron geeft assert en report tegenovergestelde polariteit, en de engine moet dat onderscheid exact behouden, anders betekenen de schendingstellingen niets. Een <assert test="X"> verklaart dat X moet gelden voor elk knooppunt dat overeenkomt met het contextpad van de regel, dus EvaluateAssert registreert een schending wanneer de resulterende knooppuntverzameling van de testexpressie leeg terugkomt; een <report test="X"> is het spiegelbeeld, dat een probleem markeert wanneer X waar is, dus EvaluateReport registreert in plaats daarvan een schending wanneer het testresultaat niet leeg is. Beide toegangspunten delen onderliggend dezelfde tweefasenvorm — eerst Doc.selectNodes(Entry.Context), om elk knooppunt te vinden waarop de regel van toepassing is, dan ContextNode.selectNodes(Entry.Test) tegen elk daarvan om de beurt — wat precies het context-dan-test-model is dat een echte Schematron-processor gebruikt, alleen aangestuurd door MSXML's XPath 1.0 selectNodes in plaats van een Schematron-bewuste uitvoeringsengine

Hoe slaat de engine XPath 2.0 over zonder de run te laten crashen?

HPDFEInvoiceValidator verdedigt zich tegen niet-ondersteunde XPath 2.0-syntaxis in twee lagen, en de eerste laat MSXML de expressie nooit zelfs maar zien. Voordat een assert of report wordt geëvalueerd, doorzoekt XPath2Detected de ruwe testexpressie-string op zes letterlijke tokens — xs:decimal, xs:integer, xs:string, upper-case, lower-case, en exists( — en als een daarvan aanwezig is, wordt de regel onmiddellijk gemarkeerd als Skipped met stsInfo-ernst, op basis van de redenering dat MSXML nooit een expressie mag krijgen waarvan al bekend is dat deze wordt afgewezen

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;

De tweede laag vangt op wat de statische tokenlijst mist. Zowel de context-selectNodes-aanroep als de per-knooppunt-test-selectNodes-aanroep lopen binnen een try/except-blok; wanneer MSXML een uitzondering opwerpt bij een expressie die de tokenscan doorliet — een constructie buiten de zes bekende tokens, of een contextpad dat het niet kan oplossen — wordt de uitzondering opgevangen en wordt de regel geregistreerd als Skipped in plaats van doorgegeven aan de aanroeper. Dat tweelagige ontwerp is waarom een XPath 2.0-constructie ergens in de regelset nooit voorbij HPDFValidateEInvoice opwerpt: elk van de 424 asserties evalueert, faalt, of wordt gemarkeerd als overgeslagen, en een interne schatting tegen dat regelbestand plaatste het XPath-1.0-uitvoerbare aandeel op ongeveer 350 van de 424 — genoeg dat gedeeltelijke evaluatie de moeite waard is in plaats van terug te vallen op een alleen-containercontrole zodra er één enkele XPath 2.0-assertie opduikt

UTF-8-factuur-XML aan MSXML voeren zonder deze te verminken

THPDFMSXMLSchematronEngine.Validate geeft de geëxtraheerde factuurbytes niet door aan IXMLDOMDocument.loadXML, omdat die methode een BSTR verwacht — UTF-16 — en een ruwe UTF-8-bytearray onder die aanname zou herinterpreteren, ongeacht wat de eigen <?xml encoding="UTF-8"?>-declaratie van het document zegt. HPDFEInvoiceValidator kopieert in plaats daarvan de bytes naar een met GlobalAlloc toegewezen HGLOBAL, wikkelt deze in een IStream via CreateStreamOnHGlobal, en laadt die stream via IPersistStreamInit.Load, een pad dat MSXML respecteert door de coderingsdeclaratie uit de bytestream zelf te lezen in plaats van vooraf UTF-16 aan te nemen. Dezelfde methode herbouwt SelectionNamespaces uit de prefix-bindingen die de loader al heeft geoogst tijdens het parseren van het .sch-bestand, zodat een regel geschreven tegen een prefix zoals ram: bij elke evaluatie correct oplost tegen de eigen namespace van de factuur-XML, niet alleen wanneer het regelbestand voor het eerst werd geparseerd

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

Eén unit laten compileren van Delphi 7 tot vandaag

HPDFEInvoiceValidator.pas moet compileren op elke Delphi-versie die HotPDF ondersteunt, inclusief releases zonder enige XML- of XPath-binding, dus de interface-sectie ervan ontsluit alleen gewone waardetypen: records, dynamische arrays, en één interface, IHPDFESchematronEngine, met de methoden Load, Validate, en LastSummary. Elk MSXML-specifiek type — IXMLDOMDocument2, de Winapi.msxml-import, THPDFMSXMLSchematronEngine zelf — bevindt zich binnen één {$IFDEF XE2+}-blok in de implementatiesectie, onzichtbaar voor zowel aanroepers als de compiler op oudere 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}

Op Delphi 7 en eerder geeft HPDFCreateSchematronEngine in plaats daarvan THPDFStubSchematronEngine terug: de Load-methode geeft altijd False terug met een ErrorText die het echte gat benoemt — MSXML-DOM-binding vereist XE2 of later — en verwijst intussen naar een externe validator zoals veraPDF, Mustang, of een ZUGFeRD-conformiteitstool voor volledige dekking. De Validate-methode geeft één synthetisch resultaat terug met RuleID 'ENGINE' en Skipped ingesteld, zodat code die BusinessRules doorloopt geen aparte tak nodig heeft voor "de engine kon niet draaien" versus "elke regel bleek overgeslagen te zijn" — beide zien er voor de aanroeper hetzelfde uit. HPDFValidateEInvoice vouwt dat ook netjes in zijn uitspraak: de boolean die het teruggeeft is ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), zodat een niet-beschikbare engine het resultaat afschaalt naar een alleen-containercontrole in plaats van een harde mislukking af te dwingen op een compiler die toch nooit Schematron-regels zou hebben uitgevoerd

De XPath 1.0-engine van HPDFEInvoiceValidator vervangt geen volledige Schematron/XSLT 2.0-processor, en dat was ook nooit de bedoeling: een engine die beperkt is tot XPath 1.0 zal altijd een handvol EN 16931-asserties onbeoordeeld laten, en dat is precies waarvoor de Skipped-vlag op elk resultaat bestaat — om dat zichtbaar te maken in plaats van te verbergen. Wat de engine wel oplevert is bedrijfsregel-feedback die overal draait waar HotPDF al draait, zonder extern proces om naar toe te shellen en zonder XSLT 2.0-runtime om te licentiëren. Deze engine wordt geleverd als onderdeel van de HotPDF PDF-component voor Delphi en C++Builder, naast de containerniveau Factur-X- en PDF/A-tooling waarop het voortbouwt