本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

朝鮮司譯院刊『清語老乞大』巻之一・第一葉表を題材に、TEI/XML で版面を符号化し、XSLT で縦書きの HTML 再現を生成する学術用デモを公開しました。

ページを開くと、影印画像 / XSLT による HTML 再現 / TEI XML ソース、の 3 列が並列に並びます。同じ内容を 3 つの形態で対比して読むためのレイアウトです。

3 パネル並列:影印画像 / HTML 再現 / TEI/XML ソース

対象資料:清語老乞大とは

『清語老乞大』(チョンオ・ノゴルデ)は朝鮮王朝の司譯院(外国語通訳養成の官署)が編んだ満州語の会話学習書です。元代の漢語会話書『老乞大』を満州語に翻訳し、各語の右側に小字でハングルによる音注、各文の下に中世韓国語訳を割書(双行に分けた小字)で添える形を取ります。康熙〜乾隆年間(17 世紀末〜18 世紀後半)に何度か刊行されました。

ひとつの版面の中に、

  • 満州文字(モンゴル文字派生の縦書きスクリプト)の本文
  • 各語の右側に添えたハングル音注
  • 文末に置いた中世韓国語訳の割書

3 層の異なる文字体系・配置規則が共存する、というのが版面構造上の特徴です。

データと可視化を分離する

このプロジェクトでは「TEI/XML(データ)」と「XSLT による HTML + SVG 描画(可視化)」を意図的に分離しました。データ側に版面構造の意味を残し、可視化側はそれを読み解いて表示する、という階層にしてあります。

データ側は TEI 標準要素のみで書きます。ベンダー拡張や独自要素は使いません。版面の物理構造の表現には次の要素を割り当てました。

TEI 要素用途
<cb n="N"/>列の境界。文の途中での改列も自然に表現できる
<w xml:lang="mnc-Mong">満州語の各語(満州文字本体を子テキストとして保持)
<note type="phon" place="marginRight">各語の右側に添えられたハングル音注
<note type="warigaki">中世韓国語訳の割書(内部に <lb/> を置いて 2 行に分割)
<pb/>, <surface>, <graphic>影印画像との対応

TEI All RelaxNG スキーマで xmllint --relaxng を通すと valid と判定されます。標準要素だけで「縦書き多層」をかなり表現できることが分かります。

curl -sSL -o /tmp/tei_all.rng \
  https://tei-c.org/release/xml/tei/custom/schema/relaxng/tei_all.rng
xmllint --noout --relaxng /tmp/tei_all.rng docs/laoqida.xml
# → docs/laoqida.xml validates

@place には TEI 推奨値のうち最も近い marginRight を採用しました。スキーマ上は open list なので right でも valid ですが、TEI ガイドラインが例示する semi-closed value list(top bottom margin inline 等)に沿うと marginRight のほうが慣用的です。

XML としては次のような形になります。

<cb n="1"/>
<s n="s01">
  <w xml:lang="mnc-Mong">ᠠᠮᠪᠠ<note type="phon" place="marginRight" xml:lang="mnc-Kore">암바</note></w>
  <w xml:lang="mnc-Mong">ᠠᡤᡝ<note type="phon" place="marginRight" xml:lang="mnc-Kore">아거</note></w>
  <w xml:lang="mnc-Mong">ᠰᡳ<note type="phon" place="marginRight" xml:lang="mnc-Kore"></note></w>
  <w xml:lang="mnc-Mong">ᠠᡳᠪᡳᠴᡳ<note type="phon" place="marginRight" xml:lang="mnc-Kore">애버치</note></w>
  <w xml:lang="mnc-Mong">ᠵᡳᡥᡝ<note type="phon" place="marginRight" xml:lang="mnc-Kore">지허</note></w>
  <note type="warigaki" xml:lang="ko">큰 형아 네 어<lb/>디로셔 온다</note>
</s>

ポイントは 2 点あります。1 つは「文の途中に <cb/> が割り込めること」。たとえば <s> の中で <w>...<cb n="2"/>...<w>... のように、文の途中で改列されても TEI 上は同じ <s> の連続として扱えます。これは版面の物理的な改列が文の論理的な区切りと一致しない、という古典籍の特徴を直接表現します。

もう 1 つは「<note> を単に注釈ではなく、版面上の位置情報を持つ要素として使うこと」。type 属性で意味(音注 / 割書)を、place 属性で物理位置(右添え)を表します。可視化側はこの属性を読み取ってレイアウトを決めます。

XSLT で列に切り出す

XSLT 1.0 で書いた変換スクリプト(docs/laoqida.xsl)が、上記の TEI を HTML に変換します。XSLT 1.0 にしているのは xsltproc でローカル変換でき、ビルド時に静的 HTML を吐けるからです(ブラウザ内蔵の XSLT エンジンを当てにする運用は最近では非推奨です。Chrome は v158 で XSLT 対応を削除予定と告知されており、Edge も追随、WebKit / Firefox も削除を表明しています。ビルド時変換に倒しておくと将来も安心です)。

