Tehnički članak

Motor pravila EN 16931 Schematron u Delphiju uz HotPDF

HotPDF validira poslovna pravila EN 16931 za elektroničke račune kroz HPDFEInvoiceValidator, Schematron motor za provjeru tvrdnji (assertion) koji biblioteka sama implementira na temelju MSXML podrške za XPath 1.0, umjesto licenciranog XSLT 2.0 procesora. HPDFEInvoiceValidator raščlanjuje službene Factur-X .sch datoteke pravila, procjenjuje svaku tvrdnju koju može izraziti u XPath 1.0, a ostale označava kao preskočene umjesto da dopusti da nepodržani izraz izbaci iznimku usred izvođenja

Opseg ovog članka ostaje unutar tog motora: kako učitavač pretvara Schematron XML u unose pravila, kako procjena assert i report elemenata zapravo odlučuje o prolazu ili neuspjehu, kako se otkriva i preskače nedostatak podrške za XPath 2.0 te kako ista jedinica i dalje kompilira u Delphiju 7. Ugrađivanje u PDF/A-3, mehanika kontejnera factur-x.xml / xrechnung.xml i priča o verzioniranju ZUGFeRD 2.5 opisani su u pratećem članku o ZUGFeRD i Factur-X e-računima u Delphiju uz HotPDF, koji ovaj tekst namjerno ne ponavlja

Zašto je HotPDF izgradio vlastiti motor za EN 16931 Schematron

HotPDF je izgradio vlastiti Schematron motor jer deklarirano povezivanje u datoteci pravila EN 16931 pretjeruje s onim što njezine tvrdnje zapravo trebaju: datoteka na vrhu postavlja queryBinding="xslt2", tehnički zahtijevajući puni XSLT 2.0 / XPath 2.0 procesor, no čitanje samih tvrdnji pokazuje da velika većina poziva samo XPath 1.0 funkcije poput string-length i substring-after. Windowsov ugrađeni MSXML DOM — jedini XML motor koji je zajamčeno prisutan na svakoj podržanoj Delphi instalaciji bez dodavanja ovisnosti o trećoj strani — slučajno implementira upravo taj podskup, XPath 1.0, što je i omogućilo praktičnost izvornog motora umjesto licenciranja zasebnog XSLT 2.0 runtime okruženja. HPDFSchematronFileForProfile preslikava otkrivenu razinu Factur-X sukladnosti na jednu od pet isporučenih datoteka pravila koje ovaj motor može učitati — MINIMUM, BASIC WL, BASIC, EN 16931 i EXTENDED — a svaka od njih ocjenjuje samo izdvojeni XML računa, nikad okolni PDF; je li taj PDF sam po sebi strukturno valjana PDF/A-3 datoteka zasebno je pitanje na koje odgovaraju HotPDF-ove provjere sukladnosti PDF/A, PDF/X i PDF/UA drugdje u biblioteci

Kako motor pretvara .sch datoteku u unose pravila?

THPDFMSXMLSchematronEngine.Load počinje pozivom CoInitializeEx(nil, COINIT_MULTITHREADED) prije nego što išta kreira, jer konzolni ili uslužni proces koji nikad nije pozvao Application.Initialize još nema COM apartman, dok ga GUI VCL host već ima; motor rezultate S_FALSE ili RPC_E_CHANGED_MODE, koje taj poziv može vratiti na dretvi koja je već unutar apartmana, tretira kao jednako ispravne, a ne kao pogrešku. Zatim raščlanjuje Schematron datoteku pomoću MSXML 6.0 DOM dokumenta (CoDOMDocument60) i poziva setProperty('SelectionLanguage', 'XPath'), jer MSXML prema zadanim postavkama koristi svoje starije narječje XSL-Pattern osim ako se pozivatelj izričito ne odluči za XPath. No od te točke nadalje učitavač nikad ne poziva selectNodes za prolaz kroz vlastitu strukturu .sch datoteke — svaki element <pattern>, <rule>, <assert> i <report> pronalazi se ručnim prolaskom kroz firstChild / nextSibling, uspoređujući lokalno ime svakog čvora i URI imenskog prostora s doslovnim nizom http://purl.oclc.org/dsdl/schematron

