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:

NameRoleSource
TEI Stylesheetsthe XSLT that turns an ODD into RELAX NG / SchematronTEIC/Stylesheets releases
SchXslt2transpiles Schematron into XSLT 3.0SchXslt/schxslt2 on Codeberg
p5subset.xmlthe whole of TEI P5 in one fileTEI 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 &lt;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::*)"
          >&lt;pb> is the first child of a &lt;div>; move it before the
          &lt;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 false
  • svrl: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 &lt;constraint&gt;.

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

macOSDarwin 25.5.0 (Apple Silicon)
OpenJDK25.0.2 (Homebrew)
Saxon-HE12.9 (Homebrew saxon)
jing20241231 (Homebrew jing-trang)
TEI Stylesheets7.61.0
SchXslt21.11.2
VS Code1.129.1
redhat.vscode-xml0.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.