可視化の中核は「<cb/> をキーに、文書順の <w><note type="warigaki"> を列ごとにグループ化する」処理です。

<xsl:variable name="all-cbs" select="//tei:cb"/>

<xsl:template match="tei:cb" mode="column">
  <xsl:variable name="this-cb" select="."/>
  <xsl:variable name="cb-pos" select="count(preceding::tei:cb) + 1"/>
  <xsl:variable name="next-cb" select="$all-cbs[position() = $cb-pos + 1]"/>

  <div class="col manchu-col">
    <xsl:choose>
      <xsl:when test="$next-cb">
        <xsl:apply-templates
          select="following::tei:w[count(. | $next-cb/preceding::tei:w)
                                  = count($next-cb/preceding::tei:w)]
                | following::tei:note[@type='warigaki']
                  [count(. | $next-cb/preceding::tei:note)
                   = count($next-cb/preceding::tei:note)]"
          mode="content"/>
      </xsl:when>
      <xsl:otherwise>
        <!-- 最終列:以降のすべて -->
        <xsl:apply-templates
          select="following::tei:w | following::tei:note[@type='warigaki']"
          mode="content"/>
      </xsl:otherwise>
    </xsl:choose>
  </div>
</xsl:template>

count(. | $next-cb/preceding::tei:w) = count($next-cb/preceding::tei:w) という条件式は、XSLT 1.0 における set-membership idiom です(XSLT 2.0 以降の intersect 演算子の代わりに使う定石)。ノード集合の合併サイズと元集合のサイズが等しいなら、対象ノードは元集合に含まれている、という判定で、ここでは「次の <cb/> より前にある <w> だけを選ぶ」を表現しています。なお Steve Muench 由来の "Muenchian grouping" は厳密には xsl:keygenerate-id() を使うグループ化の別パターンを指すので、ここは set-membership と呼ぶほうが正確です。

満州文字の描画:CSS writing-mode に倒す

縦書き満州文字を Web で出すとき、ぱっと思いつく選択肢は次の 3 つです。

  1. HTML 要素に CSS writing-mode: vertical-lr を当て、Mongolian 対応フォントを読ませる
  2. SVG <text> の中で writing-mode: vertical-lr を指定し、SVG 経由で描画する
  3. HarfBuzz 等でビルド時にプリレンダーした PNG / SVG を埋め込む