Taj pristup ručnog prolaska postoji zbog problema kokoš-ili-jaje u vezanjima <ns prefix="ram" uri="..."/> koje svaka Factur-X Schematron datoteka deklarira na početku. Razrješavanje XPath izraza s prefiksom, poput ram:Name, u odnosu na ta vezanja zahtijeva da MSXML-ovo svojstvo SelectionNamespaces već sadrži ta vezanja, no otkrivanje samih vezanja obično bi značilo pokretanje XPath upita poput //ns:ns — koji sam zahtijeva da SelectionNamespaces prvo bude postavljen. HPDFEInvoiceValidator razbija taj krug tako da prikuplja svaki element <ns> istim ručnim prolaskom kroz podređene čvorove prije nego što uopće dotakne selectNodes, a zatim prikupljene parove prefiks/URI slaže u jedan niz SelectionNamespaces koji ponovno koriste i prolazak kroz strukturu .sch datoteke i svaka kasnija procjena pravila

// 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 naspram report: što zapravo pokreće kršenje?

Schematron daje elementima assert i report suprotan polaritet, a motor mora tu razliku sačuvati potpuno točno, inače njegovo brojanje kršenja ništa ne znači. <assert test="X"> deklarira da X mora vrijediti za svaki čvor koji odgovara kontekstnoj putanji pravila, pa EvaluateAssert bilježi kršenje kad se skup čvorova rezultata izraza testa vrati prazan; <report test="X"> je zrcalna slika, označavajući problem kad je X istinit, pa EvaluateReport umjesto toga bilježi kršenje kad rezultat testa nije prazan. Obje ulazne točke dijele isti dvostupanjski oblik ispod površine — najprije Doc.selectNodes(Entry.Context), kako bi se pronašao svaki čvor na koji se pravilo odnosi, a zatim ContextNode.selectNodes(Entry.Test) nad svakim od njih zauzvrat — što je točno model kontekst-pa-test koji koristi pravi Schematron procesor, samo pokretan MSXML-ovim XPath 1.0 pozivom selectNodes umjesto motorom za izvođenje svjesnim Schematron formata

Kako motor preskače XPath 2.0 bez rušenja izvođenja?

