기술 문서

Delphi HotPDF의 EN 16931 Schematron 규칙 엔진

HotPDF는 라이선스가 필요한 XSLT 2.0 프로세서 대신 MSXML의 XPath 1.0 지원 위에 라이브러리가 직접 구현한 Schematron 어설션 엔진인 HPDFEInvoiceValidator를 통해 EN 16931 전자 인보이스 비즈니스 규칙을 검증한다. HPDFEInvoiceValidator는 공식 Factur-X .sch 규칙 파일을 파싱하고, XPath 1.0으로 표현 가능한 모든 어설션을 평가하며, 지원되지 않는 표현식이 실행 도중 예외를 일으키게 두는 대신 나머지는 건너뛴 것으로 표시한다

이 글은 그 엔진 내부에만 초점을 맞춘다: 로더가 Schematron XML을 규칙 항목으로 어떻게 바꾸는지, assert와 report 평가가 실제로 성공/실패를 어떻게 판단하는지, XPath 2.0 공백이 어떻게 감지되어 건너뛰어지는지, 그리고 같은 유닛이 어떻게 여전히 Delphi 7에서도 컴파일되는지를 다룬다. PDF/A-3 임베딩, factur-x.xml / xrechnung.xml 컨테이너 메커니즘, ZUGFeRD 2.5 버전 관리 이야기는 Delphi HotPDF의 ZUGFeRD·Factur-X 전자 인보이스에 관한 자매 글에서 다루므로 이 글에서는 의도적으로 반복하지 않는다

HotPDF가 왜 자체 EN 16931 Schematron 엔진을 만들었는가

HotPDF가 자체 Schematron 엔진을 만든 이유는 EN 16931 규칙 파일이 선언한 바인딩이 실제로 어설션에 필요한 것보다 과장되어 있기 때문이다: 이 파일은 상단에 queryBinding="xslt2"를 설정해 기술적으로는 완전한 XSLT 2.0 / XPath 2.0 프로세서를 요구하지만, 어설션 자체를 읽어보면 대부분이 string-lengthsubstring-after 같은 XPath 1.0 함수만 호출한다는 것을 알 수 있다. 서드파티 의존성을 추가하지 않고도 지원되는 모든 Delphi 설치에 반드시 존재하는 유일한 XML 엔진인 Windows 내장 MSXML DOM이 마침 정확히 그 부분집합, 즉 XPath 1.0을 구현하고 있으며, 이 덕분에 별도의 XSLT 2.0 런타임을 라이선싱하는 대신 네이티브 엔진이 실용적일 수 있었다. HPDFSchematronFileForProfile은 감지된 Factur-X 적합성 레벨을 이 엔진이 로드할 수 있는 다섯 가지 제공 규칙 파일 — MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED — 중 하나로 매핑하며, 이 중 어느 것도 주변 PDF는 검사하지 않고 추출된 인보이스 XML만 판단한다. 그 PDF 자체가 구조적으로 유효한 PDF/A-3 파일인지는 라이브러리의 다른 부분인 HotPDF의 PDF/A, PDF/X, PDF/UA 적합성 검사가 답하는 별개의 질문이다

엔진은 .sch 파일을 규칙 항목으로 어떻게 바꾸는가?

THPDFMSXMLSchematronEngine.Load는 무언가를 생성하기 전에 먼저 CoInitializeEx(nil, COINIT_MULTITHREADED)를 호출하는 것으로 시작하는데, Application.Initialize를 호출한 적 없는 콘솔이나 서비스 호스트는 아직 COM 아파트먼트가 없는 반면 GUI VCL 호스트는 이미 가지고 있기 때문이다. 이 엔진은 이미 아파트먼트 안에 있는 스레드에서 그 호출이 반환할 수 있는 S_FALSERPC_E_CHANGED_MODE 결과를 오류가 아니라 마찬가지로 정상으로 취급한다. 그런 다음 MSXML 6.0 DOM 문서(CoDOMDocument60)와 setProperty('SelectionLanguage', 'XPath')로 Schematron 파일을 파싱하는데, 호출자가 명시적으로 XPath를 선택하지 않으면 MSXML은 기본적으로 예전의 XSL-Pattern 방언을 사용하기 때문이다. 하지만 그 이후로 로더는 .sch 파일 자체의 구조를 순회하기 위해 selectNodes를 전혀 호출하지 않는다 — 모든 <pattern>, <rule>, <assert>, <report> 요소는 firstChild/nextSibling을 손으로 순회하며 찾아내고, 각 노드의 로컬 이름과 네임스페이스 URI를 리터럴 문자열 http://purl.oclc.org/dsdl/schematron과 비교한다

