Technický článek

Engine pravidel Schematron EN 16931 v Delphi s HotPDF

HotPDF ověřuje obchodní pravidla elektronické fakturace EN 16931 přes HPDFEInvoiceValidator, engine pro tvrzení Schematron, který si knihovna implementuje sama nad podporou XPath 1.0 v MSXML místo licencovaného procesoru XSLT 2.0. HPDFEInvoiceValidator parsuje oficiální soubory pravidel .sch pro Factur-X, vyhodnotí každé tvrzení, které dokáže vyjádřit v XPath 1.0, a zbytek označí jako přeskočený místo toho, aby nepodporovaný výraz uprostřed běhu vyvolal výjimku

Rozsah tohoto článku zůstává uvnitř tohoto enginu: jak loader převádí XML Schematronu na záznamy pravidel, jak vyhodnocení assert a report skutečně rozhoduje o úspěchu nebo selhání, jak se detekuje a přeskakuje mezera XPath 2.0, a jak se stejná jednotka pořád překládá i na Delphi 7. Vkládání do PDF/A-3, mechanika kontejneru factur-x.xml / xrechnung.xml a příběh verzování ZUGFeRD 2.5 žijí v doprovodném článku o elektronických fakturách ZUGFeRD a Factur-X v Delphi s HotPDF, který tento text záměrně neopakuje

Proč si HotPDF postavil vlastní engine Schematron pro EN 16931

HotPDF si postavil vlastní engine Schematron proto, že deklarovaný binding souboru pravidel EN 16931 nadhodnocuje, co jeho tvrzení skutečně potřebují: soubor nahoře nastavuje queryBinding="xslt2", čímž technicky žádá plný procesor XSLT 2.0 / XPath 2.0, ale při čtení samotných tvrzení se ukáže, že drtivá většina volá jen funkce XPath 1.0, jako string-length a substring-after. Vestavěný DOM MSXML od Windows — jediný XML engine zaručeně přítomný na každé podporované instalaci Delphi bez přidání závislosti třetí strany — právě tuto podmnožinu, XPath 1.0, implementuje, a to je to, co udělalo nativní engine praktickým místo licencování samostatného runtime XSLT 2.0. HPDFSchematronFileForProfile mapuje zjištěnou úroveň konformity Factur-X na jeden z pěti dodávaných souborů pravidel, které tento engine dokáže načíst — MINIMUM, BASIC WL, BASIC, EN 16931 a EXTENDED — a každý z nich posuzuje jen extrahované XML faktury, nikdy okolní PDF; zda je toto PDF samo o sobě strukturálně platným souborem PDF/A-3, je samostatná otázka, na kterou odpovídají kontroly konformity PDF/A, PDF/X a PDF/UA v HotPDF jinde v knihovně

Jak engine převádí soubor .sch na záznamy pravidel?

THPDFMSXMLSchematronEngine.Load začíná voláním CoInitializeEx(nil, COINIT_MULTITHREADED) ještě předtím, než cokoli vytvoří, protože konzolový nebo servisní hostitel, který nikdy nezavolal Application.Initialize, ještě nemá bytovou jednotku COM, zatímco GUI hostitel VCL už ji má; engine bere výsledek S_FALSE nebo RPC_E_CHANGED_MODE, který toto volání může vrátit na vlákně už uvnitř bytové jednotky, stejně dobře jako úspěch, ne jako chybu. Pak soubor Schematronu parsuje pomocí DOM dokumentu MSXML 6.0 (CoDOMDocument60) a setProperty('SelectionLanguage', 'XPath'), protože MSXML ve výchozím stavu používá svůj starší dialekt XSL-Pattern, pokud se volající výslovně nepřihlásí k XPath. Odtud ale loader nikdy nevolá selectNodes, aby procházel vlastní strukturu souboru .sch — každý element <pattern>, <rule>, <assert> a <report> se najde ručním procházením firstChild / nextSibling, porovnáváním lokálního jména a URI jmenného prostoru každého uzlu s doslovným řetězcem http://purl.oclc.org/dsdl/schematron

