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.

At TEI 2026 (University of British Columbia, Vancouver, 10–14 August 2026) I attended Registered Workshop 3: Schematron in ODD (Syd Bauman, Northeastern University, 11 August, 15:30–18:30, BUCH B210).

The workshop was about using Schematron inside <constraintSpec> to express constraints that PureODD cannot, and most of its second half was set aside for participants to work on their own ODDs. So I brought the ODD for the Kōi Genji Monogatari text database. I wanted rules like "a waka has exactly five lines" and "every @corresp resolves to something that actually exists."

The abstract also listed prerequisites:

Participants should arrive with the capability to generate RELAX NG and Schematron schemas from their customization ODDs. ... Participants should arrive with the capability to validate their TEI documents against both RELAX NG and Schematron. (oXygen will do, but any other method is fine, too — I use jing and a front-end to SchXslt2 on the commandline, myself.)

Generate schemas from the ODD; validate against both. I assumed I met both. As it turned out, I did not even meet the first.

I never got that far. The ground gave way first.

My own data does not pass my own published schema

As a baseline check, I ran the published RELAX NG against all 54 chapters.

$ jing tei_genji.rng xml/master/*.xml | wc -l
795

795 errors, all of the same shape:

01.xml:253:130: error: element "lg" not allowed anywhere; expected the element
end-tag, text or element "bibl", "date", "graphic", "lb", "name", "pb",
"persName", "placeName", "ref", "s", "seg" or "title"

<lg> is "not allowed anywhere." And 795 is exactly the number of waka in this database. Every single poem was invalid.

This was not an artifact of a schema I had just regenerated. Fetching the file from the live published URL gives the same result:

$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/lw/tei_genji.rng
$ jing live.rng xml/master/*.xml | wc -l
795

(That URL now 404s — the fix described here moved the file. The output above is what it returned at the time.)

The last RomaJS timestamp in the ODD's <appInfo> is 2024-06-28T00:00:16.741Z, so the ODD had not been touched since at least then — and in all that time I had not noticed.

The cause: lg and l live in core, not verse

Here is the relevant part of the ODD:

<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher"/>
...
<moduleRef key="verse" include="lg l"/>

It looks right. Verse elements belong to the verse module — surely.

But checking the actual module assignment in TEI P5:

$ # pulled from elementSpec/@module in p5subset.xml
lg         module=core
l          module=core
seg        module=linking
pb         module=core

Both lg and l are in core. The verse module contains only four elements (caesura, metDecl, metSym, rhyme) and two classes (att.enjamb, att.metrical).

And here is the part that matters:

<moduleRef key="verse" include="lg l"/> lists element names that do not exist in verse. This mistake is never reported. It simply matches nothing, and nothing is included.

Since core's own include list did not mention lg l either, both elements vanished from the schema entirely. The generated RNG shows it plainly:

<define name="tei_model.lLike">
   <notAllowed/>
</define>

There is no <define> for lg or l at all, and model.lLike is notAllowed. That is why <lg> is "not allowed anywhere."

As far as I can tell, nothing checks whether the names in @include actually exist. ODD swallows a typo or a wrong module without a word.

The ODD said moduleRef key="verse" include="lg l". Matched against what TEI P5 actually has — the verse module holds only caesura, metDecl, metSym and rhyme, while lg and l are in core — nothing matches, so nothing is included, with no warning and no error. Since core's include list does not mention lg or l either, the generated RELAX NG ends up with tei_model.lLike set to notAllowed and no define for tei_lg or tei_l, which makes all 795 waka invalid with "lg not allowed anywhere". The figure traces that chain from top to bottom.

The fix

Move lg l into core's include list. On its own, that breaks @rhyme, which comes from att.metrical — a class in the verse module. So we still need to reference verse, but we want none of its elements, so we exclude all four:

<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher lg l"/>
...
<!-- We take no elements from verse. It is referenced only to pull in
     att.metrical, which provides lg/@rhyme. -->
<moduleRef key="verse" except="caesura metDecl metSym rhyme"/>

795 → 0.

Step by step:

Statejing errors
As found795 (lg not allowed)
lg l moved to core795 (now @rhyme is rejected)
verse referenced0

A side finding about @rhyme

TEI P5 defines att.metrical/@rhyme as:

@rhyme: specifies the rhyme scheme applicable to a group of verse lines.

It is meant for rhyme schemes — values like abab. The data has rhyme="tanka" on all 795 poems, which records a verse form, not a rhyme scheme. Strictly speaking this stretches the attribute's intent.

Changing it now would touch 795 places, so I left the data alone and pinned the value with a closed value list. I have logged it as modelling debt.

Now, the actual Schematron

With the ground back under me, on to what I came for.

Dividing the work between RELAX NG and Schematron

ISO/IEC 19757 splits these deliberately: Part 2 is RELAX NG (grammar-based), Part 3 is Schematron (rule-based). A grammar defines a closed world — anything not permitted is forbidden. Rules define an open world — only what you assert gets checked.

I went through the constraints I wanted, one at a time:

ConstraintExpressible in RELAX NG?
@n on a line matches its positionNo (comparison with position)
pb/@corresp resolves to a zoneNo (checks the target's element type)
change/@who resolvesNo (same)
pb/@n increases monotonicallyNo (comparison between elements)
A waka requires xml:idYes (usage="req")
@rhyme is tankaYes (closed valList)
A line requires @nYes (usage="req")
seg/@corresp URI patternYes (datatype restriction)
A waka has five linesYes — but I kept it in Schematron (see below)

I had written all of them as Schematron at first, then moved everything the grammar could express into PureODD. Anything in the grammar is enforced by every editor, with no extra tooling. Schematron needs a processor before it does anything at all.

There is a second reason to put attributes in the grammar, which I only understood later — see below.

The five-line rule is the interesting exception.

I first assumed that fixing the content model to "five l elements" would make it illegal to put <pb/> inside lg. That was wrong: interleaving model.global between the lines lets you have both.

<content><sequence>
  <classRef key="model.global" minOccurs="0" maxOccurs="unbounded"/><elementRef key="l"/>
  ... ×5 ...
</sequence></content>

All 54 chapters pass with this, a <pb/> inserted mid-poem passes, and cutting a poem to four lines is rejected.

I still kept it in Schematron, for a different reason: the quality of the message. A grammar cannot say more than "exactly five", and the violation reads element "lg" incomplete; missing required element "l" — it will not tell you which poem has how many lines. count(tei:l) eq 5 can say "waka-001 has 4".

I noticed afterwards that the workshop abstract makes the same argument, using the placement rules for <sic> as its example:

This can be done in PureODD, but is quite difficult, and the resulting error messages are likely to be a somewhat cryptic. (Whereas using Schematron you write your own error messages.)

"Can be expressed in the grammar" and "should be expressed in the grammar" are not the same question.

The PureODD side

<elementSpec ident="lg" module="core" mode="change">
  <constraintSpec ident="waka-line-count" scheme="schematron">
    ...
  </constraintSpec>
  <attList>
    <attDef ident="xml:id" mode="change" usage="req">
      <desc xml:lang="en">Required: the API refers to each waka by this identifier.</desc>
    </attDef>
    <attDef ident="rhyme" mode="change">
      <valList type="closed">
        <valItem ident="tanka"/>
      </valList>
    </attDef>
  </attList>
</elementSpec>

And here I fell into the very same trap a second time. My first attempt was:

<!-- does nothing -->
<attDef ident="id" ns="http://www.w3.org/XML/1998/namespace" mode="change" usage="req"/>

xml:id is a namespaced attribute, so surely ident and ns go separately — or so I assumed. When I tested it, the @rhyme constraint took effect but the xml:id requirement did not: the generated RNG still pulled an optional xml:id from att.global.

TEI P5 4.12.0 declares it like this:

ident=xml:id       ns=None usage=opt
ident=n            ns=None usage=opt
ident=xml:lang     ns=None usage=opt
ident=xml:base     ns=None usage=opt
ident=xml:space    ns=None usage=opt

ident="xml:id", with no @ns. Applying mode="change" to an attribute that does not exist is, once again, silently ignored. The correct form is:

<attDef ident="xml:id" mode="change" usage="req"/>

Same failure mode as the moduleRef. ODD says nothing when the thing you are addressing does not exist.

The Schematron side

The remaining five go into <constraintSpec>:

<constraintSpec ident="pb-corresp-resolves" scheme="schematron">
  <desc xml:lang="ja">pb/@corresp は facsimile 内の zone を指していなければならない。</desc>
  <desc xml:lang="en">pb/@corresp must point to a zone inside the facsimile.</desc>
  <constraint>
    <sch:rule context="tei:pb[ @corresp ]">
      <sch:assert
        test="every $p in tokenize( normalize-space( @corresp ), '\s+' )
              satisfies ( starts-with( $p, '#' )
                          and //tei:zone[ @xml:id eq substring( $p, 2 ) ] )"
        role="error"
        >ページ <sch:value-of select="@n"/> の @corresp "<sch:value-of select="@corresp"
        />" に、文書中の zone を指していない参照があります
        / @corresp "<sch:value-of select="@corresp"/>" on page <sch:value-of select="@n"
        /> contains a reference that does not resolve to a zone in this document</sch:assert>
    </sch:rule>
  </constraint>
</constraintSpec>

This is the same behaviour I hit in the earlier post: putting <sch:assert> directly under <constraint> gets it dropped. But this is one of the rare cases that is not silent:

WARNING: Ignoring invalid sch:assert found directly within <constraint>.

The awkward part is what happens next. odd2relax.xsl does not drop it. It infers the element context, wraps it in <rule context="tei:l">, and embeds it in the RNG. The .sch and the .rng generated from the same ODD end up disagreeing. Either way, wrap it in <sch:rule> explicitly.

<desc> can carry @xml:lang for multiple languages, but <desc> never reaches the generated artifacts. Only the text of <sch:assert> does — so bilingual messages have to go in the assertion text itself, as above.

Toolchain findings

Everything below was reproduced locally.

Duplicate xml:id is already caught by RELAX NG

My first draft had a Schematron rule checking for duplicate xml:id. It was unnecessary:

<attribute name="xml:id"><data type="ID"/></attribute>

Because it is xsd:ID, jing catches it:

$ jing tei_kouigenji.rng dup.xml
dup.xml:377:130: error: ID "waka-001" has already been defined
dup.xml:253:130: error: first occurrence of ID "waka-001"

No need for a Schematron rule that walks every element with count(//*[@xml:id eq $id]), once per node carrying an xml:id.

odd2relax embeds Schematron into the RNG — but jing will not run it

odd2relax.xsl copies the Schematron patterns straight into the generated RELAX NG:

<define name="tei_lg">
   <element name="lg">
      ...
      <pattern xmlns="http://purl.oclc.org/dsdl/schematron" id="…waka-line-count…">
         <sch:rule context="tei:lg[ @type eq 'waka' ]">
            <sch:assert test="$lines eq 5"></sch:assert>

So one file should be enough, right? No. Running a deliberately broken file produced nothing at all in the RELAX NG pass, even though the rules were sitting right there.

ToolSchematron embedded in RNG
jing 20241231Not executed (skipped as a foreign-namespace annotation)
SchXslt2 1.11.2Takes a .sch; will not accept an RNG
redhat.vscode-xmlSchematron is out of scope (issue #451)
oXygen XML Editor 28.1.0Supported

On the command line and in CI, without a standalone .sch no Schematron runs at all. In practice, oXygen is the only path that runs the embedded copy.

The route from ODD to validation. tei_kouigenji.odd is merged with P5 by odd2odd.xsl into compiled.odd, which then branches. The upper route runs odd2relax.xsl to produce tei_kouigenji.rng, and jing validates structure with it — but the Schematron embedded in that same rng does not run, because jing skips it as a foreign-namespace annotation. The lower route runs extract-isosch.xsl to produce tei_kouigenji.sch, which SchXslt2 turns into sch.xsl for Saxon to execute, and that is where the rules actually run. Without a standalone .sch, no Schematron runs on the command line or in CI.

I could not get jing to run ISO Schematron

The jing jar does contain com/thaiopensource/validate/schematron/ISOSchemaReaderImpl.class, so it looks capable. Feeding it the .sch directly:

$ java -jar jing.jar tei_kouigenji.sch broken.xml
Exception in thread "main" java.lang.IllegalArgumentException: Unknown XPath version 0
	at net.sf.saxon.Configuration.newExpressionParser(Configuration.java:2804)

My first assumption was that it could not handle queryBinding="xslt2". Wrong: a minimal Schematron with no queryBinding at all (i.e. the default XSLT 1.0 binding) throws exactly the same exception.

The bottom of the stack is ISOSchemaReaderImpl.createSchema:379TemplatesHandlerImpl.getTemplates:90. jing 20241231 compiles its own ISO Schematron skeleton (XSLT 1.0) with the bundled Saxon-HE 9.3.0.4, but the validation stylesheet it generates has no xsl:stylesheet/@version, and that is where it dies. The skeleton itself runs fine under the same saxon9.

So: at least with the jing 20241231 I have here (bundling Saxon-HE 9.3.0.4), ISO Schematron validation never starts, regardless of queryBinding. A corroborating detail: jing.jar's manifest declares Class-Path: saxon9.jar xalan.jar isorelax.jar resolver.jar, but the official distribution's bin/ contains neither xalan.jar nor resolver.jar.

Schematron needs SchXslt2 plus Saxon.

teitoschematron is not in the release zip

The TEI Stylesheets source tree has bin/teitoschematron (a symlink to transformtei). Unpacking the official tei-xsl-7.61.0.zip, though, gives you only xml/ and doc/ — there is no bin/. transformtei is Ant-based and expects an installed layout with lib/saxon10he.jar and profiles/ (as in the Debian tei-xsl package).

If you only have the release zip, call the stylesheets directly:

saxon -s:odd/tei_kouigenji.odd -xsl:$XSL/odd2odd.xsl \
      -o:build/compiled.odd defaultSource=$PWD/tools/p5subset.xml
saxon -s:build/compiled.odd -xsl:$XSL/odd2relax.xsl      -o:docs/schema/tei_kouigenji.rng
saxon -s:build/compiled.odd -xsl:$XSL/extract-isosch.xsl -o:docs/schema/tei_kouigenji.sch
saxon -s:docs/schema/tei_kouigenji.sch -xsl:tools/schxslt2/transpile.xsl -o:build/sch.xsl

That absolute path matters. defaultSource is resolved relative to the source ODD, not to the working directory. Write tools/p5subset.xml and it looks for odd/tools/p5subset.xml and dies:

Error: odd2odd.xsl: Source document file:.../kouigenji/odd/tools/p5subset.xml is not readable;
  from file:.../kouigenji/odd/tei_kouigenji.odd, with loc=tools/p5subset.xml
XTMM9000  Processing terminated

Skipping odd2odd (the merge with P5) leaves elements referenced by moduleRef unresolved.

No "ODD to ISO Schematron" scenario in oXygen's TEI framework

Looking inside the TEI framework shipped with oXygen XML Editor 28.1.0:

$ ls "…/frameworks/tei/xml/tei/stylesheet/odds/"
extract-isosch.xsl  odd2dtd.xsl  odd2html.xsl  odd2json.xsl
odd2lite.xsl  odd2odd.xsl  odd2relax.xsl  odd2xslstripspace.xsl

extract-isosch.xsl is there (the real odds/ holds 15 files; the listing above is an excerpt). But of the transformation scenarios defined in teip5odd.framework, the ones that generate a schema are only these four:

  • TEI ODD to RELAX NG XML
  • TEI ODD to RELAX NG Compact
  • TEI ODD to DTD
  • TEI ODD to XML Schema

The remaining five are document conversions — XHTML / PDF / EPUB / DOCX / ODT — nine scenarios in all. Across the whole teip5odd.framework, schematron and isosch never occur.

No scenario extracts Schematron. The stylesheet is there, so you can define a scenario yourself.

Saxon 12.9 and the xmlresolver version

Saxon-HE-12.9.jar from Maven Central will not start on its own:

Caused by: java.lang.ClassNotFoundException: org.xmlresolver.Resolver

xmlresolver is a hard dependency. Add the latest 6.0.4 and it fails elsewhere, inside doc-available() in odd2odd.xsl:

Caused by: java.lang.ClassNotFoundException:
  org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
	at org.xmlresolver.ResourceAccess.getNetResource(ResourceAccess.java:240)

xmlresolver 6.x requires Apache HttpClient5. Either add HttpClient5, or use 5.3.3, which resolves over java.net alone. 5.3.3 is also what the Homebrew saxon formula bundles.

SVRL has no line numbers

The @location in a Schematron report (SVRL) is an XPath in EQName form, not a line number:

/Q{http://www.tei-c.org/ns/1.0}TEI[1]/Q{…}text[1]/Q{…}body[1]/Q{…}p[1]/Q{…}pb[1]

Saxon-HE has no saxon:line-number() (that is a PE-and-above extension). To feed an editor's problemMatcher you need line numbers, so I wrote a post-processing step that re-evaluates @location against the source document and reads sourceline. Rewriting Q{uri}local into prefixed form makes it evaluable with lxml.

Independent reviews found more holes

At this point everything passed, and deliberately broken files were caught. I still sent the schema through several independent reviews, each with a different lens. They found four genuine defects in the Schematron, plus one loose precondition underneath them all. I had spotted none of them.

xs:integer() aborts validation of the whole document

pb-monotonic was written like this:

<sch:report test="xs:integer( @n ) le xs:integer( $prev )" role="error">

Give it a page number like 7ウ — a non-numeric value. (This particular source is a Western-style bound book, so it does not occur here, but recto/verso notation in a Japanese bound book produces exactly this.)

Error code: err:FORG0001  Cannot convert string "7ウ" to an integer
saxon exit code = 2
SVRL produced: 0 bytes

The exception stops everything, so no other constraint on that document is checked at all — and because no SVRL is written, it is easy to mistake for a clean run. The fix was to gate the context on castable as xs:integer.

I was picking the wrong preceding pb

<!-- wrong -->
<sch:rule context="tei:pb[ @n ][ preceding::tei:pb/@n ]">
  <sch:let name="prev" value="preceding::tei:pb[1]/@n"/>

The context asks whether any preceding pb has @n, but $prev reads the nearest one. Slip a pb without @n in between and $prev becomes empty; the result of @n le () is an empty sequence, which sch:report treats as false — so it passes without a word. A sequence of 6, <pb/> (no @n), 2 produced zero findings. Fixed by using preceding::tei:pb[@n][1] in both places.

@corresp is not necessarily a single value

@corresp comes from att.global.linking, and its datatype is a list of teidata.pointer: whitespace-separated multiple references are legal. My substring-after(@corresp, '#') turned corresp="#zone_0006 #zone_0007" into the single string "zone_0006 #zone_0007" and reported it as unresolvable — a false positive. Rewriting it with tokenize() over every token fixed that, and as a bonus it now also catches values that do not start with #.

The test did not enforce what the <desc> claimed

<desc xml:lang="en">change/@who must point to a respStmt in the teiHeader.</desc>
...
<sch:assert test="//*[ @xml:id eq $id ]">

The prose says respStmt; the test accepted any element in the document. who="#waka-001" (a poem's id) passed happily. Narrowed to //tei:teiHeader//tei:respStmt.

And the precondition for Schematron was itself loose

This was the big one. Both waka rules use context="tei:lg[ @type eq 'waka' ]". But @type comes from att.typed and was optional, with a free-form value. Which means:

Forget to write @type, or mistype it as Waka, and both constraints drop out of scope with no error and no warning. A three-line waka sails through.

The same shape applied to pb/@corresp (no attribute, no reference check), pb/@n (no ordering check) and seg/@corresp.

In the data, 795/795 have type="waka", 1812/1812 have both @corresp and @n, and 25065/25065 have seg/@corresp. So making all of them required breaks nothing — which is what I did.

If a Schematron context tests for an attribute, make that attribute required in the grammar. Otherwise dropping one attribute quietly switches the rule off.

Non-reproducible output made the CI gate fail every time

I added a CI step checking that regenerating from the ODD reproduces the committed artifacts. It failed on the first run.

TEI Stylesheets stamps a generation timestamp into its output — in three different formats:

.sch   <!-- This file generated 2026-08-12T05:13:22Z by 'extract-isosch.xsl'. -->
.rng   Schema generated from ODD source 2026-08-12T05:20:02Z.
.html   on 2026-08-12T05:19:46Z.

The same ODD produces a different file every time, so git diff --exit-code can never pass. A small post-processing step replaces just the timestamps with fixed text, making the build deterministic. Two consecutive builds now produce byte-identical output for all three files.

Result

$ ./scripts/validate.zsh
=== RELAX NG (54 files) ===
=== Schematron (54 files) ===

0 findings. All 54 files passed both RELAX NG and Schematron.

After publishing, I fetched the live schema and checked again:

$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/schema/tei_kouigenji.rng
$ jing live.rng xml/master/*.xml | wc -l
0

795 → 0.

And on a deliberately broken file:

build/broken.xml:56:1: error: 改訂記録の @who "#nobody" に、teiHeader の respStmt を指していない参照があります
  / change/@who "#nobody" contains a reference that does not resolve to a respStmt in the teiHeader
build/broken.xml:128:1: error: ページ 5 の @corresp "#zone_9999" に、文書中の zone を指していない参照があります
  / @corresp "#zone_9999" on page 5 contains a reference that does not resolve to a zone in this document
build/broken.xml:254:1: error: 和歌は5句です (waka-001 は 4 句)
  / A waka must have 5 lines (waka-001 has 4)
build/broken.xml:254:1: error: この句の @n は 3 であるはずです (いまは "9")
  / This line's @n should be 3 (found "9")

You have only shown that validation works when you have confirmed both that it stays quiet on good data and that it fires on bad data. As this whole episode shows, a broken schema stays broken indefinitely if nobody is running it.

Making it not happen again

Two things had to line up for this bug: a mistake in the ODD that nothing reports, and nobody running real data through the result. The first is not going away, so CI has to cover the second — and, while we are there, keep the committed artifacts from drifting away from the ODD.

- name: Build schema from ODD
  run: ./scripts/build-schema.zsh

# The ODD is the single source of truth; the artifacts must agree with it
- name: Generated schema must match what is committed
  run: |
    if ! git diff --exit-code -- docs/schema/; then
      echo "::error::docs/schema/ does not match the ODD."
      exit 1
    fi

- name: Validate all chapters
  run: ./scripts/validate.zsh

Validating all 54 chapters takes 28 seconds here. The split is 0.3 seconds for jing and all the rest for Schematron — the cost of starting Saxon's JVM 54 times. Whatever the grammar can express is also cheaper in wall-clock time.

I originally kept this separate from the deploy workflow — but that means a failing validation does not stop publication. Turning it into a reusable workflow and calling it from deploy fixes that:

jobs:
  validate:
    uses: ./.github/workflows/validate.yml
  build:
    needs: validate
    ...

Now the site does not publish unless validation passes. The gate proved itself on the very next push — though what it caught was the non-reproducible-output problem above.

Takeaway

I expected to come home from the workshop with a set of Schematron rules. What I actually came home with was a single lesson: ODD ignores your mistakes without telling you. I hit that same shape repeatedly in one day.

What I wroteWhat actually happened
<moduleRef key="verse" include="lg l"/>verse has no lg/l → nothing included
<attDef ident="id" ns="…/XML/1998/namespace"/>att.global declares ident="xml:id" → no match, ignored
<datatype> with no maxOccursdefault of 1 applies; multiple values become illegal
<dataRef key="teidata.pointer" restriction="…"/>@restriction is dropped; the pattern disappears
<persName> in the ODD's own teiHeaderthe ODD schema is itself a restricted customization without persName

Only the last one produces an error. Everything else is silent. The generated schema looks healthy, the ODD stays valid, and you learn nothing until real data goes through it.

Schematron adds a second layer of quiet: if the context does not match, the rule does not fire — and nothing reports that it did not fire. One forgotten @type and the constraint is gone. "Zero findings because it was checked" and "zero findings because nothing was ever looked at" are indistinguishable in the output. I only established which one I had by counting how many nodes each rule actually matched.

So:

  • After writing an ODD, run your real data through it. "The schema builds" is not "the schema works"
  • Confirm both that it stays quiet on good data and that it fires on bad data
  • Count how many nodes each rule matched — separate silence from success
  • If a Schematron context tests an attribute, make that attribute required in the grammar
  • Then make CI remember all of it

The value of <constraintSpec> is less that you can declare a rule, and more that a machine will keep confirming the rule is actually alive.


Related: Embedding Schematron in a TEI ODD and validating it in VS Code

Data: Kōi Genji Monogatari text database (base text: Ikeda Kikan, Kōi Genji Monogatari, Chūōkōronsha. TEI data is CC0 1.0)

Versions tested: TEI Stylesheets 7.61.0 / TEI P5 4.12.0 / SchXslt2 1.11.2 / Saxon-HE 12.9 / Jing 20241231 / oXygen XML Editor 28.1.0