Техническа статия

Двигател за Schematron правила EN 16931 в Delphi с HotPDF

HotPDF валидира бизнес правилата за електронни фактури EN 16931 чрез HPDFEInvoiceValidator, двигател за Schematron твърдения, който библиотеката реализира самостоятелно върху поддръжката на MSXML за XPath 1.0, вместо върху лицензиран процесор за XSLT 2.0. HPDFEInvoiceValidator анализира официалните .sch файлове с правила на Factur-X, оценява всяко твърдение, което може да изрази чрез XPath 1.0, и отбелязва останалите като пропуснати, вместо неподдържано изражение да предизвика изключение по средата на изпълнението

Тук обхватът остава в рамките на този двигател: как зареждащият код превръща Schematron XML в записи на правила, как оценяването на assert и report действително решава успех или неуспех, как се открива и пропуска разликата при XPath 2.0 и как същият модул се компилира и на Delphi 7. Вграждането на PDF/A-3, механизмите на контейнерите factur-x.xml / xrechnung.xml и историята на версиите на ZUGFeRD 2.5 са разгледани в съпътстващата статия за електронни фактури ZUGFeRD и Factur-X в Delphi с HotPDF, което тази статия умишлено не повтаря

Защо HotPDF изгради собствен двигател за Schematron EN 16931

HotPDF изгради собствен двигател за Schematron, защото декларираната обвързаност на файла с правила EN 16931 надценява реалните изисквания на неговите твърдения: файлът задава queryBinding="xslt2" най-отгоре и технически изисква пълен процесор за XSLT 2.0 / XPath 2.0, но прегледът на самите твърдения показва, че огромното мнозинство извиква само функции на XPath 1.0 като string-length и substring-after. Вграденият в Windows DOM на MSXML — единственият XML двигател, за който е гарантирано, че съществува във всяка поддържана инсталация на Delphi без добавяне на зависимост от трета страна — реализира точно това подмножество, XPath 1.0, което прави собствения двигател практичен, вместо да се лицензира отделна среда за изпълнение на XSLT 2.0. HPDFSchematronFileForProfile свързва откритото ниво на съответствие на Factur-X с един от петте доставени файла с правила, които този двигател може да зареди — MINIMUM, BASIC WL, BASIC, EN 16931 и EXTENDED — и всеки от тях оценява само извлечения XML на фактурата, никога заобикалящия PDF; дали самият PDF е структурно валиден PDF/A-3 е отделен въпрос, на който отговарят проверките за съответствие на PDF/A, PDF/X и PDF/UA на HotPDF на друго място в библиотеката

Как двигателят превръща .sch файл в записи на правила

THPDFMSXMLSchematronEngine.Load започва, като извиква CoInitializeEx(nil, COINIT_MULTITHREADED) преди създаването на каквото и да било, защото конзолен или сервизен хост, който никога не е извиквал Application.Initialize, все още няма COM апартамент, докато графичен VCL хост вече има; двигателят приема резултатите S_FALSE или RPC_E_CHANGED_MODE, които това извикване може да върне на нишка, вече намираща се в апартамент, като еднакво приемливи, а не като грешка. След това анализира Schematron файла с DOM документ на MSXML 6.0 (CoDOMDocument60) и setProperty('SelectionLanguage', 'XPath'), тъй като MSXML използва по-стария си диалект XSL-Pattern по подразбиране, освен ако извикващият изрично не избере XPath. Оттам зареждащият код никога не извиква selectNodes, за да обхожда собствената структура на .sch файла — всеки елемент <pattern>, <rule>, <assert> и <report> се намира чрез ръчно обхождане на firstChild / nextSibling, като се сравняват локалното име на възела и URI на пространството от имена с буквалния низ http://purl.oclc.org/dsdl/schematron

Този подход с ръчно обхождане съществува заради проблем тип „кое е първо“ при обвързванията <ns prefix="ram" uri="..."/>, които всеки Schematron файл на Factur-X декларира предварително. Разрешаването на представен с префикс XPath израз като ram:Name спрямо тези обвързвания изисква свойството SelectionNamespaces на MSXML вече да ги съдържа, но откриването на самите обвързвания обикновено би означавало изпълнение на XPath заявка като //ns:ns, която също изисква предварително зададено SelectionNamespaces. HPDFEInvoiceValidator прекъсва този цикъл, като събира всеки елемент <ns> чрез същото ръчно обхождане на дъщерните възли, преди изобщо да докосне selectNodes, след което обединява събраните двойки префикс/URI в един низ SelectionNamespaces, който се използва повторно и при обхождането на .sch структурата, и при всяка последваща оценка на правило

// 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 срещу report: какво действително задейства нарушение

Schematron дава на assert и report противоположна полярност и двигателят трябва да запази точно това различие, иначе броят на нарушенията няма смисъл. Елемент <assert test="X"> заявява, че X трябва да е изпълнено за всеки възел, съвпадащ с контекстния път на правилото, затова EvaluateAssert записва нарушение, когато наборът от възли, върнат от тестовия израз, е празен; елемент <report test="X"> е огледалната форма и сигнализира проблем, когато X е вярно, затова EvaluateReport записва нарушение, когато резултатът от теста не е празен. И двете входни точки споделят една и съща двустепенна форма — първо Doc.selectNodes(Entry.Context), за да се намери всеки възел, към който се прилага правилото, след това ContextNode.selectNodes(Entry.Test) върху всеки от тях — точно модела „контекст и след това тест“, който използва реалният процесор на Schematron, но задвижван от selectNodes на XPath 1.0 на MSXML, вместо от двигател за изпълнение, разбиращ Schematron

