Artículo técnico

Motor de reglas Schematron EN 16931 en Delphi con HotPDF

HotPDF valida las reglas de negocio de factura electrónica EN 16931 a través de HPDFEInvoiceValidator, un motor de aserciones Schematron que la biblioteca implementa por sí misma sobre el soporte de XPath 1.0 de MSXML en lugar de recurrir a un procesador XSLT 2.0 con licencia. HPDFEInvoiceValidator analiza los archivos de reglas .sch oficiales de Factur-X, evalúa cada aserción que puede expresar en XPath 1.0 y marca el resto como omitidas en lugar de dejar que una expresión no admitida lance una excepción a mitad de la ejecución

El alcance aquí se mantiene dentro de ese motor: cómo el cargador convierte el XML Schematron en entradas de regla, cómo la evaluación de assert y report decide realmente el éxito o el fallo, cómo se detecta y omite la laguna de XPath 2.0, y cómo la misma unidad sigue compilando desde Delphi 7. El empaquetado en PDF/A-3, la mecánica del contenedor factur-x.xml / xrechnung.xml y la historia del versionado de ZUGFeRD 2.5 viven en el artículo complementario sobre facturas electrónicas ZUGFeRD y Factur-X en Delphi con HotPDF, que este texto no repite deliberadamente

Por qué HotPDF construyó su propio motor Schematron EN 16931

HotPDF construyó su propio motor Schematron porque el binding declarado en el archivo de reglas EN 16931 exagera lo que sus aserciones realmente necesitan: el archivo establece queryBinding="xslt2" en la cabecera, solicitando técnicamente un procesador completo XSLT 2.0 / XPath 2.0, pero al leer las propias aserciones se comprueba que la gran mayoría solo llama a funciones de XPath 1.0 como string-length y substring-after. El DOM MSXML integrado de Windows, el único motor XML cuya presencia está garantizada en toda instalación de Delphi admitida sin añadir una dependencia de terceros, resulta que implementa exactamente ese subconjunto, XPath 1.0, que es lo que hizo práctico un motor nativo en lugar de licenciar un runtime XSLT 2.0 aparte. HPDFSchematronFileForProfile asocia un nivel de conformidad Factur-X detectado con uno de los cinco archivos de reglas incluidos que este motor puede cargar, MINIMUM, BASIC WL, BASIC, EN 16931 y EXTENDED, y todos ellos juzgan únicamente el XML de factura extraído, nunca el PDF que lo rodea; si ese PDF es en sí mismo un archivo PDF/A-3 estructuralmente válido es una cuestión aparte que se responde mediante las comprobaciones de conformidad PDF/A, PDF/X y PDF/UA de HotPDF en otra parte de la biblioteca

¿Cómo convierte el motor un archivo .sch en entradas de regla?

THPDFMSXMLSchematronEngine.Load comienza llamando a CoInitializeEx(nil, COINIT_MULTITHREADED) antes de crear nada, porque un host de consola o de servicio que nunca llamó a Application.Initialize todavía no tiene apartamento COM, mientras que un host VCL con interfaz gráfica ya lo tiene; el motor trata el resultado S_FALSE o RPC_E_CHANGED_MODE que esa llamada puede devolver en un hilo ya dentro de un apartamento como igualmente válido en lugar de como un error. A continuación analiza el archivo Schematron con un documento DOM MSXML 6.0 (CoDOMDocument60) y setProperty('SelectionLanguage', 'XPath'), ya que MSXML usa por defecto su dialecto XSL-Pattern más antiguo a menos que quien llama opte explícitamente por XPath. A partir de ahí, sin embargo, el cargador nunca llama a selectNodes para recorrer la propia estructura del archivo .sch, cada elemento <pattern>, <rule>, <assert> y <report> se localiza recorriendo firstChild / nextSibling a mano, comparando el nombre local y el URI de espacio de nombres de cada nodo con la cadena literal http://purl.oclc.org/dsdl/schematron

Ese enfoque de recorrido manual existe por un problema de huevo y gallina en los bindings <ns prefix="ram" uri="..."/> que declara por adelantado todo archivo Schematron de Factur-X. Resolver una expresión XPath con prefijo como ram:Name contra esos bindings requiere que la propiedad SelectionNamespaces de MSXML ya los contenga, pero descubrir los bindings en primer lugar normalmente implicaría ejecutar una consulta XPath como //ns:ns, que a su vez necesita tener ya establecido SelectionNamespaces. HPDFEInvoiceValidator rompe ese ciclo recopilando cada elemento <ns> mediante el mismo recorrido manual de nodos hijos antes de tocar selectNodes siquiera, y luego combina los pares prefijo/URI recogidos en una única cadena SelectionNamespaces que reutilizan tanto el recorrido de la estructura .sch como cada evaluación de regla posterior

// 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 frente a report: ¿qué dispara realmente una infracción?

Schematron da a assert y a report polaridades opuestas, y el motor tiene que preservar esa distinción con exactitud o sus recuentos de infracciones no significarían nada. Un <assert test="X"> declara que X debe cumplirse para cada nodo que coincida con la ruta de contexto de la regla, así que EvaluateAssert registra una infracción cuando el conjunto de nodos resultante de la expresión de prueba vuelve vacío; un <report test="X"> es la imagen especular, señala un problema cuando X es verdadero, así que EvaluateReport registra una infracción cuando el resultado de la prueba no está vacío. Ambos puntos de entrada comparten por debajo la misma forma de dos etapas, primero Doc.selectNodes(Entry.Context), para encontrar cada nodo al que se aplica la regla, y después ContextNode.selectNodes(Entry.Test) contra cada uno de ellos por turno, que es exactamente el modelo de contexto-y-luego-prueba que usa un procesador Schematron real, solo que gobernado por el selectNodes de XPath 1.0 de MSXML en lugar de por un motor de ejecución consciente de Schematron

