最近作っている日本語学習者向けの Telegram BOT @jadicts_bot 「辞書 BOT」一つの機能として、「かな付け」というものがあります。
「まだ六歳とはいえ、世間の目を敏感に感じ取れる年齢にはなっている。」
(赤川次郎『幽霊愛好会』)
例えば以上の文を以下のように変換することです。
「まだ六歳(ろくさい)とはいえ、世間(せけん)の目(め)を敏感(びんかん)に感(かん)じ取(と)れる年齢(ねんれい)にはなっている。」
(赤川(あかがわ)次郎(じろう)『幽霊(ゆうれい)愛好会(あいこうかい)』)
問題点その一
形態素解析エンジンを使用して、言葉毎に読み仮名を取得しています。ここに問題となるのは、動詞などの送り仮名、例えば「読む」の「む」の部分が、読み仮名に含まれてしまいます。だから、安直にf"{kanji}({hiragana})"
のようにしてしまうと、「読む(よむ)」になってしまいます。別に括弧しか使えない環境なら、このままでも特にどうということもないですが、HTMLのルビなどの形になると<ruby>読む<rt>よむ<rt></ruby>
「読む」のように気持ち悪くなります。
だから、何かしらの処理をしなければなりません。
甘かった処理
まず考えつきやすいのは、交じり表記とひらがな表記を、後からその最長共通部分列(LCS)を取って、三つの部分(例えば「読む」を「読」「よ」「む」)に分けて処理することです。
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'] })"
この処理は、他のところにもよく見る方法だけど、実はとても甘いです。
問題点その二
「まだ六歳とはいえ、世間の目を敏感に感じ取れる年齢にはなっている。」
(赤川次郎『幽霊愛好会』)
それは何かというと、例えば前にも出てきたこの文の「感じ取れる」という単語。これは日本語の複合動詞で、ほとんどの場合は形態素解析エンジンにとって一語です。だから、「感じ取れる」(かんじとれる)を、前のように処理した場合、「感じ取(かんじと)れる」となります。「読む(よむ)」よりも気持ち悪いですね🤢。
最終の処理
だから色々考えた結果、正規表現を使うことにしました。
目的はあくまでもすべての漢字(の連続)の後に括弧をつけてその読み方を入れるだけだから、だから、必要なのは漢字の読み仮名を取得することだけです。しかしそれを取得するには、漢字と相応の位置にある文字を抽出する必要があります。ここで思いついたのは、例えば"感じ取れる", "かんじとれる"
に対して、「感じ取れる」から漢字を刳
具体的には、逆から行って、「かんじとれる」にはまるような正規表現が必要です。それを作るには、漢字を仮名の正規表現に変える正規表現が必要です。
それは何かというと、漢字の連続を表す\p{Han}+
です。これでマッチしたもの(漢字の連続)をひらがなの連続を表す正規表現\p{Hiragana}+
に変えて、それを括弧で捕まえます``(\p{Hiragana}+)。
捕まったら、もとの漢字表記の漢字の後の部分に穴を開けて、その中に捕まったものを順に入れればいいわけです。
分かりましたか?実際にコードを書いてみましょう!
※ ここでは、正規表現のユニコード属性(Unicode Property)\p{}
を使いたいので、Python の組み込みライブラリーre
ではなく、拡張されたregex
を使います(pip install regex
)。
# 漢字の連続をマッチするパターン
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])
# お悔(く)やみ申(もう)し上(あ)げる
これで終わります。
テスト用例
testcases = [
("感じ取れる", "かんじとれる"),
("お悔やみ", "おくやみ"),
("お悔やみ申し上げる", "おくやみもうしあげる"),
("お土産", "おみやげ"),
("任せる", "まかせる"),
("算数", "さんすう")
]
for orig, hira in testcases:
print(okurigana(orig, hira))
結果
感(かん)じ取(と)れる
お悔(く)やみ
お悔(く)やみ申(もう)し上(あ)げる
お土産(みやげ)
任(まか)せる
算数(さんすう)
終わりに
今回結構曲がり道通ったなと思いました。大量なテストを通さなければ、問題に気づかないし、気づいても解決までの道のりは遠くて曲がっています。しかし、解決法を探す過程自体が、また一つの問題となっていて、パズルを解くための方法を探すというパズルになっていて、解けば解くほど、上手くなるし楽しくなります。