Как двигателят пропуска XPath 2.0, без да прекъсне изпълнението

HPDFEInvoiceValidator се защитава от неподдържан синтаксис на XPath 2.0 на два слоя, като първият изобщо не позволява на MSXML да види израза. Преди оценяването на всеки assert или report XPath2Detected проверява суровия низ на тестовия израз за шест буквални маркера — xs:decimal, xs:integer, xs:string, upper-case, lower-case и exists( — и ако някой присъства, правилото незабавно се отбелязва като Skipped със сериозност stsInfo, тъй като MSXML никога не трябва да получава израз, за който вече е известно, че ще отхвърли

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;

Вторият слой улавя всичко, което статичният списък с маркери пропуска. Извикването на selectNodes за контекста и извикването на selectNodes за теста на всеки възел се изпълняват в блок try/except; когато MSXML повдигне грешка за израз, който проверката на маркерите е пропуснала — конструкция извън шестте известни маркера или контекстен път, който не може да разреши — изключението се прихваща и правилото се записва като Skipped, вместо да се предаде на извикващия. Тази двуслойна конструкция е причината конструкция на XPath 2.0 навсякъде в набора от правила никога да не излиза извън HPDFValidateEInvoice: всяко от неговите 424 твърдения се оценява, проваля се или се отбелязва като пропуснато, а вътрешна оценка за този файл с правила поставя дела на изпълнимото чрез XPath 1.0 на приблизително 350 от 424 — достатъчно, за да си струва частичната оценка, вместо при първото XPath 2.0 твърдение да се преминава към проверка само на контейнера

Подаване на UTF-8 XML на фактура към MSXML без повреда

THPDFMSXMLSchematronEngine.Validate не подава извлечените байтове на фактурата към IXMLDOMDocument.loadXML, защото този метод очаква BSTR — UTF-16 — и би интерпретирал суров масив от UTF-8 байтове с това предположение, независимо какво казва собствената декларация <?xml encoding="UTF-8"?> на документа. Вместо това HPDFEInvoiceValidator копира байтовете в HGLOBAL, заделен чрез GlobalAlloc, обвива го в IStream чрез CreateStreamOnHGlobal и зарежда потока чрез IPersistStreamInit.Load, път, който MSXML уважава, като прочита декларацията за кодиране от самия байтов поток, вместо предварително да приема UTF-16. Същият метод изгражда отново SelectionNamespaces от обвързванията на префиксите, събрани от зареждащия код при анализа на .sch файла, така че правило, написано с префикс като ram:, се разрешава правилно спрямо собственото пространство от имена на XML на фактурата при всяка оценка, а не само когато файлът с правила е бил анализиран за първи път

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

Поддържане на един модул, компилиращ се от Delphi 7 до днес

HPDFEInvoiceValidator.pas трябва да се компилира с всяка версия на Delphi, която HotPDF поддържа, включително издания без никакво XML или XPath свързване, затова неговата интерфейсна секция излага само обикновени типове за стойности: записи, динамични масиви и един интерфейс, IHPDFESchematronEngine, с методи Load, Validate и LastSummary. Всеки тип, специфичен за MSXML — IXMLDOMDocument2, импортът Winapi.msxml, самият THPDFMSXMLSchematronEngine — се намира в един блок {$IFDEF XE2+} в секцията за реализация, невидим едновременно за извикващите и за компилатора при по-старите инструментални вериги

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

При Delphi 7 и по-старите версии HPDFCreateSchematronEngine вместо това връща THPDFStubSchematronEngine: неговият Load винаги връща False с ErrorText, който назовава действителната разлика — обвързването с DOM на MSXML изисква XE2 или по-нова версия — и насочва към външен валидатор като veraPDF, Mustang или инструмент за съответствие със ZUGFeRD за пълно покритие дотогава. Неговият Validate връща един синтетичен резултат с RuleID 'ENGINE' и зададено Skipped, така че кодът, който обхожда BusinessRules, не се нуждае от отделен клон за „двигателят не можа да се изпълни“ спрямо „всяко правило случайно е било пропуснато“ — и двете изглеждат по един и същ начин за извикващия. HPDFValidateEInvoice включва това плавно и в своята присъда: булевата стойност, която връща, е ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), така че недостъпен двигател понижава резултата до проверка само на контейнера, вместо да налага твърд неуспех на компилатор, който така или иначе няма да изпълни Schematron правилата

Двигателят на HPDFEInvoiceValidator за XPath 1.0 не заменя пълен процесор за Schematron/XSLT 2.0 и никога не е бил предназначен за това: двигател, ограничен до XPath 1.0, винаги ще остави няколко твърдения на EN 16931 без оценка, точно затова флагът Skipped във всеки резултат ги прави видими, вместо да ги скрива. Ползата е обратна връзка за бизнес правилата, която работи навсякъде, където вече работи HotPDF, без външен процес, който да се стартира, и без лицензиране на среда за изпълнение на XSLT 2.0. Този двигател се доставя като част от HotPDF PDF component for Delphi and C++Builder, наред с инструментите за контейнерите Factur-X и PDF/A, върху които се основава