Tento přístup ručního procházení existuje kvůli problému slepice a vejce v bindingech <ns prefix="ram" uri="..."/>, které každý soubor Schematronu pro Factur-X deklaruje hned na začátku. Rozřešení výrazu XPath s prefixem, jako ram:Name, proti těmto bindingům vyžaduje, aby vlastnost SelectionNamespaces v MSXML už tyto bindingy obsahovala, ale objevení bindingů samo o sobě by normálně znamenalo spustit dotaz XPath, jako //ns:ns — což zase vyžaduje, aby byla SelectionNamespaces nastavená předem. HPDFEInvoiceValidator tento kruh přeruší tak, že posbírá každý element <ns> stejným ručním průchodem podřízených uzlů ještě předtím, než se vůbec dotkne selectNodes, a pak sesbírané dvojice prefix/URI sloučí do jednoho řetězce SelectionNamespaces, který znovu použije jak průchod strukturou .sch, tak každé pozdější vyhodnocení pravidla

// 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: co vlastně vyvolá porušení?

Schematron dává assert a report opačnou polaritu a engine musí toto rozlišení zachovat přesně, jinak jeho počty porušení nic neznamenají. <assert test="X"> deklaruje, že X musí platit pro každý uzel odpovídající kontextové cestě pravidla, takže EvaluateAssert zaznamená porušení, když se množina uzlů výsledku testovacího výrazu vrátí prázdná; <report test="X"> je zrcadlový obraz, který označí problém, když X platí, takže EvaluateReport zaznamená porušení, když je naopak výsledek testu neprázdný. Oba vstupní body sdílejí pod sebou stejný dvoufázový tvar — nejdřív Doc.selectNodes(Entry.Context), aby se našel každý uzel, na který se pravidlo vztahuje, pak ContextNode.selectNodes(Entry.Test) postupně proti každému z nich — což je přesně model kontext-pak-test, jaký používá skutečný procesor Schematronu, jen řízený přes XPath 1.0 selectNodes z MSXML místo enginu vykonávajícího s vědomím Schematronu

Jak engine přeskočí XPath 2.0, aniž by běh spadl?

