本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
TEI 2026(University of British Columbia, Vancouver, 2026-08-10〜14)の Registered Workshop 3: Schematron in ODD(S. Bauman, Northeastern University, 8月11日 15:30–18:30, BUCH B210)に参加しました。
このワークショップは、PureODD だけでは書けない制約を <constraintSpec> の Schematron で
表現しよう、という内容で、後半は参加者が自分の ODD を持ち込んで制約を書く時間にあてられます。
そこで校異源氏物語テキストデータベースの
ODD を持ち込みました。和歌は5句である、@corresp の参照先は実在する、といった規則を
入れたかったからです。
要旨には参加要件も書かれていました。
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.)
「ODD からスキーマを生成できること」「RELAX NG と Schematron の両方で検証できること」。 どちらも満たしているつもりでいましたが、実際には片方すら満たしていませんでした。
公開中のスキーマで、自分のデータが通らない
まず現状確認のつもりで、公開している RELAX NG に対して全54帖をかけました。
$ jing tei_genji.rng xml/master/*.xml | wc -l
795
795 件です。しかもすべて同じ形をしています。
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> が「どこにも置けない」。795 という数字は、このデータベースに収録されている
和歌の総数と一致します。つまり和歌が1首残らずスキーマ違反でした。
これは手元で作り直したスキーマの話ではありません。公開中の URL から取得したもので 同じ結果になります。
$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/lw/tei_genji.rng
$ jing live.rng xml/master/*.xml | wc -l
795
(この URL は本記事で述べる修正でパスを変えたため、いまは 404 になります。 上は当時の実行結果です。)
ODD の <appInfo> に残る RomaJS の編集記録は 2024-06-28T00:00:16.741Z が最後で、
少なくともそれ以降 ODD は更新されていません。その間、少なくとも私は気づいていませんでした。
原因: lg と l は verse モジュールではなく core モジュール
ODD の該当箇所はこうなっていました。
<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher"/>
...
<moduleRef key="verse" include="lg l"/>
いかにも正しく見えます。韻文なのだから verse モジュールだろう、と考えていました。
しかし TEI P5 の実際の所属を引くと、こうなっています。
$ # p5subset.xml から elementSpec/@module を引く
lg module=core
l module=core
seg module=linking
pb module=core
lg も l も core モジュールでした。verse モジュールに入っているのは
caesura / metDecl / metSym / rhyme の4要素と、att.enjamb / att.metrical
の2クラスだけです。
そして重要なのはここからです。
<moduleRef key="verse" include="lg l"/>は、verseに存在しない要素名を並べています。 この間違いは、どこからもエラーとして報告されません。単に「該当なし」として 何も取り込まれずに終わります。
core 側の include にも lg l は無いので、結果として両要素は最終スキーマから
丸ごと消えます。生成された RNG を見ると、はっきり出ています。
<define name="tei_model.lLike">
<notAllowed/>
</define>
lg と l の <define> 自体が存在せず、model.lLike は notAllowed です。
だから <lg> は「どこにも置けない」ことになります。
include に書いた名前が実在するかを検査してくれる仕組みは、調べた限りありません。
ODD は綴りの間違いも、モジュールの取り違えも、そのまま受け流します。
直す
lg l を core の include に移します。それだけでは @rhyme が通らなくなるので、
verse も参照します。@rhyme は att.metrical(verse モジュール)が提供する属性だからです。
ただし verse の要素は1つも使わないので、全部 except で外しました。
<moduleRef key="core" include="p title lb date respStmt bibl author ref pb resp name graphic publisher lg l"/>
...
<!-- verse からは要素を1つも採らない。lg/@rhyme が属する
att.metrical を取り込むためだけに参照する。 -->
<moduleRef key="verse" except="caesura metDecl metSym rhyme"/>
これで 795 件が 0 件になりました。段階を追うとこうなります。
| 状態 | jing のエラー |
|---|---|
| 元のまま | 795(lg が置けない) |
lg l を core へ移動 | 795(今度は @rhyme が通らない) |
verse を参照 | 0 |
ついでに見つかった @rhyme の使い方
att.metrical の @rhyme の定義は、TEI P5 ではこうなっています。
@rhyme: specifies the rhyme scheme applicable to a group of verse lines.
押韻構成を書く属性で、abab のような値を想定しています。
このデータでは 795 首すべてが rhyme="tanka" になっていて、短歌という詩形を
入れています。厳密には属性の意図とずれています。
いま直すとデータ側の変更が795箇所になるので今回は触らず、閉じた値リストとして
tanka のみを許す形で固定しました。設計上の宿題として記録しておきます。
本題の Schematron
足元が直ったので、本来の目的に進みます。
RELAX NG と Schematron の役割分担
ISO/IEC 19757 は、Part 2 が RELAX NG(文法ベース / grammar-based)、 Part 3 が Schematron(規則ベース / rule-based)と分かれています。 文法は「許されていないものはすべて禁止」という閉じた世界を作り、 規則は「書いたことだけを検査する」開いた世界で動きます。
入れたかった規則を並べて、どちらで書けるかを一つずつ確かめました。
| 規則 | RELAX NG で書けるか |
|---|---|
句の @n が位置と一致する | 不可(位置との比較) |
pb/@corresp が zone に解決する | 不可(参照先の要素型まで見る) |
change/@who が解決する | 不可(同上) |
pb/@n が単調増加する | 不可(要素間の比較) |
和歌に xml:id が必須 | 書ける(usage="req") |
@rhyme が tanka | 書ける(閉じた valList) |
句に @n が必須 | 書ける(usage="req") |
seg/@corresp の URI 書式 | 書ける(datatype の正規表現制限) |
| 和歌は5句 | 書けるが、Schematron に残した(理由は後述) |
最初は全部 Schematron に書いていましたが、書けるものは PureODD に移しました。 文法で書けば追加のツールなしに、どのエディタでもその場で検出されます。 Schematron は実行系を用意しないと動きません。
このあと、属性を文法で必須にしておくことが Schematron が働くための前提でもある、 と分かるのですが、それは後述します。
最後の「和歌は5句」だけは、書けるけれども移しませんでした。
はじめは「content model を『l を5個』と固定すると lg の中に <pb/> を置けなくなる」
と考えていましたが、これは私の誤りでした。句の間に model.global を挟めば両立できます。
<content><sequence>
<classRef key="model.global" minOccurs="0" maxOccurs="unbounded"/><elementRef key="l"/>
... ×5 ...
</sequence></content>
これで全54帖が通り、和歌の途中に <pb/> を挿しても通り、4句に削れば弾かれます。
それでも Schematron に置いたのは、メッセージの質が違うからです。文法は
「5個ちょうど」以上のことを言えず、違反したときの出力は
element "lg" incomplete; missing required element "l" 止まりで、
どの和歌が何句なのかを教えてくれません。count(tei:l) eq 5 なら
「waka-001 は 4 句」と名指しできます。
あとで気づいたのですが、これはワークショップの要旨に書かれていたことと同じでした。
<sic> の配置制約を例に、こう述べられています。
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.)
「文法で書ける」と「文法で書くべき」は別でした。
PureODD 側
<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="ja">和歌は API から参照されるため必須とする。</desc>
<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>
ここで同じ落とし穴をもう一度踏みました。最初こう書いていたのです。
<!-- 効かない -->
<attDef ident="id" ns="http://www.w3.org/XML/1998/namespace" mode="change" usage="req"/>
xml:id は XML 名前空間の属性だから ident と ns を分けて書くもの、と思い込んでいました。
実際に検証すると、@rhyme の制約は効くのに xml:id の必須化だけ効きません。
生成された RNG を見ると、lg は相変わらず att.global 経由の optional な xml:id を
参照していました。
TEI P5 4.12.0 の att.global を引くと、こうなっています。
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" で、@ns はありません。存在しない属性を mode="change" しても、
やはり報告されずに無視されます。正しくはこう書きます。
<attDef ident="xml:id" mode="change" usage="req"/>
moduleRef と同じ失敗の型です。ODD は、指示した対象が実在しなくても何も言いません。
Schematron 側
残った5つは <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>
前の記事でも触れましたが、<constraint> の直下に
<sch:assert> を直接書くと落とされます。ただしこれは数少ない「黙っていない」ケースで、
警告が出ます。
WARNING: Ignoring invalid sch:assert found directly within <constraint>.
やっかいなのはその先です。odd2relax.xsl の方は落としません。
要素名から <rule context="tei:l"> を補って RNG に埋め込みます。
同じ ODD から作った .sch と .rng で中身が食い違うので、
いずれにせよ <sch:rule> で明示的に囲むのが安全です。
<desc> は @xml:lang で言語別に持てますが、<desc> は生成物には出ません。
出るのは <sch:assert> のテキストだけなので、日英併記したいならメッセージ本体に両方書きます。
ツールチェーンで踏んだもの
ここからは実測の記録です。すべて手元で再現しています。
xml:id の重複は RELAX NG が見ている
最初は Schematron に「xml:id が重複していないか」を入れていましたが、不要でした。
<attribute name="xml:id"><data type="ID"/></attribute>
xsd:ID なので jing が検出します。
$ 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"
文書中の全要素を舐める count(//*[@xml:id eq $id]) を、xml:id を持つノードの数だけ
繰り返す Schematron を書く必要はありませんでした。
odd2relax は Schematron を RNG に埋め込むが、jing は実行しない
odd2relax.xsl は、生成する RELAX NG の中に Schematron のパターンをそのまま埋め込みます。
<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>
「では RNG 1枚で足りるのでは」と思ったのですが、違いました。わざと壊したファイルをかけると、 RELAX NG の欄には何も出ません。同じ規則が埋め込まれているのに、です。
手元で確かめた範囲では、処理系ごとの対応はこうなっていました。
| ツール | RNG 埋め込み Schematron |
|---|---|
| jing 20241231 | 実行しない(外部名前空間の注釈として読み飛ばす) |
| SchXslt2 1.11.2 | .sch を入力に取る。RNG は受け付けない |
| redhat.vscode-xml | Schematron 自体が対象外(issue #451) |
| oXygen XML Editor 28.1.0 | 対応する |
コマンドラインと CI の経路では、独立した .sch が無いと Schematron は走りません。
実際に利用できるのは、現状 oXygen の経路だけです。
jing で ISO Schematron を動かせなかった
jing の jar には com/thaiopensource/validate/schematron/ISOSchemaReaderImpl.class が
入っていて、ISO Schematron を読めるように見えます。.sch を直接渡してみました。
$ 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)
はじめは「queryBinding="xslt2" を処理できないのだろう」と考えましたが、違いました。
queryBinding を書かない(=既定の XSLT 1.0 バインディング)最小の Schematron でも、
まったく同じ例外が出ます。
スタックの下端は ISOSchemaReaderImpl.createSchema:379 →
TemplatesHandlerImpl.getTemplates:90 です。jing 20241231 は自前の ISO Schematron
スケルトン(XSLT 1.0)を、同梱の Saxon-HE 9.3.0.4 でコンパイルします。ところが生成される
検証用スタイルシートに xsl:stylesheet/@version が付かず、そこで «XPath version 0» に
なるようです。スケルトン自体は同じ saxon9 で単体実行すると通ります。
少なくとも手元の jing 20241231 では、queryBinding に関係なく ISO Schematron の検証が
始まりませんでした。傍証として、jing.jar の MANIFEST は
Class-Path: saxon9.jar xalan.jar isorelax.jar resolver.jar を宣言していますが、
公式配布の bin/ に xalan.jar と resolver.jar は入っていません。
Schematron は SchXslt2 + Saxon で回すことにしました。
teitoschematron はリリース zip に入っていない
TEI Stylesheets のソースツリーには bin/teitoschematron があります
(transformtei へのシンボリックリンク)。
ところが公式リリースの tei-xsl-7.61.0.zip を展開すると、入っているのは xml/ と doc/ だけで、
bin/ がありません。transformtei は Ant ベースで、lib/saxon10he.jar や profiles/ を含む
インストール版のレイアウト(Debian の tei-xsl パッケージなど)を前提としています。
リリース zip だけで完結させるなら、スタイルシートを直接叩きます。
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
defaultSource に絶対パスを渡しているのには理由があります。この値は cwd ではなく
ソース ODD からの相対で解決されます。tools/p5subset.xml と書くと、
odd/tools/p5subset.xml を探しに行って落ちます。
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
odd2odd(P5 との合成)を挟まないと、moduleRef で参照しただけの要素が解決されません。
oXygen の TEI フレームワークに「ODD → ISO Schematron」のシナリオは見当たらない
手元の oXygen XML Editor 28.1.0 の TEI フレームワークを調べました。
$ 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 は入っています(実際の odds/ には15ファイルあり、上は抜粋です)。
しかし teip5odd.framework に定義されている変換シナリオのうち、
スキーマを生成するものは次の4つでした。
- TEI ODD to RELAX NG XML
- TEI ODD to RELAX NG Compact
- TEI ODD to DTD
- TEI ODD to XML Schema
残る5つは文書生成用の XHTML / PDF / EPUB / DOCX / ODT で、合計9つです。
teip5odd.framework 全体で schematron / isosch の出現回数は 0 でした。
スタイルシート自体はあるので、手動でシナリオを作れば動きます。
Saxon 12.9 と xmlresolver のバージョン
Maven Central の Saxon-HE-12.9.jar は、単体では起動しません。
Caused by: java.lang.ClassNotFoundException: org.xmlresolver.Resolver
xmlresolver が必須依存になっています。そこで最新の 6.0.4 を足すと、今度は
odd2odd.xsl の doc-available() で落ちます。
Caused by: java.lang.ClassNotFoundException:
org.apache.hc.client5.http.classic.methods.HttpUriRequestBase
at org.xmlresolver.ResourceAccess.getNetResource(ResourceAccess.java:240)
xmlresolver 6.x は Apache HttpClient5 を要求します。HttpClient5 まで揃えるか、
5.3.3 を使うかです。Homebrew の saxon が同梱しているのも 5.3.3 で、こちらは
java.net で完結します。
SVRL に行番号は無い
Schematron の結果(SVRL)の @location は、行番号ではなく EQName 形式の XPath です。
/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 には saxon:line-number() がありません(PE 以上の拡張関数です)。
エディタの problemMatcher に食わせるには行番号が要るので、@location を
元の XML に対して評価し直して sourceline を取る後処理を書きました。
Q{uri}local を接頭辞つきの XPath に置換すれば lxml で評価できます。
独立したレビューに出したら、まだ穴があった
ここまでで「全54帖が通り、壊したデータでは検出される」ところまで来ていました。 それでも念のため、観点を分けた複数のレビューにかけました。結果、Schematron 側に 実際の欠陥が4件、加えてそれらが動く前提の緩さが1件見つかりました。 どれも自分では気づけていなかったものです。
xs:integer() は文書全体の検証を止める
pb-monotonic はこう書いていました。
<sch:report test="xs:integer( @n ) le xs:integer( $prev )" role="error">
ページ番号に 7ウ のような数字以外の値が入ると、こうなります(この底本は洋装本なので
実際には起きませんが、和装本の丁付けを扱う際には現実に起きます)。
Error code: err:FORG0001 Cannot convert string "7ウ" to an integer
saxon exit code = 2
出力された SVRL: 0 バイト
例外で止まるので、その文書の他の制約が一切検査されません。しかも SVRL が
生成されないため「エラー0件」と区別がつきにくいところです。castable as xs:integer を
context の条件に足して、変換できるときだけ比較するようにしました。
直前の pb の取り方を間違えていた
<!-- 誤 -->
<sch:rule context="tei:pb[ @n ][ preceding::tei:pb/@n ]">
<sch:let name="prev" value="preceding::tei:pb[1]/@n"/>
context は「いずれかの先行 pb が @n を持つ」を条件にしているのに、
$prev は「最近接の pb」を読んでいます。@n を持たない pb が間に挟まると
$prev が空になり、@n le () の結果は空列になって、sch:report の条件としては
偽として扱われます。つまり報告されずに通過します。
6, <pb/>(@n なし), 2 という並びで実際に 0 件でした。
preceding::tei:pb[@n][1] に統一して直しました。
@corresp は1つとは限らない
@corresp は att.global.linking 由来で、データ型は teidata.pointer のリストです。
空白区切りで複数書けます。substring-after(@corresp, '#') で切っていたので、
corresp="#zone_0006 #zone_0007" は "zone_0006 #zone_0007" という
1つの文字列になり、存在しない id として誤検出していました。
tokenize() して全トークンを検査する形に直したところ、
「# で始まらない値」も同時に捕まえられるようになりました。
<desc> に書いたことを、テストが守っていなかった
<desc xml:lang="ja">change/@who は teiHeader 中の respStmt を指す。</desc>
...
<sch:assert test="//*[ @xml:id eq $id ]">
「respStmt を指す」と書いておきながら、テストは文書中の任意の要素を許していました。
who="#waka-001"(和歌の id)でも通ってしまいます。//tei:teiHeader//tei:respStmt に
限定しました。
そして、Schematron が働く前提そのものが緩かった
これが一番大きい指摘でした。和歌の2つの制約はどちらも
context="tei:lg[ @type eq 'waka' ]" です。ところが @type は
att.typed 由来で optional かつ自由値でした。つまり、
@typeを書き忘れる、あるいはWakaと打ち間違えると、 2つの制約がエラーも警告も出さずに検査対象から外れます。3句の和歌が通過します。
同じ構造が pb/@corresp(無ければ参照検査が走らない)、
pb/@n(無ければ順序検査が走らない)、seg/@corresp にもありました。
データ側は 795/795 が type="waka"、1812/1812 が @corresp と @n を持ち、
25065/25065 が seg/@corresp を持ちます。つまり全部必須にしても現データは1件も壊れません。
そうしました。
Schematron の context に属性を書いたら、その属性は文法で必須にしておく。 さもないと、属性を1つ落とすだけで制約が静かに無効になります。
生成物が毎回変わって、CI が常に落ちた
CI に「ODD から再生成した結果が、コミット済みの生成物と一致するか」を入れたところ、 初回から落ちました。
TEI Stylesheets は生成時刻を出力に埋め込みます。3つとも別々の書式です。
.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.
同じ ODD から作ってもファイルが変わるので、git diff --exit-code は通りません。
時刻だけを固定文言に置き換える後処理を入れて、ビルドを決定的にしました。
2回連続でビルドして3ファイルとも一致することを確認しています。
結果
$ ./scripts/validate.zsh
=== RELAX NG (54 ファイル) ===
=== Schematron (54 ファイル) ===
指摘 0 件。54 ファイルすべてが RELAX NG と Schematron を通過しました。
公開後、配信されているスキーマを取得して確かめました。
$ curl -sL -o live.rng https://kouigenjimonogatari.github.io/schema/tei_kouigenji.rng
$ jing live.rng xml/master/*.xml | wc -l
0
795 件が 0 件です。
そして、わざと壊したファイルではきちんと出ます。
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")
「正しいデータで出ないこと」と「壊れたデータで出ること」の両方を確認して、はじめて 検証が機能していると言えます。今回のように、スキーマが壊れていても、 検証していなければ気づけないからです。
再発しないようにする
同じことが起きる条件は2つありました。ODD の指定が間違っていても知らせてもらえないことと、 その結果を実データで確かめていなかったことです。前者は避けられないので、 後者を CI に肩代わりさせます。あわせて、コミット済みの生成物が ODD と食い違わないことも 押さえておきます。
- name: Build schema from ODD
run: ./scripts/build-schema.zsh
# ODD が正本である以上、生成物がそれと食い違っていてはいけない
- name: Generated schema must match what is committed
run: |
if ! git diff --exit-code -- docs/schema/; then
echo "::error::docs/schema/ の生成物が ODD と一致しません。"
exit 1
fi
- name: Validate all chapters
run: ./scripts/validate.zsh
全54帖の検証は手元で 28 秒で終わります。内訳は jing が 0.3 秒、残りはすべて Schematron で、 Saxon の JVM を54回起動するコストがそのまま出ています。 「文法で書けるものは文法に置く」という判断は、速度の面でも効いていました。
当初はデプロイ用のワークフローと分けていましたが、それでは検証が落ちても公開は進んでしまいます。
再利用可能ワークフロー(workflow_call)にして、デプロイ側から呼ぶ形に変えました。
jobs:
validate:
uses: ./.github/workflows/validate.yml
build:
needs: validate
...
これで検証を通らない限りサイトは公開されません。実際、この仕組みを入れた直後の push で門番が働きました。ただし止めた理由は、上に書いた「生成物が毎回変わる」問題の方でした。
まとめ
ワークショップの成果物として持ち帰るつもりだったのは Schematron のルールでしたが、 実際に持ち帰ったのは「ODD は間違いを報告しない」という一点でした。 一日のうちに、同じ型を何度も踏んでいます。
| 書いたこと | 実際に起きたこと |
|---|---|
<moduleRef key="verse" include="lg l"/> | verse に lg/l は無い → 何も取り込まれない |
<attDef ident="id" ns="…/XML/1998/namespace"/> | att.global は ident="xml:id" → 一致せず無視 |
<datatype> に maxOccurs を書かない | 既定の 1 が効き、多値が書けなくなる |
<dataRef key="teidata.pointer" restriction="…"/> | @restriction が捨てられ、書式制限が消える |
<persName> を ODD の teiHeader に書く | ODD 用スキーマは制限版で persName を含まない |
最後のものだけは jing がエラーを出します。それ以外は、いずれも無言のまま進みます。
生成されたスキーマは正常に見えますし、ODD 自体も妥当なままです。
実データを通すまで、何も分かりません。
さらに、Schematron にはもう一段の静かさがあります。
context にマッチしなければルールは発火しませんが、発火しなかったことも報告されません。
@type を1つ書き忘れるだけで制約が消えます。
「検査した結果の0件」と「検査していないので0件」は、出力からは区別できません。
今回それを、各ルールが実データで何ノードにマッチしたかを数えて、はじめて確かめました。
まとめると、次のようになります。
- ODD を書いたら、実データを通す。「スキーマができた」は「動いている」ではない
- 正しいデータで出ないことと、壊したデータで出ることを両方確かめる
- ルールが何件にマッチしたかを数える。0件が沈黙なのか合格なのかを分ける
- Schematron の context に属性を書いたら、その属性は文法で必須にする
- そして、確かめたことを CI に覚えさせる
<constraintSpec> を書く価値は、規則を宣言できることよりも、
宣言した規則が本当に動いているかを機械が繰り返し確かめてくれることにあると思います。
関連: TEI の ODD に Schematron を埋め込んで VS Code で検証する
題材データ: 校異源氏物語テキストデータベース(底本: 池田亀鑑『校異源氏物語』中央公論社。TEI データは CC0 1.0)
動作を確認した環境: 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

コメント
…