【ルビ振り】読みがなが原文の何文字目に対応するかを見つける【python】

# 概要
MeCabなどの形態素解析ライブラリを使って読みがなを取得したときに、各読みがなが原文のどの文字と対応しているのかなるべく細かく把握する方法について説明します。ルビをふるときなどに役立つと嬉しいです。
# 背景
例えば「慮る」にルビを振りたいときは、「慮」が「おもんぱか」と発音されることを知っている必要がありますが、形態素解析では単語が最小の区切りのため、「慮る」と「おもんぱかる」が対応していることまでしかわかりません。「慮」と「おもんぱか」が対応していることを知るためには、原文と読みがなで最後の「る」が一致しているのでまずこれを対応付けた上で、残りの「慮」と「おもんぱか」が対応している、と推測する必要があります。これの具体的なやり方を説明します。
なお今回のやり方は原文に仮名が含まれている場合（つまり送り仮名つきの訓読み）のみ有効です。熟語に対しては形態素解析以上の精度の対応を見つけるそれ以上分けることはできません。例えば「野原」が「野／の」「原／はら」と対応していることはわかりません。熟語については漢字の訓読み／音読み辞書があれば推測できると考えられますが、今回はそこには踏み込まないこととします。
# コード
原文を入力とし、その読みがなと、読みがなの各文字が原文の何文字目に対応しているかのリストを得ることを目指します。

```Python
import jaconv
import MeCab
import re

m = MeCab.Tagger()
kana_pattern = re.compile("[ァ-ヴー]")

#原文におけるカナ文字とその位置をそれぞれリストで得る
def get_kana_pos(text):
  kana = kana_pattern.findall(text)
  bw = kana_pattern.split(text)
  pos = []
  cnt = 0
  for k,b in zip(kana,bw):
    cnt += len(b)
    pos.append(cnt)
    cnt += 1
  return list(zip(kana,pos))

#原文を単語に分割して、各単語の原文（平仮名はカタカナに変換する）と読みのリストを得る
def get_yomi(text):
  tokens = m.parse(text).splitlines()[:-1]
  ret = []
  for token in tokens:
    surface, pos = tuple(token.split('\t'))
    surface_kata = jaconv.hira2kata(surface)
    pos = pos.split(',')
    #読みの取得
    yomi = pos[-1]
    #もし解析に失敗していたら平仮名部分だけカタカナにした表層形を使う
    if pos[0] == "記号": yomi = ""
    elif yomi == "*": yomi = surface_kata
    ret.append((surface_kata, yomi))
  return ret

#読みがなの各文字が原文の何文字目と対応しているかのリストを得る
def get_correspondance(text, yomi, start = 0):
  #逆から処理するために逆転させる
  text, yomi = text[::-1], yomi[::-1]
  kana_pos = get_kana_pos(text)
  if len(kana_pos) == 0: 
    print(len(yomi),[start]*len(yomi))
    return [start]*len(yomi)
  n_text, n_yomi = 0, 0
  pos = []
  for k,p in kana_pos:
    while n_yomi < len(yomi):
      if yomi[n_yomi] == k:
        n_text = p
        pos.append(n_text)
        n_text += 1
        n_yomi += 1
        break
      else:
        pos.append(n_text)
        n_yomi += 1
  #まだ読みが余っていれば、最後のカナの次のpositionとする
  pos.extend([n_text]*max(len(yomi)-n_yomi,0))
  pos = [start+len(text)-1-v for v in pos[::-1]]
  return pos

#textは漢字かな交じりの文字列
def get_position(text):
  #形態素解析
  words = get_yomi(text)
  start = 0
  all_yomi,yomi_pos = "",[]
  for surface_kata, yomi in words:
    #原文からカタカナを抽出
    tmp = get_correspondance(surface_kata, yomi, start)
    yomi_pos.extend(tmp)
    start += len(surface_kata)
    all_yomi += yomi
    #print(all_yomi,yomi_pos)
  return list(zip(all_yomi,yomi_pos))
```

# 各関数の解説
## カタカナとその位置を返す関数
原文（漢字かな交じり）を入力したらカタカナとその位置を返す関数`get_kana_pos(text)`を作ります。入力は平仮名がすべてカタカナに置き換えられた漢字仮名交じり文（str）、戻り値はlistでその各要素はカタカナ（str）と位置（int）のtupleです。

```Python
#原文におけるカナ文字とその位置をそれぞれリストで得る
def get_kana_pos(text):
  kana = kana_pattern.findall(text)
  bw = kana_pattern.split(text)
  pos = []
  cnt = 0
  for k,b in zip(kana,bw):
    cnt += len(b)
    pos.append(cnt)
    cnt += 1
  return list(zip(kana,pos))
```

出力は以下のとおりです。

