Article technique

Moteur de règles Schematron EN 16931 en Delphi avec HotPDF

HotPDF valide les règles métier de facturation électronique EN 16931 via HPDFEInvoiceValidator, un moteur d'assertions Schematron que la bibliothèque implémente elle-même par-dessus le support XPath 1.0 de MSXML plutôt que via un processeur XSLT 2.0 sous licence. HPDFEInvoiceValidator analyse les fichiers de règles .sch officiels de Factur-X, évalue chaque assertion qu'il peut exprimer en XPath 1.0, et marque le reste comme ignoré au lieu de laisser une expression non prise en charge lever une exception en cours d'exécution

La portée de cet article reste à l'intérieur de ce moteur : comment le chargeur transforme le XML Schematron en entrées de règles, comment l'évaluation assert et report décide réellement de la réussite ou de l'échec, comment la lacune XPath 2.0 est détectée et contournée, et comment la même unité continue de compiler sur Delphi 7. L'intégration PDF/A-3, les mécanismes de conteneur factur-x.xml / xrechnung.xml, et l'histoire de versionnage ZUGFeRD 2.5 figurent dans l'article compagnon sur les factures électroniques ZUGFeRD et Factur-X en Delphi avec HotPDF, que cet article ne répète volontairement pas

Pourquoi HotPDF a construit son propre moteur Schematron EN 16931

HotPDF a construit son propre moteur Schematron parce que la liaison déclarée du fichier de règles EN 16931 exagère ce dont ses assertions ont réellement besoin : le fichier définit queryBinding="xslt2" en tête, demandant techniquement un processeur XSLT 2.0 / XPath 2.0 complet, mais la lecture des assertions elles-mêmes montre que la grande majorité n'appelle que des fonctions XPath 1.0 telles que string-length et substring-after. Le DOM MSXML intégré à Windows — le seul moteur XML garanti présent sur chaque installation Delphi prise en charge sans ajouter de dépendance tierce — implémente précisément ce sous-ensemble, XPath 1.0, ce qui a rendu un moteur natif réalisable plutôt que de devoir concéder sous licence un runtime XSLT 2.0 séparé. HPDFSchematronFileForProfile associe un niveau de conformité Factur-X détecté à l'un des cinq fichiers de règles fournis que ce moteur peut charger — MINIMUM, BASIC WL, BASIC, EN 16931, et EXTENDED — et chacun d'entre eux ne juge que le XML de facture extrait, jamais le PDF environnant ; savoir si ce PDF est lui-même un fichier PDF/A-3 structurellement valide est une question distincte, traitée via les vérifications de conformité PDF/A, PDF/X et PDF/UA de HotPDF ailleurs dans la bibliothèque

Comment le moteur transforme-t-il un fichier .sch en entrées de règles ?

THPDFMSXMLSchematronEngine.Load commence par appeler CoInitializeEx(nil, COINIT_MULTITHREADED) avant de créer quoi que ce soit, car un hôte console ou service qui n'a jamais appelé Application.Initialize n'a pas encore d'appartement COM, alors qu'un hôte VCL graphique en a déjà un ; le moteur traite le résultat S_FALSE ou RPC_E_CHANGED_MODE que cet appel peut renvoyer sur un thread déjà à l'intérieur d'un appartement comme tout aussi correct plutôt que comme une erreur. Il analyse ensuite le fichier Schematron avec un document DOM MSXML 6.0 (CoDOMDocument60) et setProperty('SelectionLanguage', 'XPath'), car MSXML utilise par défaut son ancien dialecte XSL-Pattern à moins qu'un appelant n'opte explicitement pour XPath. À partir de là, cependant, le chargeur n'appelle jamais selectNodes pour parcourir la structure propre du fichier .sch — chaque élément <pattern>, <rule>, <assert>, et <report> est trouvé en parcourant firstChild / nextSibling à la main, en comparant le nom local et l'URI d'espace de noms de chaque nœud à la chaîne littérale http://purl.oclc.org/dsdl/schematron