이 수동 순회 방식이 존재하는 이유는 모든 Factur-X Schematron 파일이 앞부분에 선언하는 <ns prefix="ram" uri="..."/> 바인딩에 얽힌 닭과 달걀 문제 때문이다. ram:Name 같은 접두사 붙은 XPath 표현식을 그 바인딩에 대해 해석하려면 MSXML의 SelectionNamespaces 속성에 그 바인딩이 이미 담겨 있어야 하는데, 애초에 그 바인딩을 발견하려면 보통 //ns:ns 같은 XPath 쿼리를 실행해야 하고, 그 쿼리 자체도 SelectionNamespaces가 먼저 설정되어 있어야 한다. HPDFEInvoiceValidatorselectNodes를 전혀 건드리기 전에 동일한 수동 자식 노드 순회로 모든 <ns> 요소를 먼저 수집한 뒤, 수확한 접두사/URI 쌍을 하나의 SelectionNamespaces 문자열로 접어 넣어 .sch 구조 순회와 이후의 모든 규칙 평가가 재사용하도록 함으로써 이 순환을 깨뜨린다

// 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 대 report: 실제로 무엇이 위반을 발생시키는가?

Schematron은 assert와 report에 서로 반대되는 극성을 부여하며, 엔진이 이 구분을 정확히 지키지 않으면 위반 건수는 아무 의미가 없어진다. <assert test="X">는 규칙의 컨텍스트 경로와 일치하는 모든 노드에 대해 X가 참이어야 한다고 선언하므로, EvaluateAssert는 테스트 표현식의 결과 노드 집합이 비어 있을 때 위반으로 기록한다. <report test="X">는 그 거울상으로, X가 참일 때 문제를 표시하므로 EvaluateReport는 테스트 결과가 비어 있지 않을 때 위반으로 기록한다. 두 진입점 모두 내부적으로 동일한 2단계 구조를 공유한다 — 먼저 Doc.selectNodes(Entry.Context)로 규칙이 적용되는 모든 노드를 찾은 다음, 각 노드에 대해 차례로 ContextNode.selectNodes(Entry.Test)를 실행한다 — 이는 진짜 Schematron 프로세서가 사용하는 컨텍스트-다음-테스트 모델과 정확히 같으며, 다만 Schematron 인식 실행 엔진이 아니라 MSXML의 XPath 1.0 selectNodes로 구동된다는 점만 다르다

엔진은 실행을 중단시키지 않고 XPath 2.0을 어떻게 건너뛰는가?

