HotPDF waliduje reguły biznesowe faktury elektronicznej EN 16931 przez HPDFEInvoiceValidator, silnik asercji Schematron, który biblioteka implementuje samodzielnie na bazie obsługi XPath 1.0 przez MSXML, zamiast licencjonowanego procesora XSLT 2.0. HPDFEInvoiceValidator parsuje oficjalne pliki reguł .sch Factur-X, ocenia każdą asercję, jaką potrafi wyrazić w XPath 1.0, a resztę oznacza jako pominiętą, zamiast pozwolić niewspieranemu wyrażeniu zgłosić wyjątek w połowie przebiegu
Zakres tego artykułu pozostaje wewnątrz tego silnika: jak loader zamienia XML Schematron na wpisy reguł, jak ocena assert i report faktycznie decyduje o powodzeniu lub niepowodzeniu, jak wykrywana i pomijana jest luka XPath 2.0 oraz jak ta sama jednostka nadal kompiluje się na Delphi 7. Osadzanie w PDF/A-3, mechanika kontenera factur-x.xml / xrechnung.xml oraz historia wersjonowania ZUGFeRD 2.5 mieszkają w towarzyszącym artykule o fakturach elektronicznych ZUGFeRD i Factur-X w Delphi z HotPDF, którego ten tekst celowo nie powtarza
Dlaczego HotPDF zbudował własny silnik Schematron EN 16931
HotPDF zbudował własny silnik Schematron, ponieważ deklarowane wiązanie w pliku reguł EN 16931 zawyża to, czego jego asercje faktycznie potrzebują: plik ustawia na górze queryBinding="xslt2", technicznie żądając pełnego procesora XSLT 2.0 / XPath 2.0, ale przeglądanie samych asercji pokazuje, że zdecydowana większość wywołuje wyłącznie funkcje XPath 1.0, takie jak string-length i substring-after. Wbudowany w Windows DOM MSXML — jedyny silnik XML gwarantowany na każdej wspieranej instalacji Delphi bez dodawania zależności zewnętrznej — akurat implementuje dokładnie ten podzbiór, XPath 1.0, co uczyniło natywny silnik praktycznym rozwiązaniem zamiast licencjonowania osobnego środowiska uruchomieniowego XSLT 2.0. HPDFSchematronFileForProfile mapuje wykryty poziom zgodności Factur-X na jeden z pięciu dostarczanych plików reguł, które ten silnik potrafi wczytać — MINIMUM, BASIC WL, BASIC, EN 16931 i EXTENDED — i każdy z nich ocenia wyłącznie wyekstrahowany XML faktury, nigdy otaczający go PDF; to, czy sam ten PDF jest strukturalnie poprawnym plikiem PDF/A-3, to osobne pytanie, na które odpowiadają kontrole zgodności PDF/A, PDF/X i PDF/UA w HotPDF gdzie indziej w bibliotece
Jak silnik zamienia plik .sch na wpisy reguł?
THPDFMSXMLSchematronEngine.Load zaczyna od wywołania CoInitializeEx(nil, COINIT_MULTITHREADED) przed utworzeniem czegokolwiek, ponieważ host konsolowy albo usługowy, który nigdy nie wywołał Application.Initialize, jeszcze nie ma apartamentu COM, podczas gdy host GUI VCL już go ma; silnik traktuje wynik S_FALSE albo RPC_E_CHANGED_MODE, jaki to wywołanie może zwrócić na wątku już będącym wewnątrz apartamentu, jako równie dobry, a nie jako błąd. Następnie parsuje plik Schematron dokumentem DOM MSXML 6.0 (CoDOMDocument60) i setProperty('SelectionLanguage', 'XPath'), ponieważ MSXML domyślnie używa swojego starszego dialektu XSL-Pattern, chyba że wywołujący jawnie wybierze XPath. Od tego momentu loader nigdy jednak nie wywołuje selectNodes, żeby przejść przez własną strukturę pliku .sch — każdy element <pattern>, <rule>, <assert> i <report> jest znajdowany przez ręczne przechodzenie firstChild / nextSibling, porównując lokalną nazwę i URI przestrzeni nazw każdego węzła z dosłownym ciągiem http://purl.oclc.org/dsdl/schematron
To podejście ręcznego przechodzenia istnieje z powodu problemu jajka i kury w wiązaniach <ns prefix="ram" uri="..."/>, które każdy plik Schematron Factur-X deklaruje z góry. Rozwiązanie wyrażenia XPath z prefiksem, takiego jak ram:Name, względem tych wiązań wymaga, aby właściwość SelectionNamespaces MSXML już je zawierała, ale odkrycie tych wiązań w pierwszej kolejności normalnie oznaczałoby uruchomienie zapytania XPath, takiego jak //ns:ns — które samo w sobie potrzebuje najpierw ustawionego SelectionNamespaces. HPDFEInvoiceValidator przerywa ten cykl, zbierając każdy element <ns> przez to samo ręczne przechodzenie węzłów potomnych, zanim w ogóle dotknie selectNodes, a następnie zwija zebrane pary prefiks/URI w jeden ciąg SelectionNamespaces, który ponownie wykorzystuje zarówno przejście struktury .sch, jak i każda późniejsza ocena reguł
// 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 kontra report: co faktycznie wyzwala naruszenie?
Schematron nadaje assert i report przeciwną polaryzację, a silnik musi zachować to rozróżnienie dokładnie, inaczej liczniki naruszeń nic nie znaczą. <assert test="X"> deklaruje, że X musi zachodzić dla każdego węzła pasującego do ścieżki kontekstu reguły, więc EvaluateAssert odnotowuje naruszenie, gdy zbiór węzłów wynikowych wyrażenia testowego wraca pusty; <report test="X"> to lustrzane odbicie, oznaczające problem, gdy X jest prawdziwe, więc EvaluateReport odnotowuje naruszenie zamiast tego, gdy wynik testu jest niepusty. Oba punkty wejścia dzielą pod spodem ten sam dwuetapowy kształt — najpierw Doc.selectNodes(Entry.Context), żeby znaleźć każdy węzeł, do którego reguła się stosuje, potem ContextNode.selectNodes(Entry.Test) względem każdego z nich po kolei — co jest dokładnie modelem kontekst-potem-test, jakiego używa prawdziwy procesor Schematron, tyle że sterowanym przez selectNodes XPath 1.0 z MSXML zamiast silnika wykonawczego świadomego Schematron
Jak silnik pomija XPath 2.0 bez zawieszania przebiegu?
HPDFEInvoiceValidator broni się przed niewspieraną składnią XPath 2.0 w dwóch warstwach, a pierwsza nigdy nie pozwala MSXML w ogóle zobaczyć wyrażenia. Przed oceną jakiegokolwiek assert lub report, XPath2Detected skanuje surowy ciąg wyrażenia testowego pod kątem sześciu dosłownych tokenów — xs:decimal, xs:integer, xs:string, upper-case, lower-case i exists( — i jeśli którykolwiek z nich występuje, reguła jest natychmiast oznaczana jako Skipped z ważnością stsInfo, na zasadzie, że MSXML nigdy nie powinno dostać wyrażenia, które i tak wiadomo, że odrzuci
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;
Druga warstwa łapie to, co przeoczy statyczna lista tokenów. Zarówno wywołanie selectNodes dla kontekstu, jak i wywołanie selectNodes dla testu na poszczególny węzeł działają wewnątrz bloku try/except; gdy MSXML zgłasza wyjątek na wyrażeniu, które przepuściło skanowanie tokenów — konstrukcja spoza sześciu znanych tokenów albo ścieżka kontekstu, której nie potrafi rozwiązać — wyjątek jest przechwytywany, a reguła zapisywana jako Skipped, zamiast propagować się do wywołującego. Ten dwuwarstwowy projekt jest powodem, dla którego konstrukcja XPath 2.0 gdziekolwiek w zestawie reguł nigdy nie przebija się przez HPDFValidateEInvoice: każda z jej 424 asercji albo jest oceniana, albo zawodzi, albo zostaje oznaczona jako pominięta, a wewnętrzny szacunek dla tego pliku reguł umieścił udział wykonywalny w XPath 1.0 na poziomie mniej więcej 350 z 424 — wystarczająco dużo, żeby częściowa ocena była warta wykonania zamiast cofania się do sprawdzenia samego kontenera w chwili, gdy pojawi się choćby jedna asercja XPath 2.0
Podawanie XML faktury w UTF-8 do MSXML bez jego zniekształcania
THPDFMSXMLSchematronEngine.Validate nie przekazuje wyekstrahowanych bajtów faktury do IXMLDOMDocument.loadXML, ponieważ ta metoda oczekuje BSTR — UTF-16 — i zinterpretowałaby na nowo surową tablicę bajtów UTF-8 przy tym założeniu, niezależnie od tego, co mówi własna deklaracja <?xml encoding="UTF-8"?> dokumentu. HPDFEInvoiceValidator zamiast tego kopiuje bajty do HGLOBAL zaalokowanego przez GlobalAlloc, opakowuje go w IStream przez CreateStreamOnHGlobal i wczytuje ten strumień przez IPersistStreamInit.Load, ścieżkę, którą MSXML honoruje, odczytując deklarację kodowania z samego strumienia bajtów, zamiast z góry zakładać UTF-16. Ta sama metoda odbudowuje SelectionNamespaces z wiązań prefiksów, które loader już zebrał podczas parsowania pliku .sch, więc reguła napisana względem prefiksu takiego jak ram: rozwiązuje się poprawnie względem własnej przestrzeni nazw XML faktury przy każdej ocenie, nie tylko wtedy, gdy plik reguł był po raz pierwszy parsowany
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
Utrzymanie kompilowania jednej jednostki od Delphi 7 do dziś
HPDFEInvoiceValidator.pas musi kompilować się na każdej wersji Delphi, jaką wspiera HotPDF, w tym na wydaniach zupełnie bez wiązania XML czy XPath, więc jego sekcja interfejsu udostępnia wyłącznie zwykłe typy wartościowe: rekordy, tablice dynamiczne i jeden interfejs, IHPDFESchematronEngine, z metodami Load, Validate i LastSummary. Każdy typ specyficzny dla MSXML — IXMLDOMDocument2, import Winapi.msxml, sama THPDFMSXMLSchematronEngine — siedzi wewnątrz jednego bloku {$IFDEF XE2+} w sekcji implementacji, niewidoczny zarówno dla wywołujących, jak i dla kompilatora na starszych zestawach narzędzi
{$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 i starszych HPDFCreateSchematronEngine zwraca zamiast tego THPDFStubSchematronEngine: jego Load zawsze zwraca False z ErrorText, który wskazuje na rzeczywistą lukę — wiązanie DOM MSXML wymaga XE2 lub nowszego — i kieruje w międzyczasie ku zewnętrznemu walidatorowi, takiemu jak veraPDF, Mustang albo narzędzie zgodności ZUGFeRD, dla pełnego pokrycia. Jego Validate zwraca pojedynczy syntetyczny wynik z RuleID 'ENGINE' i ustawionym Skipped, więc kod, który iteruje po BusinessRules, nie potrzebuje osobnej gałęzi dla „silnik nie mógł zostać uruchomiony" w odróżnieniu od „każda reguła akurat została pominięta" — obie sytuacje wyglądają dla wywołującego tak samo. HPDFValidateEInvoice zwija to płynnie także w swój werdykt: zwracana wartość logiczna to ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), więc niedostępny silnik degraduje wynik do sprawdzenia samego kontenera zamiast wymuszać twarde niepowodzenie na kompilatorze, który i tak nigdy nie miał uruchamiać reguł Schematron
Silnik XPath 1.0 w HPDFEInvoiceValidator nie zastępuje pełnego procesora Schematron/XSLT 2.0 i nigdy nie miał tego robić: silnik ograniczony do XPath 1.0 zawsze pozostawi garść asercji EN 16931 nieocenionych, co jest dokładnie tym, do czego istnieje flaga Skipped przy każdym wyniku — żeby to uwidocznić, a nie ukryć. To, co silnik faktycznie daje, to informacja zwrotna o regułach biznesowych działająca wszędzie tam, gdzie już działa HotPDF, bez zewnętrznego procesu do uruchomienia i bez licencjonowania środowiska uruchomieniowego XSLT 2.0. Ten silnik jest dostarczany jako część komponentu PDF HotPDF dla Delphi i C++Buildera, obok narzędzi Factur-X i PDF/A na poziomie kontenera, na których się opiera