Cette approche de parcours manuel existe à cause d'un problème de l'œuf et de la poule dans les liaisons <ns prefix="ram" uri="..."/> que chaque fichier Schematron Factur-X déclare en tête. Résoudre une expression XPath préfixée comme ram:Name par rapport à ces liaisons exige que la propriété SelectionNamespaces de MSXML les contienne déjà, mais découvrir les liaisons en premier lieu signifierait normalement exécuter une requête XPath telle que //ns:ns — qui elle-même a besoin que SelectionNamespaces soit déjà définie. HPDFEInvoiceValidator brise ce cycle en collectant chaque élément <ns> via le même parcours manuel des nœuds enfants avant même de toucher à selectNodes, puis fond les paires préfixe/URI récoltées en une seule chaîne SelectionNamespaces que le parcours de structure .sch et chaque évaluation de règle ultérieure réutilisent

// 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 contre report : qu'est-ce qui déclenche réellement une violation ?

Schematron donne à assert et report une polarité opposée, et le moteur doit préserver cette distinction exactement, sans quoi ses décomptes de violations ne signifient rien. Un <assert test="X"> déclare que X doit être vrai pour chaque nœud correspondant au chemin de contexte de la règle, si bien qu'EvaluateAssert enregistre une violation lorsque l'ensemble de nœuds résultat de l'expression de test revient vide ; un <report test="X"> en est l'image miroir, signalant un problème lorsque X est vrai, si bien qu'EvaluateReport enregistre une violation lorsque le résultat du test est non vide au lieu du contraire. Les deux points d'entrée partagent en dessous la même forme à deux étapes — Doc.selectNodes(Entry.Context) d'abord, pour trouver chaque nœud auquel la règle s'applique, puis ContextNode.selectNodes(Entry.Test) sur chacun d'eux tour à tour — ce qui est exactement le modèle contexte-puis-test qu'utilise un véritable processeur Schematron, simplement piloté par le selectNodes XPath 1.0 de MSXML plutôt que par un moteur d'exécution conscient de Schematron

Comment le moteur ignore-t-il XPath 2.0 sans faire planter l'exécution ?

HPDFEInvoiceValidator se protège contre la syntaxe XPath 2.0 non prise en charge sur deux niveaux, et le premier ne laisse jamais MSXML voir l'expression du tout. Avant d'évaluer un assert ou un report quelconque, XPath2Detected scanne la chaîne d'expression de test brute à la recherche de six jetons littéraux — xs:decimal, xs:integer, xs:string, upper-case, lower-case, et exists( — et si l'un d'eux est présent, la règle est immédiatement marquée Skipped avec une sévérité stsInfo, sur le raisonnement que MSXML ne devrait jamais recevoir une expression déjà connue pour être rejetée

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;

Le second niveau rattrape tout ce que la liste statique de jetons manque. L'appel selectNodes pour le contexte comme l'appel selectNodes du test par nœud s'exécutent tous deux à l'intérieur d'un bloc try/except ; lorsque MSXML lève une exception sur une expression que le scan de jetons a laissée passer — une construction en dehors des six jetons connus, ou un chemin de contexte qu'il ne peut pas résoudre — l'exception est capturée et la règle est enregistrée comme Skipped plutôt que propagée à l'appelant. Cette conception à deux niveaux explique pourquoi une construction XPath 2.0 n'importe où dans l'ensemble de règles ne remonte jamais au-delà de HPDFValidateEInvoice : chacune de ses 424 assertions s'évalue, échoue, ou est marquée comme ignorée, et une estimation interne portant sur ce fichier de règles a situé la part exécutable en XPath 1.0 à environ 350 des 424 — assez pour que l'évaluation partielle vaille la peine plutôt que de retomber sur une simple vérification de conteneur dès qu'une seule assertion XPath 2.0 apparaît