¿Cómo omite el motor XPath 2.0 sin hacer fallar la ejecución?

HPDFEInvoiceValidator se defiende de la sintaxis XPath 2.0 no admitida en dos capas, y la primera nunca deja que MSXML llegue a ver la expresión. Antes de evaluar cualquier assert o report, XPath2Detected escanea la cadena de expresión de prueba en bruto en busca de seis tokens literales, xs:decimal, xs:integer, xs:string, upper-case, lower-case y exists(, y si aparece cualquiera de ellos la regla se marca inmediatamente como Skipped con severidad stsInfo, bajo el razonamiento de que a MSXML nunca se le debería entregar una expresión que ya se sabe que va a rechazar

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;

La segunda capa atrapa lo que la lista estática de tokens no detecta. Tanto la llamada selectNodes de contexto como la llamada selectNodes de prueba por nodo se ejecutan dentro de un bloque try/except; cuando MSXML lanza una excepción sobre una expresión que el escaneo de tokens dejó pasar, una construcción fuera de los seis tokens conocidos, o una ruta de contexto que no puede resolver, la excepción se captura y la regla se registra como Skipped en lugar de propagarse hasta quien llama. Ese diseño de dos capas es la razón por la que una construcción XPath 2.0 en cualquier parte del conjunto de reglas nunca escapa más allá de HPDFValidateEInvoice: cada una de sus 424 aserciones se evalúa, falla o se marca como omitida, y una estimación interna sobre ese archivo de reglas situó la proporción ejecutable en XPath 1.0 en torno a 350 de las 424, lo suficiente como para que merezca la pena una evaluación parcial en lugar de recurrir a una comprobación limitada al contenedor en cuanto aparece una sola aserción XPath 2.0

Entregar XML de factura en UTF-8 a MSXML sin corromperlo

THPDFMSXMLSchematronEngine.Validate no entrega los bytes de factura extraídos a IXMLDOMDocument.loadXML, porque ese método espera un BSTR, UTF-16, y reinterpretaría un array de bytes UTF-8 en bruto bajo esa suposición sin importar lo que diga la propia declaración <?xml encoding="UTF-8"?> del documento. HPDFEInvoiceValidator, en cambio, copia los bytes en un HGLOBAL reservado con GlobalAlloc, lo envuelve en un IStream mediante CreateStreamOnHGlobal y carga ese flujo a través de IPersistStreamInit.Load, una vía que MSXML respeta leyendo la declaración de codificación directamente del propio flujo de bytes en lugar de asumir UTF-16 de antemano. El mismo método reconstruye SelectionNamespaces a partir de los bindings de prefijo que el cargador ya recopiló al analizar el archivo .sch, de modo que una regla escrita con un prefijo como ram: se resuelve correctamente contra el propio espacio de nombres del XML de factura en cada evaluación, no solo cuando se analizó por primera vez el archivo de reglas

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

Mantener una única unidad compilando desde Delphi 7 hasta hoy

HPDFEInvoiceValidator.pas tiene que compilar en cada versión de Delphi que HotPDF admite, incluidas versiones sin ningún binding de XML o XPath, así que su sección de interfaz expone únicamente tipos de valor sencillos: registros, arrays dinámicos y una única interfaz, IHPDFESchematronEngine, con métodos Load, Validate y LastSummary. Todo tipo específico de MSXML, IXMLDOMDocument2, la importación Winapi.msxml, la propia THPDFMSXMLSchematronEngine, se sitúa dentro de un único bloque {$IFDEF XE2+} en la sección de implementación, invisible tanto para quien llama como para el compilador en cadenas de herramientas más antiguas

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

En Delphi 7 y anteriores, HPDFCreateSchematronEngine devuelve en su lugar THPDFStubSchematronEngine: su Load siempre devuelve False con un ErrorText que nombra la laguna real, el binding DOM de MSXML requiere XE2 o posterior, y remite mientras tanto a un validador externo como veraPDF, Mustang o una herramienta de conformidad ZUGFeRD para obtener cobertura completa. Su Validate devuelve un único resultado sintético con RuleID 'ENGINE' y Skipped activado, de modo que el código que recorre BusinessRules no necesita una rama aparte para «el motor no pudo ejecutarse» frente a «resulta que todas las reglas se omitieron», ambos casos presentan la misma forma para quien llama. HPDFValidateEInvoice también integra esto con elegancia en su veredicto: el booleano que devuelve es ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), así que un motor no disponible degrada el resultado a una comprobación limitada al contenedor en lugar de forzar un fallo duro en un compilador que de todos modos nunca iba a ejecutar reglas Schematron

El motor XPath 1.0 de HPDFEInvoiceValidator no sustituye a un procesador Schematron/XSLT 2.0 completo, y nunca pretendió hacerlo: un motor limitado a XPath 1.0 siempre dejará sin evaluar un puñado de aserciones EN 16931, que es exactamente lo que el indicador Skipped en cada resultado existe para hacer visible en lugar de ocultar. Lo que el motor sí aporta es retroalimentación de reglas de negocio que se ejecuta en cualquier sitio donde ya se ejecute HotPDF, sin proceso externo al que recurrir ni runtime XSLT 2.0 que licenciar. Este motor forma parte del componente PDF HotPDF para Delphi y C++Builder, junto con las herramientas de Factur-X y PDF/A a nivel de contenedor sobre las que se construye