デジタル延喜式は、延長5年(927年)に完成した律令の施行細則集『延喜式』を TEI (Text Encoding Initiative) XML で符号化し、Web上で閲覧・検索できるようにするプロジェクトです。国立歴史民俗博物館を中心に、校訂文・現代語訳・英訳を TEI でマークアップし、Nuxt.js(Vue.js)ベースのビューアで公開しています。

この開発の中で、TEI XML のスタンドオフ(standoff)注釈をインライン注釈に変換する処理において、XML の文書構造が崩壊するバグに遭遇しました。本記事では、その原因と DOM 操作ベースの解決策を記録します。

スタンドオフ注釈とは

TEI XML では、テキスト中の校異(variant readings)を記録する方法として、スタンドオフ方式がよく使われます。デジタル延喜式では、複数の写本間のテキストの異同を <app> 要素で記録しており、テキスト中に <anchor> 要素で範囲を示し、対応する <app> 要素を別の場所に置く構造になっています。

<p>
  前テキスト
  <anchor xml:id="app001"/>
  校異対象のテキスト
  <anchor xml:id="app001e"/>
  後テキスト
</p>

<!-- 別の場所に校異情報 -->
<app from="#app001" to="#app001e">
  <lem>校異対象のテキスト</lem>
  <rdg wit="#写本A">異なるテキスト</rdg>
</app>

この方式は、XML のネスト制約を回避できる利点があります。校異の範囲が要素境界をまたぐ場合(overlapping hierarchy)でも、anchor はどこにでも置けるためです。

インライン化の理由

XML ツリーと UI コンポーネントツリーの対応

デジタル延喜式のビューアは Vue.js で構築しています。Vue.js や React のようなコンポーネントベースのフレームワークでは、UI はツリー構造で記述されます。TEI XML もツリー構造なので、XML の各要素を UI コンポーネントに 1:1 でマッピングする再帰レンダリングが自然なアプローチになります。

<!-- TEI.vue: XML要素を再帰的にコンポーネントにマッピング -->
<template>
  <component v-for="child in element.children"
             :is="getComponent(child.tagName)"
             :element="child" />
</template>

この設計では、<app> 要素がテキスト中にインラインで存在すれば、ツリーの走査だけでレンダリングできます。

<!-- インライン化後: ツリー走査でそのまま描画可能 -->
<p>
  前テキスト
  <app xml:id="app001">
    <note type="base">校異対象のテキスト</note>
    <lem>校異対象のテキスト</lem>
    <rdg wit="#写本A">異なるテキスト</rdg>
  </app>
  後テキスト
</p>

スタンドオフのままだと、レンダラがテキスト描画の途中で <anchor> に遭遇するたびに、ツリーの別の場所にある <app> を検索して注入する処理が必要になります。ツリー走査の途中で別のノードを参照することになり、再帰レンダリングとの相性がよくありません。

副次的なメリットとして、検索インデックスの構築が容易になること(各 <p> の全テキスト走査で完結する)、静的サイト生成との相性がよいこと(ランタイムに複雑なロジックが不要)なども挙げられます。

ただし、これはアーキテクチャ上の選択であり、Web Annotation モデルのようにスタンドオフのまま表示レイヤーでオーバーレイする方式も選択肢としてはあります。

スタンドオフとインラインの相互変換可能性

そもそもスタンドオフとインラインは自由に相互変換できるのか、という点を整理しておきます。

インライン → スタンドオフ: 常に可能

任意のインライン注釈は、機械的にスタンドオフに変換できます。インライン要素の開始・終了位置に anchor を挿入し、要素本体を別の場所に移すだけで、情報の損失はありません。

スタンドオフ → インライン: 常に可能とは限らない

注釈範囲が重複(overlap)する場合、well-formed な XML にできません。これは TEI コミュニティで「overlapping hierarchy 問題」として知られる制約です。

<!-- 2つの注釈範囲が部分的に重なる -->
テキスト: あ い う え お
注釈A:       [い う え]
注釈B:          [う え お]

<!-- インライン化を試みると… -->
あ <A>い う <B>え</A> お</B>   ← XMLとして不正

