reStructuredTextでruby(ふりがな)のマークアップ

あとで読みかえしたいページをちまちまとスクレイピングしては reStructuredText に落しこんで、そこそこたまると EPUB にまとめてリーダーアプリでメモしたりしています。 reST には html の ruby element に相当するものがありません。もちろんサポートしているモジュールは見つかりますが、 docutils をすこし拡張すればできそうなので、やってみました。

とりあえず reST の拡張といえばカスタム Directives の利用ですが、インラインマークアップにするために Substitution References でカスタム Directives と入れ替えします。

|君子|\ は\ |和|\ して\ |同|\ ぜず。

.. |君子| ruby:: 君[くん]子[し]
   :delimiters: []
.. |和| ruby:: 和(わ)
.. |同| ruby:: 同(どう)

さすがにこれは面倒なので、つぎに Interpreted Text Roles の使用を考えます。 Specialized Roles として “raw” が提供されていますが、これは reST の可読性が著しく低下してしまいます。現在マークダウンを利用することが多いですが、プレーンテキストとしてエディタで開いたときは reST のほうが読みやすいと思っています。

.. role:: raw-html(raw)
   :format: html
   
:raw-html:`<ruby>君<rp>(</rp><rt>くん</rt><rp>)</rp>子<rp>(</rp><rt>し</rt><rp>)</rp></ruby>は<ruby>和<rp>(</rp><rt>わ</rt><rp>)</rp></ruby>して<ruby>同<rp>(</rp><rt>どう</rt><rp>)</rp></ruby>`ぜず。

最後ですが、カスタム Roles を利用するのがいちばんマシに見えました。

:rubi:`君(くん)子(し)`\ は\ :rubi:`和(わ)`\ して\ :rubi:`同(どう)`\ ぜず。

.. role:: rubi-sq(rubi)
   :delimiters: []

:rubi-sq:`君[くん]子[し]`\ は\ :rubi-sq:`和[わ]`\ して\ :rubi-sq:`同[どう]`\ ぜず。

以下、カスタム Roles や Directives を登録するためのコードです。

from docutils.core import publish_string
from docutils.nodes import TextElement, Inline, Text, unescape
from docutils.parsers import rst
from docutils.parsers.rst import directives
from docutils.writers.html4css1 import Writer, HTMLTranslator
import re

# ruby node
class ruby(Inline, TextElement): pass
class rt(Inline, TextElement): pass
class rp(Inline, TextElement): pass

def build_node(rawtext, node, delimiters='()'):
    dlm = re.escape(delimiters)
    rx = re.compile(f"([^{dlm}]+)(\s*[{dlm}]\s*)([^{dlm}]+)(\s*[{dlm}]\s*)")
    for m in re.finditer(rx, unescape(rawtext)):
        rbase, rpl, rant, rpr = m.groups()
        node += [
            Text(rbase),
            rp(rawtext, rpl),
            rt(rawtext, rant),
            rp(rawtext, rpr),
        ]
    return node


# ruby directive
class Ruby(rst.Directive):
    required_arguments = 1
    option_spec = {'delimiters': directives.unchanged,}

    def run(self):
        rawtext = self.arguments[0]
        node = ruby(rawtext, '', **self.options)
        return [build_node(
            rawtext,
            node,
            self.options.get('delimiters')
        )]

directives.register_directive('ruby', Ruby)


# rubi role
def rubi_role(role, rawtext, text, lineno, inliner, options=None, content=None):
    options = roles.normalized_role_options(options)
    node = ruby(rawtext, '', **options)
    return [build_node(
        rawtext,
        node,
        options.get('delimiters')
    )], []

ruby_role.options = {'delimiters': directives.unchanged}
roles.register_canonical_role('rubi', rubi_role)


# Transformer and Writer
class MyWriter(Writer):
    def __init__(self):
        self.parts = {}
        self.translator_class = MyHTMLTranslator

class MyHTMLTranslator(HTMLTranslator):
    def visit_ruby(self, node):
        self.body.append(self.starttag(node, 'ruby', suffix=''))

    def depart_ruby(self, node):
        self.body.append('</ruby>')
        
    def visit_rp(self, node):
        self.body.append(self.starttag(node, 'rp', suffix=''))

    def depart_rp(self, node):
        self.body.append('</rp>')
        
    def visit_rt(self, node):
        self.body.append(self.starttag(node, 'rt', suffix=''))

    def depart_rt(self, node):
        self.body.append('</rt>')


# Publisher
with open(source) as src, \
     open(destination, 'wb') as out:
    output = publish_string(src.read(), writer=MyWriter())
    out.write(output)

テキストをパーズして rt (base text) 部と rp (annotation) 部をとりだしているため、入れ子状の ruby を構成するにはまだ工夫が必要ですが、現状ふりがなとしての使用しか考えていないのでしばらくこれで運用します。

(gist)