HotPDF проверяет бизнес-правила электронных счетов-фактур EN 16931 через HPDFEInvoiceValidator — движок утверждений Schematron, который библиотека реализует самостоятельно поверх поддержки XPath 1.0 в MSXML, а не через лицензированный процессор XSLT 2.0. HPDFEInvoiceValidator разбирает официальные файлы правил Factur-X .sch, вычисляет каждое утверждение, которое может выразить в 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. Встроенный в 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, тогда как 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, поэтому в его секции interface представлены только простые типы значений: записи, динамические массивы и единственный интерфейс, IHPDFESchematronEngine, с методами Load, Validate и LastSummary. Каждый тип, специфичный для MSXML, — IXMLDOMDocument2, импорт Winapi.msxml, сам THPDFMSXMLSchematronEngine — находится внутри одного блока {$IFDEF XE2+} в секции implementation, невидимого как для вызывающего кода, так и для компилятора на более старых версиях инструментария
{$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, без внешнего процесса для запуска и без необходимости лицензировать среду выполнения XSLT 2.0. Этот движок поставляется как часть компонента PDF HotPDF для Delphi и C++Builder, наряду с инструментарием уровня контейнера для Factur-X и PDF/A, на котором он построен