Artigo Técnico

Motor de Regras Schematron EN 16931 em Delphi com o HotPDF

O HotPDF valida regras de negócio de nota fiscal eletrônica EN 16931 por meio de HPDFEInvoiceValidator, um motor de asserções Schematron que a própria biblioteca implementa sobre o suporte a XPath 1.0 do MSXML, em vez de um processador XSLT 2.0 licenciado. HPDFEInvoiceValidator analisa os arquivos de regras .sch oficiais do Factur-X, avalia cada asserção que consegue expressar em XPath 1.0, e marca o restante como ignorado (skipped) em vez de deixar uma expressão não suportada levantar uma exceção no meio da execução

O escopo aqui permanece dentro desse motor: como o carregador transforma o XML Schematron em entradas de regra, como a avaliação de assert e report de fato decide aprovação ou reprovação, como a lacuna do XPath 2.0 é detectada e ignorada, e como a mesma unit ainda compila no Delphi 7. O embutimento em PDF/A-3, a mecânica do contêiner factur-x.xml / xrechnung.xml, e a história de versionamento do ZUGFeRD 2.5 estão em o artigo complementar sobre notas fiscais eletrônicas ZUGFeRD e Factur-X em Delphi com HotPDF, que este texto deliberadamente não repete

Por que o HotPDF construiu seu próprio motor Schematron EN 16931

O HotPDF construiu seu próprio motor Schematron porque a vinculação declarada no arquivo de regras EN 16931 exagera o que suas asserções realmente precisam: o arquivo define queryBinding="xslt2" no topo, tecnicamente exigindo um processador completo XSLT 2.0 / XPath 2.0, mas ler as próprias asserções mostra que a grande maioria chama apenas funções XPath 1.0, como string-length e substring-after. O DOM MSXML embutido do Windows — o único motor XML com presença garantida em toda instalação Delphi suportada, sem adicionar uma dependência de terceiros — por acaso implementa exatamente esse subconjunto, XPath 1.0, o que foi o que tornou um motor nativo viável em vez de licenciar um runtime XSLT 2.0 separado. HPDFSchematronFileForProfile mapeia um nível de conformidade Factur-X detectado para um dos cinco arquivos de regras que esse motor sabe carregar — MINIMUM, BASIC WL, BASIC, EN 16931 e EXTENDED — e cada um deles julga apenas o XML de nota fiscal extraído, nunca o PDF ao redor; se esse PDF é, por si só, um arquivo PDF/A-3 estruturalmente válido é uma questão separada, respondida por meio das verificações de conformidade PDF/A, PDF/X e PDF/UA do HotPDF em outra parte da biblioteca

Como o motor transforma um arquivo .sch em entradas de regra?

THPDFMSXMLSchematronEngine.Load começa chamando CoInitializeEx(nil, COINIT_MULTITHREADED) antes de criar qualquer coisa, porque um host de console ou serviço que nunca chamou Application.Initialize ainda não tem apartment COM, enquanto um host VCL gráfico já tem; o motor trata o resultado S_FALSE ou RPC_E_CHANGED_MODE que essa chamada pode retornar em uma thread já dentro de um apartment como igualmente aceitável, e não como erro. Em seguida, ele analisa o arquivo Schematron com um documento DOM MSXML 6.0 (CoDOMDocument60) e setProperty('SelectionLanguage', 'XPath'), já que o MSXML usa por padrão seu dialeto XSL-Pattern mais antigo, a menos que quem chama opte explicitamente por XPath. A partir daí, porém, o carregador nunca chama selectNodes para percorrer a própria estrutura do arquivo .sch — cada elemento <pattern>, <rule>, <assert> e <report> é encontrado percorrendo firstChild / nextSibling manualmente, comparando o nome local e o URI de namespace de cada nó com a string literal http://purl.oclc.org/dsdl/schematron

Essa abordagem de percurso manual existe por causa de um problema de ovo-e-galinha nas vinculações <ns prefix="ram" uri="..."/> que todo arquivo Schematron do Factur-X declara logo no início. Resolver uma expressão XPath prefixada como ram:Name contra essas vinculações exige que a propriedade SelectionNamespaces do MSXML já as contenha, mas descobrir as vinculações em primeiro lugar normalmente exigiria rodar uma consulta XPath como //ns:ns — que por sua vez precisa que SelectionNamespaces já esteja definida. HPDFEInvoiceValidator quebra esse ciclo coletando cada elemento <ns> por meio do mesmo percurso manual de nós filhos antes mesmo de tocar em selectNodes, depois dobra os pares prefixo/URI coletados em uma única string SelectionNamespaces que tanto o percurso da estrutura .sch quanto toda avaliação de regra posterior reutilizam

// 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: o que de fato dispara uma violação?

O Schematron dá a assert e a report polaridades opostas, e o motor precisa preservar essa distinção com exatidão, ou suas contagens de violação não significam nada. Um <assert test="X"> declara que X deve valer para cada nó que corresponda ao caminho de contexto da regra, de modo que EvaluateAssert registra uma violação quando o conjunto de nós resultante da expressão de teste volta vazio; um <report test="X"> é a imagem espelhada, sinalizando um problema quando X é verdadeiro, de modo que EvaluateReport registra uma violação quando o resultado do teste é não vazio, em vez disso. Ambos os pontos de entrada compartilham a mesma forma de dois estágios por baixo — Doc.selectNodes(Entry.Context) primeiro, para encontrar cada nó ao qual a regra se aplica, depois ContextNode.selectNodes(Entry.Test) contra cada um deles por vez — que é exatamente o modelo contexto-depois-teste que um processador Schematron de verdade usa, apenas conduzido pelo selectNodes XPath 1.0 do MSXML em vez de um motor de execução com conhecimento de Schematron

