技術記事

HotPDFによるDelphiのEN 16931 Schematronルールエンジン

HotPDFはEN 16931電子請求書ビジネスルールを、ライブラリ自身がMSXMLのXPath 1.0サポートの上に実装したSchematronアサーションエンジンであるHPDFEInvoiceValidatorを通じて検証する。ライセンス版のXSLT 2.0プロセッサは使わない。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のバージョニングの経緯はHotPDFによるDelphiでの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の関数しか呼んでいないことが分かる。Windows組み込みのMSXML DOM——サードパーティ依存を追加することなく、サポートされるあらゆるDelphiインストールに確実に存在する唯一のXMLエンジン——は、たまたまその部分集合、すなわちXPath 1.0をちょうど実装しており、これが別個のXSLT 2.0ランタイムをライセンスする代わりにネイティブエンジンを実用的なものにした理由である。HPDFSchematronFileForProfileは、検出されたFactur-Xの適合レベルを、このエンジンが読み込める5つの同梱ルールファイル——MINIMUM、BASIC WL、BASIC、EN 16931、EXTENDED——のいずれかにマッピングし、そのすべてが判断するのは抽出された請求書XMLのみであり、周囲のPDFは対象としない。その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を、エラーとしてではなく等しく問題ないものとして扱う。そこからSchematronファイルをMSXML 6.0 DOMドキュメント(CoDOMDocument60)とsetProperty('SelectionLanguage', 'XPath')で解析する。呼び出し元が明示的にXPathを選ばない限り、MSXMLは既定でより古いXSL-Pattern方言を使うからだ。しかしそこから先、ローダーは.schファイル自身の構造を辿るためにselectNodesを一切呼ばない——すべての<pattern><rule><assert><report>要素は、各ノードのローカル名と名前空間URIをリテラル文字列http://purl.oclc.org/dsdl/schematronと比較しながら、firstChild/nextSiblingを手作業で辿ることによって見つけられる

この手作業での走査は、あらゆるFactur-X Schematronファイルが冒頭で宣言する<ns prefix="ram" uri="..."/>バインディングにおける鶏と卵の問題のために存在する。ram:Nameのような接頭辞付きXPath式をそれらのバインディングに対して解決するには、MSXMLのSelectionNamespacesプロパティがすでにそれらを保持している必要があるが、そもそもバインディングを発見するには通常//ns:nsのようなXPathクエリを実行する必要があり、それ自体が先にSelectionNamespacesが設定されていることを必要とする。HPDFEInvoiceValidatorは、selectNodesに一切触れる前に同じ手作業での子ノード走査ですべての<ns>要素を収集し、収集した接頭辞・URIのペアを、.sch構造の走査とその後のすべてのルール評価が再利用する1つのSelectionNamespaces文字列にまとめることでこの循環を断ち切る

// 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構文に対して2層の防御を行い、第一層はMSXMLにその式を一切見せない。あらゆるassertやreportを評価する前に、XPath2Detectedは生のテスト式文字列を6つのリテラルトークン——xs:decimalxs:integerxs:stringupper-caselower-caseexists(——についてスキャンし、いずれかが存在すれば、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ブロックの中で実行される。トークンスキャンをすり抜けた式——6つの既知トークン以外の構文、あるいは解決できないコンテキストパス——に対してMSXMLが例外を発生させると、その例外は捕捉され、そのルールは呼び出し元に伝播させられるのではなくSkippedとして記録される。この2層設計こそが、ルールセットのどこに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から現在まで、1つのユニットをコンパイル可能に保つ

HPDFEInvoiceValidator.pasは、XMLもXPathバインディングも一切持たないリリースを含む、HotPDFがサポートするすべてのDelphiバージョンでコンパイルできなければならない。そのためインターフェースセクションは単純な値型のみを公開する:レコード、動的配列、そしてLoadValidateLastSummaryメソッドを持つ単一のインターフェースIHPDFESchematronEngineである。IXMLDOMDocument2Winapi.msxmlのインポート、THPDFMSXMLSchematronEngine自体といったMSXML固有の型はすべて、実装セクション内の1つの{$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を返し、ErrorTextには実際のギャップ——MSXML DOMバインディングにはXE2以降が必要——が名指しされ、当面のフルカバレッジのために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コンポーネントの一部として出荷される