XSchematron 0.2.2

dotnet add package XSchematron --version 0.2.2
                    
NuGet\Install-Package XSchematron -Version 0.2.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="XSchematron" Version="0.2.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="XSchematron" Version="0.2.2" />
                    
Directory.Packages.props
<PackageReference Include="XSchematron" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add XSchematron --version 0.2.2
                    
#r "nuget: XSchematron, 0.2.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package XSchematron@0.2.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=XSchematron&version=0.2.2
                    
Install as a Cake Addin
#tool nuget:?package=XSchematron&version=0.2.2
                    
Install as a Cake Tool

XSchematron

A .NET library that validates XML documents against ISO Schematron rule sets — the business-rule layer that XSD can't express ("if the VAT category is X, the rate must be Y").

XSchematron parses a .sch file into an object model and walks pattern → rule → assert/report, evaluating each rule's XPath directly. There is no XSLT compilation step and no SVRL round-trip, so you don't need an XSLT 2.0 processor — which matters, because .NET's built-in one is XSLT 1.0 only, and that is not enough for the queryBinding="xslt2" rule sets used throughout e-invoicing.

Looking for a command line rather than an API? Install XSchematron.Cli, the xschematron dotnet tool built on this library.

Install

dotnet add package XSchematron

Targets net8.0 and net10.0. The only runtime dependency is XPath2.Net (MS-PL).

You supply the rule files. This package ships code only — no .sch files are included. Point it at the rule sets you need (CEN EN 16931, Peppol BIS Billing 3.0, the French BR-FR-CTC and Factur-X rules, or your own) at runtime.

Quickstart

using XSchematron.Evaluation;
using XSchematron.Model;

// Load one or more Schematron rule files...
var schemas = new[] { "CEN-EN16931-UBL.sch", "PEPPOL-EN16931-UBL.sch" }
    .Select(path => SchematronSchema.Load(path));

// ...and validate a document against all of them at once.
var validator = new SchematronValidator(schemas);

using var xml = File.OpenRead("invoice.xml");
var result = validator.Validate(xml);

foreach (var message in result.Messages)
{
    Console.WriteLine($"[{message.Severity}] {message.RuleId} at {message.XPathContext}");
    Console.WriteLine($"  {message.Message}");
}

Console.WriteLine(result.IsValid ? "valid" : "invalid");

Thread safety and reuse

Hold onto your parsed schemas. A SchematronSchema is immutable, and it is what the library caches against: compiled XPath, interpreted <xsl:function> bodies, and document('…') code lists are all keyed to the schema instance. Parse each rule set once, keep it for the life of the process, and build validators around it freely — constructing a SchematronValidator from already-parsed schemas is cheap. Re-parsing a rule set per document throws that cache away and roughly doubles the cost of every validation.

SchematronValidator is thread-safe. Any number of threads may call Validate on one instance at the same time. Schemas may also be parsed on one thread while others validate, which is what a server does when it accepts an uploaded rule set while requests are in flight. Nothing needs to be locked or pooled by the caller.

// Parse once, at startup.
private static readonly SchematronSchema[] RuleSets =
    [SchematronSchema.Load("CEN-EN16931-UBL.sch"), SchematronSchema.Load("PEPPOL-EN16931-UBL.sch")];

// Then, per request, from any thread:
var result = new SchematronValidator(RuleSets).Validate(xmlStream);

The cache lives exactly as long as the schema does. A rule set held in a static field or a DI singleton stays warm for the life of the process; one parsed to serve a single request is collected along with it, so accepting user-supplied schemas does not grow memory without bound.

API

Loading a schema

SchematronSchema is the parsed, in-memory form of a <schema> document — its namespace table, phases, schema-scoped <let> variables, and patterns.

Method Use
SchematronSchema.Load(string path, SchematronLoadOptions? options = null) Parse a .sch file from disk
SchematronSchema.Load(Stream stream, string sourceName, SchematronLoadOptions? options = null) Parse from a stream; sourceName is what messages and errors are attributed to
SchematronSchema.Parse(string xml, string sourceName = "<memory>", SchematronLoadOptions? options = null) Parse from an in-memory string

SchematronLoadOptions carries two settings:

  • ValidateCustomFunctions (default true) — fail fast at load time if the schema declares a custom <function> with no matching native port. Set to false for tooling that only reads a schema's structure; evaluation will still fail later if a rule actually calls the unported function.
  • OnUnrecognizedElement — a callback invoked once per element the parser skips (<title>, <p>, <diagnostics>, …), so you can observe what is being ignored instead of reading the source XML by hand.
var schema = SchematronSchema.Load(path, new SchematronLoadOptions
{
    ValidateCustomFunctions = false,
    OnUnrecognizedElement = name => Console.WriteLine($"skipped <{name}>"),
});

Validating

public ValidationResult Validate(Stream xmlStream, ValidationOptions? options = null);
public ValidationResult Validate(XPathDocument document, ValidationOptions? options = null);

Prefer the stream overload. It loads the document with line-info tracking enabled, so every message carries the matched node's position in the source document. The XPathDocument overload validates a document you already have, but whether line info is available depends on how that document was built — not on anything the validator controls.

Restrict evaluation to a single phase with ValidationOptions:

var result = validator.Validate(xml, new ValidationOptions { PhaseId = "codelist_phase" });

When PhaseId is null (the default), every pattern in every schema runs — the ISO Schematron #ALL default.

Results

ValidationResult exposes:

Member Meaning
Messages Every message across every schema, in evaluation order
BySchema The same messages grouped by the .sch file that produced them
IsValid true when no message has Error severity

