Технічна стаття

Механізм правил Schematron EN 16931 у Delphi з HotPDF

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

Обсяг тут обмежується самим цим рушієм: як завантажувач перетворює XML Schematron на записи правил, як обчислення 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. Вбудований DOM MSXML у Windows — єдиний 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, тоді як GUI-хост на 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

Рушій XPath 1.0 у HPDFEInvoiceValidator не замінює повноцінний процесор Schematron/XSLT 2.0, і він ніколи не мав цього робити: рушій, обмежений XPath 1.0, завжди залишить кілька тверджень EN 16931 неоціненими, і саме для того, щоб зробити це видимим, а не приховати, і існує прапорець Skipped на кожному результаті. Що рушій справді дає — це зворотний зв'язок за бізнес-правилами, що працює будь-де, де вже працює HotPDF, без зовнішнього процесу для виклику через shell і без потреби ліцензувати середовище виконання XSLT 2.0. Цей рушій постачається як частина компонента HotPDF для Delphi та C++Builder, поряд з інструментарієм рівня контейнера для Factur-X та PDF/A, на якому він будується