Como o motor pula XPath 2.0 sem travar a execução?

HPDFEInvoiceValidator se defende contra sintaxe XPath 2.0 não suportada em duas camadas, e a primeira nunca deixa o MSXML sequer ver a expressão. Antes de avaliar qualquer assert ou report, XPath2Detected varre a string bruta da expressão de teste em busca de seis tokens literais — xs:decimal, xs:integer, xs:string, upper-case, lower-case e exists( — e, se algum deles estiver presente, a regra é imediatamente marcada como Skipped com severidade stsInfo, sob o raciocínio de que o MSXML nunca deveria receber uma expressão já sabidamente rejeitada

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;

A segunda camada captura o que a lista estática de tokens deixa passar. Tanto a chamada selectNodes de contexto quanto a chamada selectNodes de teste por nó rodam dentro de um bloco try/except; quando o MSXML levanta uma exceção em uma expressão que a varredura de tokens deixou passar — uma construção fora dos seis tokens conhecidos, ou um caminho de contexto que não consegue resolver — a exceção é capturada e a regra é registrada como Skipped em vez de propagada para quem chamou. Esse design de duas camadas é o motivo pelo qual uma construção XPath 2.0 em qualquer lugar do conjunto de regras nunca ultrapassa HPDFValidateEInvoice: cada uma das suas 424 asserções ou é avaliada, ou falha, ou é marcada como ignorada, e uma estimativa interna contra esse arquivo de regras colocou a fração executável em XPath 1.0 em aproximadamente 350 das 424 — suficiente para que a avaliação parcial valha a pena, em vez de recair para uma verificação apenas de contêiner no instante em que uma única asserção XPath 2.0 aparece

Alimentando XML de nota fiscal em UTF-8 ao MSXML sem corrompê-lo

THPDFMSXMLSchematronEngine.Validate não entrega os bytes extraídos da nota fiscal a IXMLDOMDocument.loadXML, porque esse método espera um BSTR — UTF-16 — e reinterpretaria um array de bytes UTF-8 bruto sob essa suposição, independentemente do que a própria declaração <?xml encoding="UTF-8"?> do documento diga. Em vez disso, HPDFEInvoiceValidator copia os bytes para um HGLOBAL alocado via GlobalAlloc, o envolve em um IStream por meio de CreateStreamOnHGlobal, e carrega esse stream via IPersistStreamInit.Load, um caminho que o MSXML respeita lendo a declaração de codificação a partir do próprio fluxo de bytes, em vez de assumir UTF-16 de antemão. O mesmo método reconstrói SelectionNamespaces a partir das vinculações de prefixo que o carregador já coletou ao analisar o arquivo .sch, de modo que uma regra escrita contra um prefixo como ram: se resolve corretamente contra o próprio namespace do XML de nota fiscal em cada avaliação, não apenas quando o arquivo de regras foi analisado pela primeira 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

Mantendo uma única unit compilando do Delphi 7 até hoje

HPDFEInvoiceValidator.pas precisa compilar em toda versão do Delphi que o HotPDF suporta, incluindo lançamentos sem nenhuma vinculação XML ou XPath, de modo que sua seção interface expõe apenas tipos de valor simples: registros, arrays dinâmicos e uma única interface, IHPDFESchematronEngine, com os métodos Load, Validate e LastSummary. Todo tipo específico do MSXML — IXMLDOMDocument2, o import Winapi.msxml, o próprio THPDFMSXMLSchematronEngine — fica dentro de um único bloco {$IFDEF XE2+} na seção de implementação, invisível tanto para quem chama quanto para o compilador em toolchains mais antigos

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

No Delphi 7 e anteriores, HPDFCreateSchematronEngine devolve THPDFStubSchematronEngine em vez disso: seu Load sempre retorna False com um ErrorText que nomeia a lacuna real — a vinculação DOM do MSXML exige XE2 ou posterior — e aponta para um validador externo, como veraPDF, Mustang ou uma ferramenta de conformidade ZUGFeRD, para cobertura completa enquanto isso. Seu Validate retorna um único resultado sintético com RuleID 'ENGINE' e Skipped ativado, de modo que o código que itera BusinessRules não precisa de um ramo separado para "o motor não conseguiu rodar" versus "toda regra por acaso foi ignorada" — ambos têm a mesma forma para quem chama. HPDFValidateEInvoice também absorve isso graciosamente no seu veredito: o booleano que retorna é ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), de modo que um motor indisponível rebaixa o resultado para uma verificação apenas de contêiner, em vez de forçar uma falha grave em um compilador que de qualquer forma nunca ia rodar regras Schematron

O motor XPath 1.0 de HPDFEInvoiceValidator não substitui um processador Schematron/XSLT 2.0 completo, e nunca foi essa a intenção: um motor limitado a XPath 1.0 sempre deixará um punhado de asserções EN 16931 não avaliadas, que é exatamente o que a flag Skipped em cada resultado existe para tornar visível, em vez de esconder. O que o motor de fato traz é retorno de regras de negócio que roda em qualquer lugar onde o HotPDF já roda, sem processo externo para chamar e sem runtime XSLT 2.0 para licenciar. Esse motor vem como parte do componente HotPDF PDF para Delphi e C++Builder, ao lado das ferramentas de nível de contêiner para Factur-X e PDF/A sobre as quais ele se apoia