XML は要素のネストが必須であり、部分的に重なる2つの要素を表現できません。スタンドオフ方式はまさにこの制約を回避するために設計されています。

デジタル延喜式のケース

今回のバグの対象である校異注(<app>)は、既存の構造要素(<measure>)の境界をまたいでいました。

<measure>...<anchor start/>...</measure>...<measure>...<anchor end/>...</measure>

<app> の範囲と <measure> の範囲が重複しており、<app><measure> の中にも外にも正しくネストできません。そのため、現在の実装では anchor 間のコンテンツをコピーして <note> に格納する近似的な変換を行っています。厳密なインライン化ではなく、コンテンツの複製を伴う回避策です。

方向可能性条件
インライン → スタンドオフ常に可能無条件
スタンドオフ → インライン条件付き注釈範囲が他の要素と重複しない場合のみ
スタンドオフ → インライン(重複あり)近似的に可能コンテンツ複製・要素分割等の回避策が必要

この非対称性は、スタンドオフがデータの正規表現(canonical form)として優れている理由でもあり、インライン化には本質的な複雑さが伴うことを示しています。

要素境界をまたぐ anchor ペア

多くの校異は同一親要素内に収まりますが、一部は要素境界をまたぎます。延喜式では、貢納物の数量を <measure> 要素でマークアップしており、校異の範囲がこの <measure> の境界をまたぐケースがあります。

<!-- 延喜式 巻24 主計寮上: 伊予国の調 -->
<p>
  <measure quantity="4">
    <anchor xml:id="app001"/>
    <unit unitRef="#疋"></unit>
  </measure>
  <measure commodity="#緋帛">
    <anchor xml:id="app001e"/>
  </measure>
</p>

anchor_start は最初の <measure> 内、anchor_end は次の <measure> 内にあり、DOM の next_sibling では到達できません。

旧実装: 文字列操作 + 再パース

当初の実装では、DOM 操作では対処しきれないと判断し、XML 全体を文字列化→文字列操作→再パースという方式を採用していました。

# 旧実装(簡略化)
soup_str = str(self.soup)                          # XML全体を文字列化
between = soup_str.split(start_str)[1].split(end_str)[0]  # anchor間を抽出

# anchor間コンテンツを文字列から削除
start_pos = soup_str.index(start_str) + len(start_str)
end_pos = soup_str.index(end_str)
soup_str = soup_str[:start_pos] + soup_str[end_pos:]

# 文字列からDOMを再構築
self.soup.__init__(soup_str, "xml")  # ← ここが問題

崩壊の原因

anchor 間のコンテンツには、構造タグの開始・終了が含まれます。

四 <unit>疋</unit> </measure> 、 <measure ...> 緋
                    ^^^^^^^^^     ^^^^^^^^^^^^
                    構造タグの閉じ   構造タグの開き

これを文字列から削除すると、XML の入れ子構造が壊れます。

<!-- 削除前 -->
<measure quantity="4">...<anchor start/><unit></unit></measure><measure><anchor end/>...</measure>

<!-- 削除後(壊れたXML) -->
<measure quantity="4">...<anchor start/><anchor end/>...</measure>

閉じタグ </measure> と開きタグ <measure> が消えたため、XML として well-formed ではなくなります。BeautifulSoup のパーサーは壊れた XML を「修復」しようとしますが、その結果、文書全体の構造が意図しない形に再編成されます。

デジタル延喜式では、校訂文(<div type="original">)と現代語訳(<div type="japanese">)を同一ファイル内で管理していますが、この再パースにより現代語訳の <div> の内容が校訂文の <div> に移動してしまいました。その結果、検索インデックスの生成時に巻24(主計寮上)の 87 件中 84 件のデータが欠落し、「鰒」「緋」などの語句で検索しても該当巻がヒットしないという障害につながりました。

解決策: DOM 操作ベースの範囲操作

文字列操作を排除し、BeautifulSoup の DOM 操作のみで実装し直しました。

共通祖先からのフェーズ分解

2つの anchor が異なる親要素にある場合、最近共通祖先(Lowest Common Ancestor)を基準に処理を分解します。