Transmettre du XML de facture UTF-8 à MSXML sans le corrompre

THPDFMSXMLSchematronEngine.Validate ne transmet pas les octets de facture extraits à IXMLDOMDocument.loadXML, car cette méthode attend un BSTR — UTF-16 — et réinterpréterait un tableau d'octets UTF-8 brut sous cette hypothèse, quelle que soit la déclaration <?xml encoding="UTF-8"?> propre au document. HPDFEInvoiceValidator copie plutôt les octets dans un HGLOBAL alloué via GlobalAlloc, l'enveloppe dans un IStream via CreateStreamOnHGlobal, et charge ce flux via IPersistStreamInit.Load, un chemin que MSXML honore en lisant la déclaration d'encodage depuis le flux d'octets lui-même plutôt qu'en supposant l'UTF-16 d'emblée. La même méthode reconstruit SelectionNamespaces à partir des liaisons de préfixes que le chargeur a déjà récoltées lors de l'analyse du fichier .sch, de sorte qu'une règle écrite contre un préfixe comme ram: se résout correctement par rapport à l'espace de noms propre du XML de facture à chaque évaluation, pas seulement lors de la première analyse du fichier de règles

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

Garder une seule unité compilable de Delphi 7 à aujourd'hui

HPDFEInvoiceValidator.pas doit compiler sur chaque version de Delphi prise en charge par HotPDF, y compris des versions ne disposant d'aucune liaison XML ou XPath, si bien que sa section interface n'expose que des types valeur simples : des enregistrements, des tableaux dynamiques, et une seule interface, IHPDFESchematronEngine, avec les méthodes Load, Validate, et LastSummary. Chaque type spécifique à MSXML — IXMLDOMDocument2, l'import Winapi.msxml, THPDFMSXMLSchematronEngine lui-même — se trouve à l'intérieur d'un seul bloc {$IFDEF XE2+} dans la section implémentation, invisible aussi bien pour les appelants que pour le compilateur sur les chaînes d'outils plus anciennes

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

Sous Delphi 7 et versions antérieures, HPDFCreateSchematronEngine renvoie à la place THPDFStubSchematronEngine : son Load renvoie toujours False avec un ErrorText qui nomme la lacune réelle — la liaison DOM MSXML nécessite XE2 ou une version ultérieure — et oriente en attendant vers un validateur externe tel que veraPDF, Mustang, ou un outil de conformité ZUGFeRD pour une couverture complète. Son Validate renvoie un unique résultat synthétique avec RuleID égal à 'ENGINE' et Skipped défini, si bien que le code qui itère sur BusinessRules n'a pas besoin d'une branche séparée pour « le moteur n'a pas pu s'exécuter » par rapport à « chaque règle s'est trouvée ignorée » — les deux prennent la même forme aux yeux de l'appelant. HPDFValidateEInvoice intègre cela avec élégance dans son verdict également : le booléen qu'il renvoie est ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), si bien qu'un moteur indisponible rétrograde le résultat vers une simple vérification de conteneur au lieu de forcer un échec dur sur un compilateur qui n'allait de toute façon jamais exécuter de règles Schematron

Le moteur XPath 1.0 de HPDFEInvoiceValidator ne remplace pas un processeur Schematron/XSLT 2.0 complet, et il n'a jamais eu cette prétention : un moteur confiné à XPath 1.0 laissera toujours une poignée d'assertions EN 16931 non évaluées, ce qui est précisément ce que l'indicateur Skipped sur chaque résultat existe pour rendre visible plutôt que pour dissimuler. Ce que le moteur apporte réellement, c'est un retour sur les règles métier qui s'exécute partout où HotPDF s'exécute déjà, sans processus externe à invoquer et sans runtime XSLT 2.0 à mettre sous licence. Ce moteur est fourni dans le cadre du composant PDF HotPDF pour Delphi et C++Builder, aux côtés de l'outillage Factur-X et PDF/A au niveau conteneur sur lequel il s'appuie