最初は安全策に見えた 2 を採用していましたが、これは iOS Safari で正しく動かないケースがある ことが分かりました。WebKit の SVG <text> 上の writing-mode 実装には歴史的にバグがあり (WebKit Bug 112488)、Mongolian の文字方向についても iOS 17.4 / Safari 17.4(2024 年 3 月)でようやく修正されたという経緯があります (WebKit commit 6c53862w3c/mlreq#39)。なお writing-mode を SVG <text> の presentation attribute として書く形は SVG 2 で deprecated 扱いです(MDN)。

W3C の Mongolian Layout RequirementsVertical text styling が推奨しているのは、SVG ではなく HTML + CSS writing-mode: vertical-lr; text-orientation: mixed を直接当てる路線です。Safari 17.4 以降は Mongolian / Phags-pa の上下方向も正しく描画されるため、現時点の主要ブラウザ(macOS / iOS Safari, Chrome, Firefox, Edge)で安定して動きます。

ということで実装はシンプルになりました。

<xsl:template match="tei:w" mode="content">
  <xsl:variable name="m-text" select="normalize-space(text()[normalize-space()][1])"/>
  <xsl:variable name="phon" select="tei:note[@type='phon']"/>
  <span class="word">
    <span class="m-text" lang="mnc-Mong"><xsl:value-of select="$m-text"/></span>
    <span class="m-pron"><xsl:value-of select="normalize-space($phon)"/></span>
  </span>
</xsl:template>
.m-text {
  writing-mode: vertical-lr;
  text-orientation: mixed;
  font-family: "Noto Sans Mongolian", "Mongolian Baiti", serif;
  font-size: 22px;
  line-height: 1.05;
  white-space: nowrap;
  display: inline-block;
}

SVG 経由をやめたことで、満州文字本体が 本物の DOM テキストになり、ユーザーの選択・コピー・読み上げ・フォントスケールがすべて素直に効くようになりました。語長に応じた高さ計算も不要です(縦書きフローが自動でレイアウトする)。

HTML 再現パネルの拡大:満州文字の縦書きとハングル音注

iPhone Safari でも縦並びレイアウトで 3 セクションが順に表示されます。

モバイル表示(iPhone 14 Pro 相当のビューポート)

なお、フォント自体の供給状況にも触れておきます。macOS と iOS にはデフォルトで Mongolian script 対応のシステムフォントが入っていません("Mongolian Baiti" は Windows / Office に付属する Microsoft のフォントです)。本デモは Google Fonts の Noto Sans Mongolian を @font-face で読ませて埋める前提です。配信先がオフライン環境になる場合は WOFF2 を self-host するのが堅いです。

右添えハングルと、割書(warigaki)諺解

満州文字 SVG の右側にハングル音注を置く部分は flexbox の素直な使い方です。

.word {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: .08rem;
}
.m-pron {
  writing-mode: vertical-lr;
  font-family: "Noto Serif KR", "Apple SD Gothic Neo", serif;
  font-size: .68rem;
  /* ... */
}

<w>.word の flex 行で、左に満州文字 SVG、右にハングル音注の縦書き列、を並べます。

割書諺解は、版面では 1 つの列の幅の中に細い字を 2 行並べて入れるという独特の表現です。これも flexbox で 2 つの .wari-line を並べる形に落とし込みました。XSLT 側で <note type="warigaki"> の中の <lb/> 区切りでテキストノードを 2 つに割り、それぞれを縦書きの 1 行として出します。

<xsl:template match="tei:note[@type='warigaki']" mode="content">
  <xsl:variable name="lines" select="text()[normalize-space()]"/>
  <span class="warigaki">
    <span class="wari-line"><xsl:value-of select="normalize-space($lines[1])"/></span>
    <span class="wari-line"><xsl:value-of select="normalize-space($lines[2])"/></span>
  </span>
</xsl:template>

割書の 2 行をどう数えるか、というのは古典籍ごとに揺れがあります。本デモでは「<note type="warigaki"> の直下のテキストノードを上から数えて 2 つ」、つまり <lb/> で区切られた前後を取る、という単純なルールにしました。3 行以上を許す本格的なデータでは <lb/> ごとに繰り返す形に拡張する余地があります。

列の進行方向

満州文字版面では 題簽が左端、本文の列は左から右へ進み、各列は上から下に読む、という方向です。漢籍の縦書きが右→左に進むのとは逆向きで、これはモンゴル文字系の伝統に由来します。

CSS でこれを再現するときの落とし穴は、ブラウザの writing-mode: vertical-rl(中国・日本語の縦書き)と writing-mode: vertical-lr(モンゴル系)の違いを混同しないことです。漢籍用に書かれた CSS をそのまま流用すると、列の並びが鏡像になります。本デモでは:

  • 列の並び: flexbox の flex-direction: row(左→右)
  • 各列の中の文字方向: writing-mode: vertical-lr

を組み合わせています。

影印・HTML 再現・TEI XML の 3 列対比ビュー

ページ全体は影印画像 / XSLT 出力 / XML ソース の 3 列を横並びにして、同じ位置の内容を 3 形態で比較できるようにしました。XML ソース欄は JavaScript で laoqida.xml を fetch して <body> 要素だけを抜き出し、簡易シンタックスハイライト付きで <pre> に流し込みます。

fetch('laoqida.xml', { cache: 'no-store' })
  .then(r => r.text())
  .then(raw => {
    const doc = new DOMParser().parseFromString(raw, 'application/xml');
    const bodyEl = doc.getElementsByTagNameNS(
      'http://www.tei-c.org/ns/1.0', 'body')[0];
    const src = new XMLSerializer().serializeToString(bodyEl);
    // ... タグ・属性・文字列・コメント・PI を span で囲むだけの簡易ハイライト
    pre.innerHTML = src
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/(&lt;\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*"[^"]*")*)\s*(\/?&gt;)/g,
        /* ... */ );
  });

外部ライブラリを入れずに DOM 標準だけで完結させているのは、配布物を 1 つの HTML + CSS + JS に閉じ込めるためです(XSLT が事前に流すスタイルシートと併せて 3 ファイルで動きます)。

デプロイ

docs/ 配下を Vercel と GitHub Pages の両方に配信しています。vercel.jsonoutputDirectory: docs を指定し、/laoqida.xml のレスポンスに Content-Type: application/xml; charset=utf-8 を明示するだけのシンプルな設定です。

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "outputDirectory": "docs",
  "cleanUrls": true,
  "trailingSlash": false,
  "headers": [
    {
      "source": "/laoqida.xml",
      "headers": [
        { "key": "Content-Type", "value": "application/xml; charset=utf-8" }
      ]
    }
  ]
}

xsltproc docs/laoqida.xsl docs/laoqida.xml > docs/index.html を手元で実行すれば、デプロイ時には静的 HTML として配信されます。ブラウザ内蔵の XSLT を使う形にしておくと配信は更に薄くできますが、初回表示時のクライアント側コストとファビコン・OG 画像のクロール挙動を考えると、ビルド時に HTML を吐く方が運用が楽でした。

残課題

  • 影印画像と TEI のセル単位の双方向リンク。現状は <surface><pb/> でページ全体は紐付いていますが、<zone> 単位の bbox は未付与で、影印上の位置とのクリック連動はできていません。
  • 第一葉裏以降の符号化。デモは第一葉表のみです。
  • 影印画像が原本そのものか写本・翻刻かの確認。本ページは TEI 符号化と XSLT による縦書きレイアウトの再現を目的とした学術用デモで、原本同定までは踏み込めていません。
  • 音注(中世韓国語ハングル)と諺解の判読は底本影印からの推定を含みます。誤りがあれば docs/laoqida.xml の該当 <w> または <note> を修正します。

参考