This article was written with the help of generative AI. Facts have been checked against primary sources where possible, but errors may remain. Please verify against primary sources before relying on any of this.
Encoding text in TEI (Text Encoding Initiative) eventually turns up rules that a
grammar cannot express. "Every personal name in the body must carry an authority
pointer." "@ref="#foo" must resolve to something that actually exists in the
header." "@from must not be later than @to."
RELAX NG is more capable here than it is usually given credit for. It handles
co-occurrence constraints ā varying a content model by attribute value ā which
was one of its selling points against W3C XML Schema 1.0. It also inherits
ID/IDREF from the DTD Compatibility specification, and jing checks those by
default. What it cannot do is compare two values, verify that a pointer resolves
to an element of a particular kind, or apply a rule only within a given
ancestor context.
Those are Schematron's job. TEI lets you keep both in one place: write the Schematron inside the ODD (One Document Does it All), then generate the RELAX NG schema and the Schematron schema from that single source.
This article walks through the whole loop on macOS with VS Code and the command
line, without oXygen XML Editor. The sample is on GitHub, and three brew installs plus two scripts reproduce it:
The pipeline
flowchart LR
A["letters.odd<br/>(customization ODD)"] -->|odd2odd.xsl| B["compiled.odd<br/>(merged with P5)"]
B -->|odd2relax.xsl| C["letters.rng<br/>(RELAX NG)"]
B -->|extract-isosch.xsl| D["letters.sch<br/>(ISO Schematron)"]
D -->|SchXslt2 transpile.xsl| E["letters.sch.xsl<br/>(XSLT 3.0)"]
C -->|jing| F["structural validation"]
E -->|Saxon| G["SVRL ā rule validation"]
The customization ODD is not transformed directly. It only says things like "use
the core module", so it first has to be merged with the full TEI P5 source to
produce an expanded ODD that carries the actual element declarations. Everything
else derives from that.
Setting up
Three things are needed: Java, Saxon, and jing.
brew install openjdk saxon jing-trang
Homebrew's openjdk does not always symlink into
/Library/Java/JavaVirtualMachines/, in which case java is not on PATH:
java -version # may report no Java runtime
/opt/homebrew/opt/openjdk/bin/java -version # this one works
Putting the path into the scripts is less invasive than changing the system configuration, which is what the sample does.
Three more pieces are downloaded rather than installed:
| Name | Role | Source |
|---|---|---|
| TEI Stylesheets | the XSLT that turns an ODD into RELAX NG / Schematron | TEIC/Stylesheets releases |
| SchXslt2 | transpiles Schematron into XSLT 3.0 | SchXslt/schxslt2 on Codeberg |
| p5subset.xml | the whole of TEI P5 in one file | TEI Vault |
SchXslt2 ships as an 18 KB zip containing little more than transpile.xsl. TEI
Stylesheets is 12 MB, and p5subset.xml is 4.1 MB.
Writing the constraints
The sample is a small customization for a corpus of letters:
<schemaSpec ident="letters" start="TEI" prefix="tei_" docLang="ja" targetLang="ja">
<constraintDecl scheme="schematron" queryBinding="xslt2"/>
<moduleRef key="tei"/>
<moduleRef key="core"/>
<moduleRef key="header"/>
<moduleRef key="textstructure"/>
<moduleRef key="namesdates"/>
<moduleRef key="corpus"/>
<!-- constraintSpec elements go here -->
</schemaSpec>
<constraintDecl> declares that Schematron is in use and which XPath version
applies. With queryBinding="xslt2", functions from XPath 2.0 and later ā
matches(), xs:date() ā become available.
Worth noting: TEI Stylesheets substitutes xslt2 when no @queryBinding is
given, but ISO Schematron's own default is xslt, meaning XPath 1.0. If the
generated .sch might be consumed by another processor, declare it explicitly.
TEI Guidelines treat the attribute as recommended rather than required.
Element-specific rules go inside elementSpec
<elementSpec ident="persName" module="namesdates" mode="change">
<constraintSpec ident="persName-needs-ref" scheme="schematron" mode="add">
<constraint>
<sch:rule context="tei:body//tei:persName">
<sch:assert test="@ref">A <persName> in the body needs @ref.
Found: "<sch:value-of select="normalize-space(.)"/>"</sch:assert>
</sch:rule>
</constraint>
</constraintSpec>
<constraintSpec ident="persName-ref-resolves" scheme="schematron" mode="add">
<constraint>
<sch:rule context="tei:persName[starts-with(@ref, '#')]">
<sch:let name="target" value="substring-after(@ref, '#')"/>
<sch:assert test="//tei:person[@xml:id eq $target]"
>Cannot resolve @ref: <sch:value-of select="@ref"/></sch:assert>
</sch:rule>
</constraint>
</constraintSpec>
</elementSpec>
RELAX NG could make @ref mandatory, but not only in the body ā the
<persName> inside <person> in the header should not need one. Hence the
context narrowed to tei:body//tei:persName.
The second rule is referential integrity. RELAX NG's IDREF would confirm that
some element carries that xml:id, but not that the target is a <person>.
Schematron's variable element is <sch:let>, not <sch:variable> ā an easy slip
if you spend time in XSLT. This has not changed in the 2025 edition of ISO
Schematron; that revision added an @as attribute for typing, but kept the name.
assert versus report
<assert> says "this ought to be true", <report> says "if this is true we have
a problem". Either can express the same thing; pick whichever avoids a double
negative.
<elementSpec ident="pb" module="core" mode="change">
<constraintSpec ident="pb-not-first-in-div" scheme="schematron" mode="add">
<constraint>
<sch:rule context="tei:pb">
<sch:report test="parent::tei:div and not(preceding-sibling::*)"
><pb> is the first child of a <div>; move it before the
<div>. (n=<sch:value-of select="@n"/>)</sch:report>
</sch:rule>
</constraint>
</constraintSpec>
</elementSpec>
Document-wide rules go directly under schemaSpec
<constraintSpec ident="isbn-format" scheme="schematron">
<constraint>
<sch:rule context="tei:idno[@type eq 'ISBN']">
<sch:assert test="matches(normalize-space(.), '^97[89]-\d{1,5}-\d{1,7}-\d{1,7}-\d$')"
>Malformed ISBN: "<sch:value-of select="normalize-space(.)"/>"</sch:assert>
</sch:rule>
</constraint>
</constraintSpec>
<constraintSpec ident="date-range-order" scheme="schematron">
<constraint>
<sch:rule context="tei:date[@from][@to]">
<sch:assert test="xs:date(@from) le xs:date(@to)"
>@from is later than @to: <sch:value-of select="@from"/> /
<sch:value-of select="@to"/></sch:assert>
</sch:rule>
</constraint>
</constraintSpec>
Generating the schemas
Four steps:
export PATH="/opt/homebrew/opt/openjdk/bin:$PATH" # if java is not on PATH
XSL=tools/tei-xsl/xml/tei/stylesheet/odds
# 1. merge the customization with P5
saxon -s:odd/letters.odd -xsl:$XSL/odd2odd.xsl -o:build/letters.compiled.odd defaultSource="$PWD/tools/p5subset.xml"
# 2. RELAX NG
saxon -s:build/letters.compiled.odd -xsl:$XSL/odd2relax.xsl -o:build/letters.rng
# 3. ISO Schematron
saxon -s:build/letters.compiled.odd -xsl:$XSL/extract-isosch.xsl -o:build/letters.sch
# 4. Schematron ā executable XSLT 3.0
saxon -s:build/letters.sch -xsl:tools/schxslt2/schxslt2-1.11.2/transpile.xsl -o:build/letters.sch.xsl
Without defaultSource, every run fetches p5subset.xml from tei-c.org. A local
copy is faster and works offline.
The artefacts:
letters.compiled.odd 2.1M
letters.rng 494K (10,968 lines)
letters.sch 13K
letters.sch.xsl 100K
The generated letters.sch holds 36 <pattern> elements. Only 5 are the ones
written above; the remaining 31 come from TEI P5 itself. Names such as
att-datable-w3c-when ("@when cannot be used with any other att.datable.w3c
attribute") or spanTo-points-to-following give the flavour. Validating with
RELAX NG alone means none of these are ever checked ā arguably a better reason to
generate Schematron from your ODD than any rule you write yourself.
Validating
jing handles RELAX NG; the transpiled XSLT runs under Saxon for Schematron.
jing build/letters.rng data/invalid.xml
saxon -s:data/invalid.xml -xsl:build/letters.sch.xsl -o:report.svrl
Schematron reports in SVRL (Schematron Validation Report Language), an XML format. Violations appear as two element types:
svrl:failed-assertā an<assert>whose test evaluated to falsesvrl:successful-reportā a<report>whose test evaluated to true
The names read as opposites but both mean "fix this".
SVRL identifies locations by XPath, whereas editors want line numbers. The obvious approach ā do the conversion in XSLT ā runs aground:
XPST0017 Cannot find a 1-argument function named Q{http://saxon.sf.net/}line-number().
Saxon extension functions are not available under Saxon-HE
Saxon's extension functions require Saxon-PE or higher. The sample instead walks
the source document with Python's expat parser to build a map from element paths
to line numbers, then matches that against SVRL's @location. No third-party
dependencies:
import xml.parsers.expat
def build_line_map(path):
"""Map (uri, local, index) tuples to (line, column)."""
line_map, stack, counters = {}, [], [{}]
def start(name, _attrs):
uri, _, local = name.rpartition("|") if "|" in name else ("", "", name)
counters[-1][(uri, local)] = counters[-1].get((uri, local), 0) + 1
stack.append((uri, local, counters[-1][(uri, local)]))
counters.append({})
line_map[tuple(stack)] = (parser.CurrentLineNumber,
parser.CurrentColumnNumber + 1)
def end(_name):
counters.pop()
stack.pop()
parser = xml.parsers.expat.ParserCreate(namespace_separator="|")
parser.StartElementHandler = start
parser.EndElementHandler = end
with open(path, "rb") as fh:
parser.ParseFile(fh)
return line_map
SVRL's @location uses EQName syntax
(/Q{http://www.tei-c.org/ns/1.0}TEI[1]/...), so a regular expression splits it
into the same shape of tuples.
Output is formatted as absolute-path:line:column: severity: message to match
jing, so a single problem matcher can consume both.
Results
The sample includes a document that is valid RELAX NG but breaks five Schematron rules:
āā data/invalid.xml āā
ā
RELAX NG: structurally valid
data/invalid.xml:23:11: error: Malformed ISBN: "4-12-345678-X" ā¦
data/invalid.xml:30:9: error: @from is later than @to: 1911-12-31 / 1911-01-01
data/invalid.xml:45:9: error: <pb> is the first child of a <div> ā¦
data/invalid.xml:50:15: error: A <persName> in the body needs @ref ā¦
data/invalid.xml:53:12: error: Cannot resolve @ref: #soseki
ā Schematron violations
āā data/valid.xml āā
ā
RELAX NG: structurally valid
ā
Schematron: all constraints satisfied
RELAX NG passes both files. All five violations are structurally correct and wrong only in their values or their references. That gap is precisely why the two languages are used together.
Wiring it into VS Code
RELAX NG validates inline
Red Hat's XML extension
(redhat.vscode-xml, built on LemMinX) supports RELAX NG. The support is
officially labelled experimental, but it handled the TEI schema without trouble.
code --install-extension redhat.vscode-xml
An <?xml-model?> processing instruction at the top of the document is enough:
<?xml-model href="../build/letters.rng" type="application/xml"
schematypens="http://relaxng.org/ns/structure/1.0"?>
Relative paths resolve. To confirm this without opening the editor, the sample
starts the bundled language server directly and sends it a
textDocument/didOpen over the Language Server Protocol:
$ ./scripts/lsp-probe.py probe/broken-structure.xml
diagnostics: 1
L48:17 [xml/out_of_context_element] element "opener" not allowed here; expected
the element end-tag, text or element "abbr", "add", "addName", "ad...
Line and column are correct, and the generated letters.rng is clearly being
consulted.
Schematron runs as a task
The extension does not support Schematron. The request has been open as
issue #451 since
2021, and a <?xml-model schematypens="http://purl.oclc.org/dsdl/schematron"?>
is simply ignored. Feeding the same language server a document with five
Schematron violations ā structurally valid ā yields nothing:
$ ./scripts/lsp-probe.py data/invalid.xml
diagnostics: 0
The instruction is still worth keeping, since oXygen honours it. For VS Code, the
validation script runs as a task and a problemMatcher routes results into the
Problems panel:
{
"label": "TEI: validate current file",
"type": "shell",
"command": "./scripts/validate.zsh '${relativeFile}'",
"group": { "kind": "test", "isDefault": true },
"problemMatcher": {
"owner": "tei",
"fileLocation": "absolute",
"pattern": {
"regexp": "^(/.+?):(\\d+):(\\d+):\\s+(error|warning|fatal):\\s+(.*)$",
"file": 1, "line": 2, "column": 3, "severity": 4, "message": 5
}
}
}
jing emits the same absolute-path:line:column: error: message shape, so this one
expression catches both kinds of violation, and each entry in the panel jumps to
the offending line.
Things that went wrong
A bare <assert> inside <constraint> disappears silently
The least obvious failure mode. Omit <sch:rule>, put <sch:assert> directly
inside <constraint>, and the constraint vanishes from the generated .sch:
WARNING: Ignoring invalid sch:assert found directly within <constraint>.
The warning goes to stderr, the transform still succeeds, and the exit status is 0. Grepping the output confirms the loss:
grep -c "persName-needs-ref" build/letters.sch
# ā 0
This is TEI's rule rather than a Stylesheets quirk. P5's own <constraintSpec>
carries a context-required Schematron constraint stating that <sch:assert>
and <sch:report> must descend from an <sch:rule> that has a @context.
extract-isosch.xsl simply discards what does not qualify.
The awkward part is that <constraint> has a content model of
(text | anyElement)*, so validating the ODD against RELAX NG says nothing. The
rule does live in tei_odds.rng as embedded Schematron ā but LemMinX does not run
Schematron, so VS Code cannot catch it either. Getting that feedback means using
oXygen, or running the ODD itself through the same extraction pipeline.
jing's built-in Schematron does not work
jing bundles an ISO Schematron reader ā ISOSchemaReaderImpl appears in the stack
trace ā but passing a .sch throws during Saxon initialisation:
Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0
at net.sf.saxon.Configuration.newExpressionParser(Configuration.java:2804)
at com.thaiopensource.validate.schematron.ISOSchemaReaderImpl.createSchema(...)
queryBinding="xslt2" looked like the culprit, but a minimal schema with no
queryBinding at all fails identically. jing 20241231 bundles Saxon 9.3.0.4, and
that combination does not work; the bundled documentation still describes
Schematron 1.5 support. Use jing for RELAX NG only.
<particDesc> is in the corpus module
Putting a person list in the header with only namesdates imported produces:
error: element "particDesc" not allowed anywhere; expected the element end-tag or
element "abstract", "calendarDesc", "correspDesc", "creation", "langUsage" or "textClass"
<listPerson> and <person> belong to namesdates, but <particDesc>, the
element that holds them, is in corpus. Adding <moduleRef key="corpus"/> fixes
it. In a trimmed customization, an element and its natural container can easily
sit in different modules.
Verified with
| macOS | Darwin 25.5.0 (Apple Silicon) |
| OpenJDK | 25.0.2 (Homebrew) |
| Saxon-HE | 12.9 (Homebrew saxon) |
| jing | 20241231 (Homebrew jing-trang) |
| TEI Stylesheets | 7.61.0 |
| SchXslt2 | 1.11.2 |
| VS Code | 1.129.1 |
| redhat.vscode-xml | 0.29.3 (LemMinX 0.31.2) |
oXygen XML Editor covers this entire loop through its interface, from editing the
ODD to generating and running both schemas. Building it on the command line buys
two things instead: it drops into CI unchanged, and every stage is inspectable.
When a constraint does not fire the way it was meant to, you can follow it from
the <constraint> in the ODD all the way to the XSLT that ends up executing.



Comments
ā¦