HPDFEInvoiceValidator se brani od nepodržane XPath 2.0 sintakse u dva sloja, a prvi MSXML-u uopće ne dopušta da vidi izraz. Prije procjene bilo kojeg assert ili report elementa, XPath2Detected pretražuje sirovi niz izraza testa za šest doslovnih tokena — xs:decimal, xs:integer, xs:string, upper-case, lower-case i exists( — i ako je bilo koji od njih prisutan, pravilo se odmah označava kao Skipped s razinom ozbiljnosti stsInfo, uz obrazloženje da MSXML-u nikad ne bi trebalo predati izraz za koji se već zna da će ga odbaciti

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;

Drugi sloj hvata sve što statički popis tokena propusti. I poziv selectNodes za kontekst i poziv selectNodes za test po čvoru izvode se unutar bloka try/except; kad MSXML izbaci iznimku na izrazu koji je provjera tokena propustila — konstrukciju izvan šest poznatih tokena ili kontekstnu putanju koju ne može razriješiti — iznimka se hvata i pravilo se bilježi kao Skipped umjesto da se proslijedi pozivatelju. Taj dvoslojni dizajn razlog je zašto XPath 2.0 konstrukcija bilo gdje u skupu pravila nikad ne izbaci iznimku mimo HPDFValidateEInvoice: svaka od njegovih 424 tvrdnji se ili procjenjuje, ne uspijeva, ili se označava kao preskočena, a interna procjena nad tom datotekom pravila procijenila je udio izvediv u XPath-u 1.0 na otprilike 350 od 424 — dovoljno da se djelomična procjena isplati provesti, umjesto da se prijeđe na provjeru samo kontejnera čim se pojavi jedna jedina XPath 2.0 tvrdnja

Predaja UTF-8 XML-a računa u MSXML bez oštećivanja sadržaja

THPDFMSXMLSchematronEngine.Validate ne predaje izdvojene bajtove računa funkciji IXMLDOMDocument.loadXML, jer ta metoda očekuje BSTR — UTF-16 — i pod tom bi pretpostavkom pogrešno protumačila sirovo UTF-8 polje bajtova, bez obzira na to što kaže dokumentova vlastita deklaracija <?xml encoding="UTF-8"?>. HPDFEInvoiceValidator umjesto toga kopira bajtove u HGLOBAL alociran pomoću GlobalAlloc, omata ga u IStream preko CreateStreamOnHGlobal i taj tok učitava putem IPersistStreamInit.Load — putanje koju MSXML poštuje čitajući deklaraciju kodiranja iz samog bajtovnog toka, umjesto da odmah pretpostavi UTF-16. Ista metoda ponovno gradi SelectionNamespaces iz vezanja prefiksa koje je učitavač već prikupio pri raščlambi .sch datoteke, pa se pravilo napisano nad prefiksom poput ram: ispravno razrješava u odnosu na vlastiti imenski prostor XML-a računa pri svakoj procjeni, ne samo kad je datoteka pravila prvi put raščlanjena

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

Kako ista jedinica kompilira od Delphija 7 do danas

HPDFEInvoiceValidator.pas mora kompilirati na svakoj verziji Delphija koju HotPDF podržava, uključujući izdanja bez ikakvog XML ili XPath povezivanja, pa njegov interface odjeljak izlaže samo obične tipove vrijednosti: zapise, dinamička polja i jedno jedino sučelje, IHPDFESchematronEngine, s metodama Load, Validate i LastSummary. Svaki tip specifičan za MSXML — IXMLDOMDocument2, uvoz Winapi.msxml, sama klasa THPDFMSXMLSchematronEngine — smješten je unutar jednog bloka {$IFDEF XE2+} u implementation odjeljku, nevidljiv i pozivateljima i kompajleru na starijim alatnim lancima

{$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 Delphiju 7 i starijim verzijama, HPDFCreateSchematronEngine umjesto toga vraća THPDFStubSchematronEngine: njegov Load uvijek vraća False s porukom ErrorText koja imenuje stvarni nedostatak — MSXML DOM povezivanje zahtijeva XE2 ili noviju verziju — i u međuvremenu upućuje na vanjski validator poput veraPDF-a, Mustanga ili alata za provjeru ZUGFeRD sukladnosti radi potpunog pokrivanja. Njegov Validate vraća jedan jedini umjetan rezultat s vrijednošću RuleID 'ENGINE' i postavljenim Skipped, pa kôd koji prolazi kroz BusinessRules ne treba zasebnu granu za „motor nije mogao raditi" naspram „sva su pravila slučajno preskočena" — oboje pozivatelju izgleda jednako. HPDFValidateEInvoice to elegantno uklapa i u svoju konačnu ocjenu: booleovska vrijednost koju vraća jest ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), pa nedostupan motor rezultat degradira na provjeru samo kontejnera, umjesto da nametne tvrd neuspjeh na kompajleru koji nikad i nije trebao pokretati Schematron pravila

HotPDF-ov HPDFEInvoiceValidator XPath 1.0 motor ne zamjenjuje puni Schematron/XSLT 2.0 procesor, i to nikad nije bila njegova namjena: motor ograničen na XPath 1.0 uvijek će ostaviti šačicu EN 16931 tvrdnji neprocijenjenima, a upravo je to ono što oznaka Skipped uz svaki rezultat postoji učiniti vidljivim, a ne skrivenim. Ono što motor donosi jest povratna informacija o poslovnim pravilima koja radi posvuda gdje HotPDF već radi, bez vanjskog procesa kojeg treba pokretati i bez XSLT 2.0 runtime okruženja koje treba licencirati. Ovaj motor isporučuje se kao dio HotPDF PDF komponente za Delphi i C++Builder, uz alate za Factur-X i PDF/A na razini kontejnera na kojima se temelji