```
>>> get_kana_pos("庭ニハ二羽ニワトリガイル")
[('ニ', 1), ('ハ', 2), ('ニ', 5), ('ワ', 6), ('ト', 7), ('リ', 8), ('ガ', 9), ('イ', 10), ('ル', 11)]
```
## 原文を単語に分割して単語と読みのペアをリストで得る
MeCabで原文を単語に分割し、各単語の原文（平仮名はカタカナにしておく）とその読みのペアをリストで取得します。

```Python
#原文を単語に分割して、各単語の原文（平仮名はカタカナに変換する）と読みのリストを得る
def get_yomi(text):
  tokens = m.parse(text).splitlines()[:-1]
  ret = []
  for token in tokens:
    surface, pos = tuple(token.split('\t'))
    surface_kata = jaconv.hira2kata(surface)
    pos = pos.split(',')
    #読みの取得
    yomi = pos[-1]
    #もし解析に失敗していたら平仮名部分だけカタカナにした表層形を使う
    if pos[0] == "記号": yomi = ""
    elif yomi == "*": yomi = surface_kata
    ret.append((surface_kata, yomi))
  return ret
```
出力は以下です。

```
>>> get_yomi("庭には二羽ニワトリがいる")
[('庭', 'ニワ'), ('ニ', 'ニ'), ('ハ', 'ワ'), ('二羽', 'ニワ'), ('ニワトリ', 'ニワトリ'), ('ガ', 'ガ'), ('イル', 'イル')]
```


## 読みの各文字が原文の何文字目に対応しているかを返す関数
読みの各文字が原文の何文字目に対応しているかを返す関数`get_correspondance(text, yomi, start=0)`を作ります。入力は原文の一部（str）、その読みのカタカナ（str）、原文の一部が全体の何文字目から始まっているか（int）です。出力はlistでそのi番目の要素はyomiのi文字目が原文の何文字目に対応するか（int）を表します。
なお、漢字部分の読みにカナと同じものが含まれていることなどの条件が複合的に運悪く重なると、答え（出力）が一意に定まらない場合がありそうなのですが、単語に分割したあとの原文と読みに対してそれが起こる確率はとても低そうなので、とりあえず無視します（矛盾しない解析結果のひとつを出力する、ということで妥協します）。

```Python
#読みがなの各文字が原文の何文字目と対応しているかのリストを得る
def get_correspondance(text, yomi, start = 0):
  #逆から処理するために逆転させる
  text, yomi = text[::-1], yomi[::-1]
  kana_pos = get_kana_pos(text)
  if len(kana_pos) == 0: 
    return [start]*len(yomi)
  n_text, n_yomi = 0, 0
  pos = []
  for k,p in kana_pos:
    while n_yomi < len(yomi):
      if yomi[n_yomi] == k:
        n_text = p
        pos.append(n_text)
        n_text += 1
        n_yomi += 1
        break
      else:
        pos.append(n_text)
        n_yomi += 1
  #まだ読みが余っていれば、最後のカナの次のpositionとする
  pos.extend([n_text]*max(len(yomi)-n_yomi,0))
  #順読みの場合に変換する
  pos = [start+len(text)-1-v for v in pos[::-1]]
  return pos
```

出力は以下です。

```
>>> get_correspondance("慮ル","オモンパカル",0)
[0, 0, 0, 0, 0, 1]
>>> get_correspondance("バッテリー","バッテリー",0)
[0, 1, 2, 3, 4]
>>> get_correspondance("バッテリー","バッテリー",3)
[3, 4, 5, 6, 7]
```
## 原文（全体）を入力したら読みと原文の対応位置を返す関数
まず原文全体をMeCabで単語に分割したあとに、単語の表層形と読みをget_correspondanceに代入しています。

```Python
#textは漢字かな交じりの文字列
def get_position(text):
  #形態素解析
  words = get_yomi(text)
  start = 0
  all_yomi,yomi_pos = "",[]
  for surface_kata, yomi in words:
    #原文からカタカナを抽出
    tmp = get_correspondance(surface_kata, yomi, start)
    yomi_pos.extend(tmp)
    start += len(surface_kata)
    all_yomi += yomi
    #print(all_yomi,yomi_pos)
  return list(zip(all_yomi,yomi_pos))

```

出力は以下です。

```
>>> get_position("庭には二羽ニワトリがいる")
[('ニ', 0), ('ワ', 0), ('ニ', 1), ('ワ', 2), ('ニ', 3), ('ワ', 3), ('ニ', 5), ('ワ', 6), ('ト', 7), ('リ', 8), ('ガ', 9), ('イ', 10), ('ル', 11)]
>>> get_position("飛んだ「じゃじゃ馬」だ")
[('ト', 0), ('ン', 1), ('ダ', 2), ('ジ', 4), ('ャ', 5), ('ジ', 6), ('ャ', 7), ('ウ', 8), ('マ', 8), ('ダ', 10)]
```