def _collect_between_content(self, anchor_start, anchor_end):
    common = self._find_common_ancestor(anchor_start, anchor_end)
    start_path = self._path_to_ancestor(anchor_start, common)
    end_path = self._path_to_ancestor(anchor_end, common)
          common (p)
         /     |     \
   measure_A   、   measure_B
   /    \              |
anchor_s  unit      anchor_e

5つのフェーズに分けて収集・削除を行います。

Phase対象操作
1anchor_start の後続兄弟親内で収集(anchor_end を含む要素は部分収集)
2start 側パス上の後続兄弟各レベルで上方に辿りながら収集
3common 直下の中間兄弟start_child と end_child の間を収集
4end 側パス上の先行兄弟各レベルで下方に辿りながら収集
5anchor_end の先行兄弟親内で収集

anchor_end を含む要素の部分処理

Phase 1 で、anchor_start の兄弟が anchor_end を子孫として含む場合があります(例: <unit> 内に anchor_end がある場合)。この場合、要素全体を収集するのではなく、anchor_end より前のコンテンツのみを再帰的に収集します。

def _collect_siblings_after_safe(self, element, anchor_end, collected):
    current = element.next_sibling
    while current:
        if self._contains(current, anchor_end):
            # anchor_end を含む要素 → 内部を部分収集
            self._collect_up_to_anchor(current, anchor_end, collected)
            break
        collected.append(self._deep_copy_node(current))
        current = current.next_sibling

削除も同じパターンで、anchor_end を含む要素は部分削除します。これにより、構造タグを壊さずにコンテンツの収集・削除が可能になります。

Well-formedness の保証

DOM 操作は常に well-formed なツリーを維持するため、変換後の XML が壊れることがありません。さらに、変換後に再パースして構造が一致することを検証するテストを追加しました。

def test_cross_parent_preserves_document_structure():
    # 変換実行
    replacer = AppReplacer(soup, verbose=False)
    replacer.process()

    # 再パースして構造を比較
    reparsed = BeautifulSoup(str(soup), "xml")

    original = reparsed.find("div", type="original")
    japanese = reparsed.find("div", type="japanese")
    assert original is not None
    assert japanese is not None

    # 校訂文divに現代語訳のitemが混入していないこと
    for item in original.find_all("p", ana="項"):
        assert not item.get("xml:id", "").startswith("ja-")

設計についての所感

文字列操作で XML を編集するリスク

XML の部分的な文字列操作は、構造タグの対応関係を壊すリスクが常にあります。一見うまくいくケースでも、特定のデータパターンで崩壊する可能性があり、今回のように問題の発覚が遅れることもあります。DOM 操作はやや冗長ですが、構造の整合性が保証されます。

ビルド時変換のテスト

スタンドオフ→インライン変換をビルドパイプラインに組み込む場合、変換処理に複雑さが集中します。安全網として、以下のようなテストが有効でした。

  • 構造保全テスト: 変換後の XML を再パースして、div 構造が維持されていることを検証
  • アイテム数テスト: 各巻の校訂文・現代語訳のアイテム数が期待値と一致することを検証
  • 混入テスト: 言語別 div に他言語のアイテムが混入していないことを検証

DOM Range 操作の汎用性

今回実装した「2点間のコンテンツ収集・削除」のパターン(共通祖先探索→パス分解→フェーズ別処理)は、ブラウザの DOM Range API と本質的に同じ操作です。BeautifulSoup や lxml にはこの機能が組み込まれていないため、TEI に限らず XML/HTML のクロス要素境界処理で再利用できる可能性があります。

まとめ

  • スタンドオフ→インラインの変換には非対称性があり、注釈範囲の重複(overlapping hierarchy)がある場合はコンテンツ複製等の回避策が必要になります
  • Vue.js / React の再帰レンダリングとの親和性や、検索インデックス構築の容易さから、ビルド時のインライン化は実用的な選択です
  • 文字列操作 + 再パースは構造タグの対応を壊すリスクがあり、DOM 操作ベースの共通祖先からのフェーズ分解で安全に変換できます
  • ビルド時変換を採用する場合、構造保全テストによる品質担保が重要です