Each ValidationMessage is a record with:

Property Meaning
SchemaSource The .sch file this message came from
RuleId The firing assert/report's id, e.g. "BR-52" (nullable)
PatternId The enclosing pattern's id (nullable)
Kind AssertionKind.Assert or AssertionKind.Report
Severity Error, Warning, or Info — see the mapping below
Flag The raw flag attribute value, preserved for your own policy (nullable)
Message The assert/report's message text
XPathContext Absolute path of the node that matched the rule
LineNumber Line of the assert/report declaration in the schema file (nullable)
DocumentLineNumber / DocumentLinePosition Position of the matched node in the validated document (nullable; always populated by the Stream overload)

Exceptions

Exception When
SchematronParseException The .sch is not well-formed, or breaks a structural requirement (e.g. a <rule> with no context)
UnsupportedSchematronFunctionException The schema declares a custom <function> with no native port
UnsupportedXPathFeatureException A rule uses an XPath construct the evaluator does not support
DocumentResolutionException A rule's document('…') names a missing file, or one outside the schema's directory

SchematronValidator's constructor throws ArgumentException if given no schemas.

Supported Schematron features

Feature Status
<pattern> / <rule> / <assert> / <report> Supported
<let> variables at schema, pattern, and rule scope Supported, including chaining (a later <let> may reference an earlier one) and per-matched-node evaluation of rule-scoped lets
<phase> / <active> Supported, opt-in via ValidationOptions.PhaseId; all patterns run by default (ISO #ALL)
<ns> prefix declarations Supported
flag attribute (fatal, warning) Supported, mapped to severities
XPath 2.0 in context/test/let Supported via XPath2.Net
XSLT match-pattern semantics for rule/@context Supported — a relative context matches nodes anywhere in the document
Custom XSLT <function> blocks Supported — natively ported ones below, plus any declaration whose body is pure XPath; anything else fails fast
<value-of> / <name> message interpolation Supported, resolved against the node that matched
document('…') Supported, resolved relative to the schema's own directory and confined to it
<sch:include>, abstract patterns / is-a Not supported — rejected at load with a message naming the construct, never silently skipped
<extends> rule inheritance Not supported — reported through OnUnrecognizedElement
SVRL output Not supported — results use the library's own model

Severity mapping

Source Severity
<assert> fails Error
<assert flag="fatal"> fails Error
<assert flag="warning"> fails Warning
<report> fires Info (does not make a document invalid)

The raw flag value stays on ValidationMessage.Flag if you want a different policy.

Custom <function> support

Real-world rule sets embed custom XSLT functions — national identifier checksums, format checks. XSchematron handles them two ways.

Interpreted bodies. A declaration whose body is pure XPath dressed as XSLT — <xsl:param>s, <xsl:variable select="…"/> bindings, and a single <xsl:sequence select="…"/> — is evaluated directly, with no porting needed. That covers all 19 custom:* functions of the French FNFE-MPE rule sets. A body using XSLT control flow (<xsl:choose>, <xsl:for-each>, recursion) is deliberately not approximated.

Native ports. 19 functions are implemented in C#, and take precedence over any declared body:

  • Peppol (utils namespace): u:gln, u:slack, u:mod11, u:mod97-0208, u:checkCodiceIPA, u:checkCF, u:checkCF16, u:checkPIVAseIT, u:checkPIVA, u:addPIVA, u:abn, u:TinVerification, u:checkSEOrgnr
  • France: custom:check-siret-siren-coherence, custom:is-valid-decimal-19-2, -19-4, -19-6, -19-6-positive, custom:is-valid-percent-4-2-positive

Because a port outranks a schema's own body, only functions declared identically by every rule set that uses them are ported. Several French functions are not: custom:is-valid-date-format has three different bodies across the FNFE-MPE rule sets, and porting it would apply one rule set's meaning to the others.

One name can mean two things. Calls are dispatched to the body belonging to the schema being evaluated, so loading rule sets that disagree about a function into one validator is safe.

If you load a .sch that declares a <function> with neither a port nor an interpretable body, loading throws UnsupportedSchematronFunctionException immediately — it never silently skips the rule or reports a wrong result. To port a function, implement ISchematronCustomFunction and register it in CustomFunctionRegistry; contributions of ports for other national rule sets are welcome.

Why not compile to XSLT?

The traditional approach runs a .sch file through the ISO skeleton stylesheets to produce a validating XSLT, then parses its SVRL output. That's standards-complete but needs a full XSLT 2.0 processor. On .NET the only fully supported one is SaxonCS, which ships only in the paid Saxon-EE tier — Saxon-HE never made the jump to .NET Core. Walking the rules directly and evaluating XPath 2.0 through the MS-PL-licensed XPath2.Net sidesteps that entirely, at the cost of owning the Schematron semantics ourselves — which is why the feature table above is explicit about the gaps.

The engine is tested against 13 real, unmodified rule sets totalling over 6,700 assertions: CEN EN 16931 in both UBL and CII bindings, Peppol BIS Billing 3.0 and its SBDH envelope rules, and the full French CTC / Factur-X stack published by FNFE-MPE for the September 2026 mandate.

Licensing

MIT — see LICENSE. The runtime dependency XPath2.Net is MS-PL licensed; MS-PL is permissive and imposes no obligations on code that merely consumes it. Full third-party attribution is in NOTICE.md, included in this package and in the repository.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.2 80 9/8/2026
0.2.1 88 9/7/2026
0.2.0 104 9/6/2026
0.1.1 102 9/6/2026
0.1.0 101 9/6/2026