HotPDF valida las reglas de negocio de facturación electrónica EN 16931 mediante 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 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 omitida en lugar de dejar que una expresión no soportada 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 de Schematron en entradas de regla, cómo la evaluación de assert y report realmente decide aprobar o fallar, cómo se detecta y omite la brecha de XPath 2.0, y cómo la misma unidad todavía compila en Delphi 7. La incrustación PDF/A-3, la mecánica del contenedor factur-x.xml / xrechnung.xml, y la historia de 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 deliberadamente no repite
Por qué HotPDF construyó su propio motor Schematron para EN 16931
HotPDF construyó su propio motor Schematron porque el binding declarado del archivo de reglas de EN 16931 exagera lo que sus aserciones realmente necesitan: el archivo establece queryBinding="xslt2" en la parte superior, pidiendo técnicamente un procesador completo XSLT 2.0 / XPath 2.0, pero al leer las propias aserciones se ve que la gran mayoría solo llama a funciones de XPath 1.0 como string-length y substring-after. El DOM de MSXML incorporado en Windows —el único motor XML que tiene garantizado existir en cada instalación de Delphi soportada sin agregar 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 separado. HPDFSchematronFileForProfile mapea un nivel de conformidad de Factur-X detectado a uno de los cinco archivos de reglas incluidos que este motor puede cargar —MINIMUM, BASIC WL, BASIC, EN 16931 y EXTENDED— y cada uno de ellos juzga solo 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 pregunta aparte que responden en otra parte de la biblioteca las comprobaciones de conformidad PDF/A, PDF/X y PDF/UA de HotPDF
¿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 servicio que nunca llamó a Application.Initialize todavía no tiene un apartamento COM, mientras que un host GUI de VCL 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 correcto en lugar de como un error. Luego analiza el archivo Schematron con un documento DOM de MSXML 6.0 (CoDOMDocument60) y setProperty('SelectionLanguage', 'XPath'), ya que MSXML por defecto usa 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 encuentra recorriendo firstChild / nextSibling a mano, comparando el nombre local y el URI de espacio de nombres de cada nodo contra 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 todo archivo Schematron de Factur-X declara al principio. 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 significaría ejecutar una consulta XPath como //ns:ns —que a su vez necesita que SelectionNamespaces ya esté establecido. HPDFEInvoiceValidator rompe ese ciclo recolectando cada elemento <ns> mediante el mismo recorrido manual de nodos hijos antes de tocar selectNodes en absoluto, y luego pliega los pares prefijo/URI recolectados en una sola 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 versus report: ¿qué es lo que realmente dispara una violación?
Schematron da a assert y report polaridad opuesta, y el motor tiene que preservar esa distinción exactamente o sus conteos de violaciones no significan 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 violació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 violación cuando el resultado de la prueba no está vacío en su lugar. Ambos puntos de entrada comparten la misma forma de dos etapas por debajo —primero Doc.selectNodes(Entry.Context), para encontrar cada nodo al que aplica la regla, luego 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 impulsado por el selectNodes de XPath 1.0 de MSXML en lugar de un motor de ejecución consciente de Schematron
¿Cómo omite el motor XPath 2.0 sin bloquear la ejecución?
HPDFEInvoiceValidator se defiende de la sintaxis de XPath 2.0 no soportada en dos capas, y la primera nunca deja que MSXML vea la expresión en absoluto. Antes de evaluar cualquier assert o report, XPath2Detected escanea la cadena de expresión de prueba cruda en busca de seis tokens literales —xs:decimal, xs:integer, xs:string, upper-case, lower-case, y exists(— y si alguno de ellos está presente, la regla se marca Skipped con severidad stsInfo de inmediato, con el razonamiento de que nunca se le debería entregar a MSXML una expresión que ya se sabe que 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 deja pasar. 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 en 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 a quien llama. Ese diseño de dos capas es la razón por la que una construcción de XPath 2.0 en cualquier parte del conjunto de reglas nunca se propaga más allá de HPDFValidateEInvoice: cada una de sus 424 aserciones se evalúa, falla, o se marca omitida, y una estimación interna sobre ese archivo de reglas situó la proporción ejecutable en XPath 1.0 en aproximadamente 350 de las 424 —suficiente como para que valga la pena hacer una evaluación parcial en lugar de recurrir a una comprobación solo de contenedor en cuanto aparezca una sola aserción de 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 arreglo de bytes UTF-8 crudo bajo esa suposición sin importar lo que diga la propia declaración <?xml encoding="UTF-8"?> del documento. En su lugar, HPDFEInvoiceValidator copia los bytes en un HGLOBAL asignado con GlobalAlloc, lo envuelve en un IStream mediante CreateStreamOnHGlobal, y carga ese flujo a través de IPersistStreamInit.Load, una ruta que MSXML respeta leyendo la declaración de codificación del propio flujo de bytes en lugar de asumir UTF-16 de entrada. El mismo método reconstruye SelectionNamespaces a partir de los bindings de prefijo que el cargador ya recolectó al analizar el archivo .sch, así que una regla escrita contra un prefijo como ram: se resuelve correctamente contra el propio espacio de nombres del XML de la factura en cada evaluación, no solo cuando el archivo de reglas se analizó por primera vez
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 sola unidad compilando desde Delphi 7 hasta hoy
HPDFEInvoiceValidator.pas tiene que compilar en cada versión de Delphi que HotPDF soporta, incluidas versiones sin ningún binding de XML o XPath, así que su sección de interfaz expone solo tipos de valor simples: registros, arreglos dinámicos, y una única interfaz, IHPDFESchematronEngine, con métodos Load, Validate y LastSummary. Cada tipo específico de MSXML —IXMLDOMDocument2, la importación Winapi.msxml, el propio THPDFMSXMLSchematronEngine— se ubica dentro de un único bloque {$IFDEF XE2+} en la sección de implementación, invisible tanto para quien llama como para el compilador en toolchains más antiguos
{$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 entrega en su lugar THPDFStubSchematronEngine: su Load siempre devuelve False con un ErrorText que nombra la brecha real —el binding DOM de MSXML requiere XE2 o posterior— y apunta hacia un validador externo como veraPDF, Mustang, o una herramienta de conformidad ZUGFeRD para cobertura completa mientras tanto. Su Validate devuelve un único resultado sintético con RuleID 'ENGINE' y Skipped establecido, así que el código que itera BusinessRules no necesita una rama separada para "el motor no pudo ejecutarse" frente a "cada regla resultó omitida" —ambos se ven con la misma forma para quien llama. HPDFValidateEInvoice también incorpora 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 solo de contenedor en lugar de forzar un fallo total en un compilador que de todos modos nunca iba a ejecutar reglas Schematron
El motor XPath 1.0 de HPDFEInvoiceValidator no reemplaza 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 de 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 lugar donde HotPDF ya se ejecuta, sin ningún proceso externo al que recurrir y sin runtime XSLT 2.0 que licenciar. Este motor se incluye como 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