HPDFEInvoiceValidator se brání proti nepodporované syntaxi XPath 2.0 ve dvou vrstvách a první z nich nedovolí MSXML výraz vůbec spatřit. Před vyhodnocením jakéhokoli assert nebo report XPath2Detected prohledá surový řetězec testovacího výrazu na šest doslovných tokenů — xs:decimal, xs:integer, xs:string, upper-case, lower-case a exists( — a pokud je přítomen kterýkoli z nich, pravidlo se okamžitě označí jako Skipped se závažností stsInfo, s úvahou, že MSXML by nikdy neměl dostat výraz, o kterém se už ví, že jej odmítne

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;

Druhá vrstva zachytí vše, co statický seznam tokenů propustí. Jak volání selectNodes pro kontext, tak volání selectNodes pro test na jednotlivém uzlu běží uvnitř bloku try/except; když MSXML vyvolá výjimku na výrazu, který skenování tokenů pustilo dál — konstrukce mimo šest známých tokenů, nebo kontextová cesta, kterou nedokáže rozřešit — výjimka se zachytí a pravidlo se zaznamená jako Skipped místo toho, aby se propagovala k volajícímu. Tento dvouvrstvý návrh je důvodem, proč konstrukce XPath 2.0 kdekoli v sadě pravidel nikdy nepropadne za HPDFValidateEInvoice: každé z jeho 424 tvrzení se buď vyhodnotí, selže, nebo se označí jako přeskočené, a interní odhad na tomto souboru pravidel odhadl podíl vykonatelný v XPath 1.0 na zhruba 350 ze 424 — dost na to, aby se dílčí vyhodnocení vyplatilo místo toho, aby se ve chvíli, kdy se objeví jediné tvrzení XPath 2.0, spadlo zpět na kontrolu jen na úrovni kontejneru

Předání UTF-8 XML faktury do MSXML bez jeho poškození

THPDFMSXMLSchematronEngine.Validate nepředává extrahované bajty faktury metodě IXMLDOMDocument.loadXML, protože ta očekává BSTR — UTF-16 — a přeinterpretovala by surové pole bajtů v UTF-8 podle tohoto předpokladu bez ohledu na to, co říká vlastní deklarace dokumentu <?xml encoding="UTF-8"?>. HPDFEInvoiceValidator místo toho zkopíruje bajty do HGLOBAL alokovaného přes GlobalAlloc, obalí je do IStream přes CreateStreamOnHGlobal a tento proud načte přes IPersistStreamInit.Load, cestu, kterou MSXML respektuje tím, že čte deklaraci kódování přímo z bajtového proudu místo toho, aby předem předpokládal UTF-16. Tatáž metoda znovu sestaví SelectionNamespaces z bindingů prefixů, které už loader posbíral při parsování souboru .sch, takže pravidlo napsané proti prefixu, jako ram:, se při každém vyhodnocení správně rozřeší proti vlastnímu jmennému prostoru XML faktury, ne jen v okamžiku, kdy byl soubor pravidel poprvé parsován

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

Jak udržet jednu jednotku překládající se od Delphi 7 až po dnešek

HPDFEInvoiceValidator.pas se musí překládat na každé verzi Delphi, kterou HotPDF podporuje, včetně vydání zcela bez bindingu na XML nebo XPath, takže jeho sekce interface vystavuje jen obyčejné hodnotové typy: záznamy, dynamická pole a jediné rozhraní, IHPDFESchematronEngine, s metodami Load, Validate a LastSummary. Každý typ specifický pro MSXML — IXMLDOMDocument2, import Winapi.msxml, samotné THPDFMSXMLSchematronEngine — sedí uvnitř jediného bloku {$IFDEF XE2+} v sekci implementace, neviditelný jak pro volající, tak pro kompilátor na starších sadách nástrojů

{$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}

Na Delphi 7 a starším HPDFCreateSchematronEngine místo toho vrátí THPDFStubSchematronEngine: jeho Load vždy vrátí False s ErrorText, který pojmenuje skutečnou mezeru — binding na MSXML DOM vyžaduje XE2 nebo novější — a mezitím ukazuje na externí validátor, jako veraPDF, Mustang, nebo nástroj pro konformitu ZUGFeRD, pro plné pokrytí. Jeho Validate vrátí jediný syntetický výsledek s RuleID 'ENGINE' a nastaveným Skipped, takže kód, který prochází BusinessRules, nepotřebuje samostatnou větev pro „engine se nepodařilo spustit" oproti „náhodou bylo přeskočeno úplně každé pravidlo" — obojí vypadá pro volajícího stejně. HPDFValidateEInvoice to elegantně zahrne i do svého verdiktu: booleovská hodnota, kterou vrací, je ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), takže nedostupný engine sníží výsledek na kontrolu jen kontejneru místo toho, aby vynutil tvrdé selhání na kompilátoru, který stejně nikdy neměl pravidla Schematronu spouštět

Engine XPath 1.0 v HPDFEInvoiceValidator nenahrazuje plný procesor Schematron/XSLT 2.0 a nikdy to tak zamýšlené nebylo: engine omezený na XPath 1.0 vždy ponechá hrstku tvrzení EN 16931 nevyhodnocenou, a přesně to je to, co má příznak Skipped u každého výsledku zviditelnit, ne skrýt. Co engine přináší, je zpětná vazba k obchodním pravidlům, která běží kdekoli, kde už běží HotPDF, bez externího procesu, na který by se muselo volat shellem, a bez runtime XSLT 2.0, který by se musel licencovat. Tento engine je součástí komponenty HotPDF pro PDF pro Delphi a C++Builder, spolu s nástroji na úrovni kontejneru pro Factur-X a PDF/A, na kterých staví