ルビ付けにおける正規表現による送り仮名の処理

最近作っている日本語学習者向けの Telegram BOT [@jadicts_bot](https://telegram.me/jadicts_bot) 「辞書 BOT」一つの機能として、「かな付け」というものがあります。

https://github.com/mkpoli/telegram-jadicts-bot

> 「まだ六歳とはいえ、世間の目を敏感に感じ取れる年齢にはなっている。」
>　　　　　（赤川次郎『幽霊愛好会』）

例えば以上の文を以下のように変換することです。

> 「まだ六歳（ろくさい）とはいえ、世間（せけん）の目（め）を敏感（びんかん）に感（かん）じ取（と）れる年齢（ねんれい）にはなっている。」
　　（赤川（あかがわ）次郎（じろう）『幽霊（ゆうれい）愛好会（あいこうかい）』）

# 問題点その一

形態素解析エンジンを使用して、言葉毎に読み仮名を取得しています。ここに問題となるのは、動詞などの送り仮名、例えば「読む」の「む」の部分が、読み仮名に含まれてしまいます。だから、安直に``f"{kanji}（{hiragana}）"``のようにしてしまうと、「読む（よむ）」になってしまいます。別に括弧しか使えない環境なら、このままでも特にどうということもないですが、HTMLのルビなどの形になると``<ruby>読む<rt>よむ<rt></ruby>`` 「<ruby>読む<rt>よむ<rt></ruby>」のように気持ち悪くなります。

だから、何かしらの処理をしなければなりません。

# 甘かった処理

まず考えつきやすいのは、交じり表記とひらがな表記を、後からその[最長共通部分列（LCS）](https://ja.wikipedia.org/wiki/%E6%9C%80%E9%95%B7%E5%85%B1%E9%80%9A%E9%83%A8%E5%88%86%E5%88%97%E5%95%8F%E9%A1%8C)を取って、三つの部分（例えば「読む」を「読」「よ」「む」）に分けて処理することです。

```python

def get_okurigana(a: str, b: str) -> str:
    # 順を逆転
    ra, rb = a[::-1], b[::-1]
　　# 最長共通部分列を取る
    def _iter():
        for a, b in zip(ra, rb):
            if a == b:
                yield a
            else:
                return
    return ''.join(reversed(list(_iter())))

def format_okurigana(orig: str, hira: str, okurigana: str) -> tuple[str, str, str]:
    # 「読む」を「読」「よ」「む」に分ける
    okurigana_length = len(okurigana)
    return [orig[:-okurigana_length], hira[:-okurigana_length], okurigana]

def process_item(item: dict) -> str:
    # 単語リストを処理する
    if item['orig'] == item['hira'] or item['orig'] == item['kana']:
        # ひらがなやカタカナのみの語をそのまま
        return item['orig']
    elif len(okurigana := get_okurigana(item['orig'], item['hira'])):
        # 送り仮名
        orig, hira, okrgn = format_okurigana(item['orig'], item['hira'], okurigana)
        return f"{ orig }（{ hira }）{okrgn}"
    else:
        # 漢字のみの語
        return f"{ item['orig'] }（{ item['hira'] }）"
```

この処理は、他のところにもよく見る方法だけど、実はとても甘いです。

# 問題点その二

> 「まだ六歳とはいえ、世間の目を敏感に感じ取れる年齢にはなっている。」
>　　　　　（赤川次郎『幽霊愛好会』）

それは何かというと、例えば前にも出てきたこの文の「感じ取れる」という単語。これは日本語の複合動詞で、ほとんどの場合は形態素解析エンジンにとって一語です。だから、「感じ取れる」（かんじとれる）を、前のように処理した場合、「感じ取（かんじと）れる」となります。「読む（よむ）」よりも気持ち悪いですね🤢。

# 最終の処理

だから色々考えた結果、正規表現を使うことにしました。

目的はあくまでもすべての漢字（の連続）の後に括弧をつけてその読み方を入れるだけだから、だから、必要なのは漢字の読み仮名を取得することだけです。しかしそれを取得するには、漢字と相応の位置にある文字を抽出する必要があります。ここで思いついたのは、例えば``"感じ取れる", "かんじとれる"``に対して、「感じ取れる」から漢字を<ruby>刳<rt>く</rt></ruby>り貫いて、「○じ○れる」を「かんじとれる」に嵌めて、「かん」と「と」を取るというプロセスです。

具体的には、逆から行って、「かんじとれる」にはまるような正規表現が必要です。それを作るには、漢字を仮名の正規表現に変える正規表現が必要です。

それは何かというと、漢字の連続を表す``\p{Han}+``です。これでマッチしたもの（漢字の連続）をひらがなの連続を表す正規表現``\p{Hiragana}+``に変えて、それを括弧で捕まえます``(\p{Hiragana}+)。

捕まったら、もとの漢字表記の漢字の後の部分に穴を開けて、その中に捕まったものを順に入れればいいわけです。

分かりましたか？実際にコードを書いてみましょう！

※ ここでは、正規表現のユニコード属性（Unicode Property）``\p{}``を使いたいので、Python の組み込みライブラリー``re``ではなく、拡張された``regex``を使います（``pip install regex``）。

```python

# 漢字の連続をマッチするパターン
KANJI_PATTERN = regex.compile(r"(\p{Han}+)")
def okurigana(orig, hira):
    # e.g. orig=お悔やみ申し上げる, hira=おくやみもうしあげる

　　# 漢字の連続を (\p{Hiragana}+) に替える
    new_pattern = KANJI_PATTERN.sub(r"(\p{Hiragana}+)", orig)
    # お(\p{Hiragana}+)やみ(\p{Hiragana}+)し(\p{Hiragana}+)げる

    # 「おくやみもうしあげる」に型を嵌める
    mgroups = regex.match(new_pattern, hira).groups()
    # く, もう, あ

    # 「お悔やみ申し上げる」に穴をあける
    filling = KANJI_PATTERN.sub(r"\1{}", orig)
    # お悔{}やみ申{}し上{}げる

    # 開いた穴に捕まったひらがなを入れる
    return filling.format(*[f"（{x}）" for x in mgroups])
    # お悔（く）やみ申（もう）し上（あ）げる
```

これで終わります。

# テスト用例
```python
testcases = [
    ("感じ取れる", "かんじとれる"),
    ("お悔やみ", "おくやみ"),
    ("お悔やみ申し上げる", "おくやみもうしあげる"),
    ("お土産", "おみやげ"),
    ("任せる", "まかせる"),
    ("算数", "さんすう")
]
for orig, hira in testcases:
    print(okurigana(orig, hira))
```
結果

```
感（かん）じ取（と）れる
お悔（く）やみ
お悔（く）やみ申（もう）し上（あ）げる
お土産（みやげ）
任（まか）せる
算数（さんすう）
```

# 終わりに

今回結構曲がり道通ったなと思いました。大量なテストを通さなければ、問題に気づかないし、気づいても解決までの道のりは遠くて曲がっています。しかし、解決法を探す過程自体が、また一つの問題となっていて、パズルを解くための方法を探すというパズルになっていて、解けば解くほど、上手くなるし楽しくなります。