HPDFEInvoiceValidator는 지원되지 않는 XPath 2.0 문법에 대해 두 겹으로 방어하며, 첫 번째 층은 MSXML이 그 표현식을 아예 보지 못하게 한다. 어떤 assert나 report를 평가하기 전에 XPath2Detected는 원시 테스트 표현식 문자열에서 xs:decimal, xs:integer, xs:string, upper-case, lower-case, exists(라는 여섯 개의 리터럴 토큰을 스캔하고, 그중 하나라도 있으면 MSXML에 이미 거부될 것이 확실한 표현식을 넘겨서는 안 된다는 논리에 따라 그 규칙을 즉시 stsInfo 심각도의 Skipped로 표시한다

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;

두 번째 층은 정적 토큰 목록이 놓치는 것을 잡아낸다. 컨텍스트 selectNodes 호출과 노드별 테스트 selectNodes 호출 모두 try/except 블록 안에서 실행된다. 토큰 스캔을 통과한 표현식 — 여섯 개의 알려진 토큰 밖에 있는 구문이나 해석할 수 없는 컨텍스트 경로 — 에 대해 MSXML이 예외를 일으키면, 그 예외는 잡히고 규칙은 호출자에게 전파되는 대신 Skipped로 기록된다. 이 이중 방어 설계 덕분에 규칙 집합 어디에 XPath 2.0 구문이 있든 HPDFValidateEInvoice를 넘어 예외가 전파되지 않는다: 424개 어설션 각각은 평가되거나, 실패하거나, 건너뛴 것으로 표시된다. 이 규칙 파일에 대한 내부 추정치는 XPath 1.0으로 실행 가능한 비율을 424개 중 약 350개로 잡았는데, 단 하나의 XPath 2.0 어설션이 나타나는 순간 컨테이너 전용 검사로 되돌아가는 대신 부분 평가를 수행할 가치가 충분한 수준이다

UTF-8 인보이스 XML을 손상 없이 MSXML에 전달하기

THPDFMSXMLSchematronEngine.Validate는 추출된 인보이스 바이트를 IXMLDOMDocument.loadXML에 넘기지 않는데, 이 메서드는 BSTR — UTF-16 — 을 기대하며, 문서 자체의 <?xml encoding="UTF-8"?> 선언이 뭐라고 하든 상관없이 그 가정하에 원시 UTF-8 바이트 배열을 재해석해 버리기 때문이다. 대신 HPDFEInvoiceValidator는 바이트를 GlobalAlloc으로 만든 HGLOBAL에 복사하고, CreateStreamOnHGlobalIStream에 감싼 뒤, IPersistStreamInit.Load를 통해 그 스트림을 로드한다. 이 경로는 MSXML이 처음부터 UTF-16을 가정하는 대신 바이트 스트림 자체에서 인코딩 선언을 읽도록 존중해 준다. 같은 메서드는 로더가 .sch 파일을 파싱하는 동안 이미 수확해 둔 접두사 바인딩으로부터 SelectionNamespaces를 재구성하므로, ram: 같은 접두사에 대해 작성된 규칙은 규칙 파일이 처음 파싱될 때뿐 아니라 매 평가마다 인보이스 XML 자체의 네임스페이스에 대해 올바르게 해석된다

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

Delphi 7부터 오늘날까지 한 유닛이 계속 컴파일되게 하기

HPDFEInvoiceValidator.pas는 XML이나 XPath 바인딩이 전혀 없는 배포판을 포함해 HotPDF가 지원하는 모든 Delphi 버전에서 컴파일되어야 하므로, 인터페이스 섹션은 오직 순수한 값 타입 — 레코드, 동적 배열, 그리고 Load, Validate, LastSummary 메서드를 가진 단일 인터페이스 IHPDFESchematronEngine — 만 노출한다. IXMLDOMDocument2, Winapi.msxml 임포트, THPDFMSXMLSchematronEngine 자체를 포함한 MSXML 특화 타입은 모두 구현 섹션의 단일 {$IFDEF XE2+} 블록 안에 들어 있어, 호출자에게도 구형 툴체인의 컴파일러에게도 보이지 않는다

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

Delphi 7 이하에서는 HPDFCreateSchematronEngine이 대신 THPDFStubSchematronEngine을 돌려준다: 이 클래스의 Load는 항상 False를 반환하며, 실제 공백 — MSXML DOM 바인딩에는 XE2 이상이 필요함 — 을 명시하는 ErrorText와 함께 그동안 완전한 커버리지를 위해 veraPDF, Mustang, ZUGFeRD 적합성 도구 같은 외부 검증기로 안내하는 문구를 담는다. 이 클래스의 ValidateRuleID 'ENGINE'Skipped가 설정된 합성 결과 하나만 반환하므로, BusinessRules를 순회하는 코드는 "엔진이 실행될 수 없었다"와 "모든 규칙이 우연히 건너뛰어졌다"를 구분하는 별도 분기가 필요 없다 — 호출자 입장에서는 둘 다 같은 모양으로 보인다. HPDFValidateEInvoice도 이를 자연스럽게 최종 판정에 반영한다: 반환하는 불리언 값은 ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0))이므로, 엔진을 사용할 수 없으면 결과는 컨테이너 전용 검사로 격하될 뿐, 애초에 Schematron 규칙을 실행할 일이 없었던 컴파일러에게 강제로 실패를 안기지 않는다

HPDFEInvoiceValidator의 XPath 1.0 엔진은 완전한 Schematron/XSLT 2.0 프로세서를 대체하지 않으며, 애초에 그럴 의도도 없었다: XPath 1.0에 국한된 엔진은 항상 몇몇 EN 16931 어설션을 평가하지 못한 채 남기는데, 각 결과의 Skipped 플래그는 그것을 숨기는 대신 정확히 드러내기 위해 존재한다. 이 엔진이 실제로 제공하는 것은 별도의 외부 프로세스를 실행할 필요도, XSLT 2.0 런타임을 라이선싱할 필요도 없이 HotPDF가 이미 실행되는 모든 곳에서 동작하는 비즈니스 규칙 피드백이다. 이 엔진은 그것이 기반으로 하는 컨테이너 수준의 Factur-X 및 PDF/A 도구와 함께 Delphi와 C++Builder용 HotPDF PDF 컴포넌트의 일부로 제공된다