O HotPDF valida as regras de negócio de faturação eletrónica EN 16931 através de HPDFEInvoiceValidator, um motor de asserções Schematron que a própria biblioteca implementa sobre o suporte XPath 1.0 do MSXML, em vez de um processador XSLT 2.0 licenciado. HPDFEInvoiceValidator analisa os ficheiros de regras .sch oficiais do Factur-X, avalia todas as asserções que consegue exprimir em XPath 1.0, e marca as restantes como ignoradas em vez de deixar uma expressão não suportada levantar uma exceção a meio da execução
O âmbito mantém-se dentro desse motor: como o carregador transforma o XML Schematron em entradas de regra, como a avaliação de assert e report decide efetivamente sucesso ou falha, como a lacuna do XPath 2.0 é detetada e ignorada, e como a mesma unidade ainda compila no Delphi 7. A incorporação em PDF/A-3, os pormenores do contentor factur-x.xml / xrechnung.xml, e a história de versionamento do ZUGFeRD 2.5 vivem em o artigo complementar sobre faturas eletrónicas ZUGFeRD e Factur-X em Delphi com o HotPDF, que este texto deliberadamente não repete
Porque construiu o HotPDF o seu próprio motor Schematron EN 16931
O HotPDF construiu o seu próprio motor Schematron porque a vinculação (binding) declarada no ficheiro de regras EN 16931 exagera o que as suas asserções efetivamente precisam: o ficheiro define queryBinding="xslt2" no topo, pedindo tecnicamente um processador XSLT 2.0 / XPath 2.0 completo, mas ao ler as próprias asserções verifica-se que a grande maioria chama apenas funções XPath 1.0 como string-length e substring-after. O DOM MSXML incorporado no Windows — o único motor XML com garantia de existir em qualquer instalação Delphi suportada, sem acrescentar uma dependência de terceiros — implementa precisamente esse subconjunto, XPath 1.0, o que tornou um motor nativo prático, em vez de licenciar um runtime XSLT 2.0 separado. HPDFSchematronFileForProfile mapeia um nível de conformidade Factur-X detetado para um de cinco ficheiros de regras incluídos que este motor consegue carregar — MINIMUM, BASIC WL, BASIC, EN 16931, e EXTENDED — e cada um deles avalia apenas o XML da fatura extraído, nunca o PDF envolvente; se esse PDF é ele próprio um ficheiro PDF/A-3 estruturalmente válido é uma questão separada, respondida através das verificações de conformidade PDF/A, PDF/X e PDF/UA do HotPDF noutro ponto da biblioteca
Como transforma o motor um ficheiro .sch em entradas de regra?
THPDFMSXMLSchematronEngine.Load começa por chamar CoInitializeEx(nil, COINIT_MULTITHREADED) antes de criar seja o que for, porque um processo de consola ou de serviço que nunca chamou Application.Initialize ainda não tem apartamento COM, enquanto um hospedeiro VCL gráfico já o tem; o motor trata o resultado S_FALSE ou RPC_E_CHANGED_MODE, que essa chamada pode devolver numa thread já dentro de um apartamento, como igualmente aceitável, e não como um erro. De seguida, analisa o ficheiro Schematron com um documento DOM MSXML 6.0 (CoDOMDocument60) e setProperty('SelectionLanguage', 'XPath'), uma vez que o MSXML assume por predefinição o seu dialeto XSL-Pattern mais antigo, a menos que um chamador opte explicitamente por XPath. A partir daí, contudo, o carregador nunca chama selectNodes para percorrer a própria estrutura do ficheiro .sch — todos os elementos <pattern>, <rule>, <assert>, e <report> são encontrados percorrendo firstChild / nextSibling manualmente, comparando o nome local e o URI de espaço de nomes de cada nó com a cadeia literal http://purl.oclc.org/dsdl/schematron
Essa abordagem de percurso manual existe por causa de um problema do tipo "ovo e galinha" nas vinculações <ns prefix="ram" uri="..."/> que todos os ficheiros Schematron do Factur-X declaram logo no início. Resolver uma expressão XPath com prefixo, como ram:Name, contra essas vinculações exige que a propriedade SelectionNamespaces do MSXML já as contenha, mas descobrir essas vinculações em primeiro lugar normalmente implicaria executar uma consulta XPath como //ns:ns — que, por sua vez, precisa que SelectionNamespaces já esteja definida. O HPDFEInvoiceValidator quebra esse ciclo recolhendo cada elemento <ns> através do mesmo percurso manual de nós filho antes sequer de tocar em selectNodes, e depois combina os pares prefixo/URI recolhidos numa única cadeia SelectionNamespaces que tanto o percurso da estrutura .sch como cada 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 efetivamente dispara uma violação?
O Schematron atribui a assert e a report polaridades opostas, e o motor tem de preservar exatamente essa distinção, ou as suas contagens de violações não significam nada. Um <assert test="X"> declara que X tem de se verificar para cada nó que corresponda ao caminho de contexto da regra, pelo que EvaluateAssert regista uma violação quando o conjunto de nós resultante da expressão de teste vem vazio; um <report test="X"> é a imagem espelhada, assinalando um problema quando X é verdadeiro, pelo que EvaluateReport regista uma violação quando o resultado do teste é, pelo contrário, não vazio. Ambos os pontos de entrada partilham por baixo a mesma forma em duas fases — primeiro Doc.selectNodes(Entry.Context), para encontrar cada nó a que a regra se aplica, depois ContextNode.selectNodes(Entry.Test) contra cada um deles por sua vez — que é exatamente o modelo contexto-depois-teste que um verdadeiro processador Schematron usa, apenas conduzido pelo selectNodes XPath 1.0 do MSXML em vez de um motor de execução consciente do Schematron
Como salta o motor o XPath 2.0 sem falhar a execução?
HPDFEInvoiceValidator defende-se de 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 analisa a cadeia de expressão de teste em bruto 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 é marcada de imediato como Skipped com severidade stsInfo, partindo do princípio de que o MSXML nunca deve receber uma expressão já sabida como rejeitável
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 apanha o que a lista estática de tokens deixa escapar. Tanto a chamada selectNodes de contexto como a chamada selectNodes de teste por nó são executadas dentro de um bloco try/except; quando o MSXML levanta uma exceção sobre uma expressão que a análise 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 é registada como Skipped em vez de propagada ao chamador. Esse design de duas camadas é a razão pela qual uma construção XPath 2.0 em qualquer ponto do conjunto de regras nunca chega a levantar uma exceção que ultrapasse HPDFValidateEInvoice: cada uma das suas 424 asserções ou é avaliada, ou falha, ou é marcada como ignorada, e uma estimativa interna feita sobre esse ficheiro de regras situou a fração executável em XPath 1.0 em cerca de 350 das 424 — o suficiente para que a avaliação parcial valha a pena, em vez de recuar para uma verificação apenas ao nível do contentor assim que surge uma única asserção XPath 2.0
Entregar XML de fatura UTF-8 ao MSXML sem o corromper
THPDFMSXMLSchematronEngine.Validate não entrega os bytes da fatura extraída a IXMLDOMDocument.loadXML, porque esse método espera uma BSTR — UTF-16 — e reinterpretaria um array de bytes UTF-8 em bruto sob esse pressuposto, independentemente do que a própria declaração <?xml encoding="UTF-8"?> do documento diga. Em vez disso, o HPDFEInvoiceValidator copia os bytes para um HGLOBAL alocado com GlobalAlloc, envolve-o num IStream através 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 à partida. O mesmo método reconstrói SelectionNamespaces a partir das vinculações de prefixo que o carregador já recolheu ao analisar o ficheiro .sch, pelo que uma regra escrita contra um prefixo como ram: se resolve corretamente contra o espaço de nomes do próprio XML da fatura em cada avaliação, e não apenas quando o ficheiro 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
Manter uma única unidade a compilar do Delphi 7 até hoje
HPDFEInvoiceValidator.pas tem de compilar em todas as versões do Delphi que o HotPDF suporta, incluindo edições sem qualquer vinculação XML ou XPath, pelo que a sua secção de interface expõe apenas tipos de valor simples: registos, arrays dinâmicos, e uma única interface, IHPDFESchematronEngine, com os métodos Load, Validate, e LastSummary. Todos os tipos específicos do MSXML — IXMLDOMDocument2, a importação Winapi.msxml, o próprio THPDFMSXMLSchematronEngine — ficam dentro de um único bloco {$IFDEF XE2+} na secção de implementação, invisíveis tanto para os chamadores como para o compilador em toolchains mais antigas
{$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 antes THPDFStubSchematronEngine: o seu Load devolve sempre False com um ErrorText que identifica a lacuna real — a vinculação DOM do MSXML exige XE2 ou posterior — e aponta, entretanto, para um validador externo como o veraPDF, o Mustang, ou uma ferramenta de conformidade ZUGFeRD para cobertura completa. O seu Validate devolve um único resultado sintético com RuleID 'ENGINE' e Skipped definido, pelo que código que itere BusinessRules não precisa de um ramo separado para "o motor não conseguiu executar" versus "todas as regras calharam de ser ignoradas" — ambos apresentam a mesma forma ao chamador. HPDFValidateEInvoice também integra isso graciosamente no seu veredito: o booleano que devolve é ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), pelo que um motor indisponível degrada o resultado para uma verificação apenas ao nível do contentor, em vez de forçar uma falha total num compilador que nunca iria executar regras Schematron em primeiro lugar
O motor XPath 1.0 do HPDFEInvoiceValidator não substitui um processador Schematron/XSLT 2.0 completo, e nunca foi essa a intenção: um motor confinado a XPath 1.0 deixará sempre por avaliar um punhado de asserções EN 16931, o que é precisamente o que a flag Skipped em cada resultado existe para tornar visível, em vez de esconder. O que o motor efetivamente proporciona é feedback de regras de negócio que corre em qualquer sítio onde o HotPDF já corre, sem qualquer processo externo a invocar e sem runtime XSLT 2.0 a licenciar. Este motor é distribuído como parte do componente PDF HotPDF para Delphi e C++Builder, a par das ferramentas Factur-X e PDF/A ao nível do contentor sobre as quais assenta