ローマ字をカタカナに変換する【python】

#概要
ローマ字をカタカナに変換する関数を作りました。

コード全体：
https://github.com/JiroShimaya/PythonModule/tree/main/Romaji

先人が多くいるため（以下など）、多くの人はあえて自作する必要はないと思いますが、

https://pypi.org/project/romkan/
https://mohayonao.hatenadiary.org/entry/20091129/1259505966
https://github.com/ikegami-yukino/jaconv

今回、一部、メジャーじゃない変換ルール（woをヲではなくウォにするなど）を設けたかったので、練習も兼ねて自作しました。

#方針
今回はローマ字をカナに変換することにしか興味がないので、その機能だけ作ります。
基本的に、カナに変換できるまとまりごとに機械的に置き換えるだけですが、nを"ン"にする変換と、同じローマ字子音が連続したときに"ッ"に変換する処理があるので、そこだけ少し面倒です。

構文解析っぽい考え方で作れそうだと思ったので、構文の式を書いてみました。
参考：https://qiita.com/kRysTasis/items/77a4b4e6214646a079ed

```
kana = [ unit | hatsuon | sokuon ], [ kana ]
```

kanaを解析対象であるアルファベットの並びとします。
kanaはunit, hatsuon, sokuonのいずれかとkana（としての条件を満たす文字列）が結合された文字列とみなせます。
ここで、大かっこ（[]）はその中身が0または1回登場することを意味します。縦棒（"|"）はその左右のいずれかが要素となること（つまりor条件）を意味します。

unit, hatsuon, sokuonの定義は以下のとおりです。

```
unit = １モウラのカナに変換できるローマ字の並び
hatsuon = "ン"に変換すべきアルファベット。具体的には"n"か"m"
sokuon = "ッ"に変換すべきアルファベット。具体的には、次の文字と同じアルファベット。 
```

hatsuonの定義について、例えば"na"（"ナ"）というアルファベットの並びの１文字目が"ン"と変換されてしまうと良くないですが、構文を左から評価する（つまりunitの評価を先にする）ことで、そのようなケースを除外できます。
sokuonについても同様に"aa"（"アア"）や"nn"（"ンン"）の１文字目を"ッ"に変換されると困りますが、unit, hatsuonを先に評価しているので、そのような事例は回避できます。

あとは式に沿ってコーディングをしていけばよいです。

#実装
##変換辞書
アルファベットとカナの対応表をjsonファイルとして作っておきます。

```data/romaji_dict.json
{
  "a": "ア",
  "i": "イ",
  "u": "ウ",
  "e": "エ",
  "o": "オ",
  "ka": "カ",
  "ki": "キ",
  "ku": "ク",
  "ke": "ケ",
  "ko": "コ",
  ...
```

筆者は以下の様に自作しました。
https://qiita.com/shimajiroxyz/items/ff1e0e992ca921d81b41

色んな人がすでに作ったものがあると思うので、それをコピーして使っても良いと思います。

##式を関数に置き換え
「方針」で立てた式に基づいて関数の大枠を作っておきます。
kanaの定義にkanaが使われているので、再帰関数で実装できることがイメージできます。
再帰関数として実装するために、文字列のどのインデックスから解析するかを引数でうけられるようにしておき、インデックスが末尾まで到達したら、値を返すような構成にしておきます。

```Python:romaji.py
class Romaji:
  ...
  #ローマ字全体をカナに変換する
  @classmethod
  def getKana(cls, tokens, s=0):
    if s >= len(tokens) or s < 0:
      return ""
    kana = ""
    idx = s
    if cls.isUnit(tokens, idx):
      kana, idx = cls.getUnit(tokens, idx)
    elif cls.isHatsuon(tokens, idx):
      kana, idx = cls.getHatsuon(tokens, idx)
    elif cls.isSokuon(tokens, idx):
      kana, idx = cls.getSokuon(tokens, idx)
    else:
      kana, idx = tokens[idx], idx+1
    if idx >= len(tokens):
      return kana
    else:
      return kana + cls.getKana(tokens, idx)     
      
  #大文字、または小文字のアルファベットの並びをカナに変換する  
  @classmethod
  def toKana(cls, text): 
    text = text.lower()
    return cls.getKana(text,0)
```

cls.isUnitは与えられた文字列の指定index以降がunitであるかどうかを判定する関数です。
もしunitならcls.getUnitでその部分をカナに変換した結果と、次の開始indexを受け取ります。
cls.isHatsuonとcls.getHatsuon, cls.isSokuonとcls.getSokuonも、それぞれ対象がhatsuon, sokuonの場合の同様の関数となります。
これらの関数を次小節で定義します。

##要素の変換
与えられた文字列の先頭が、unit, hatsuon, sokuonになっているか判定する関数および、それら要素のカナへの変換を行う関数を定義します。

```Python:romaji.py
import os
import json

ROMAJI_DICT_PATH = os.path.join(os.path.dirname(__file__),"data","romaji_dict.json")

#JSON読み込み用の関数を定義したクラス。
#モジュールとして利用されることを想定し、ファイル位置からの相対パスで読み込む関数を定義
class JsonLoader:
  @staticmethod
  def load(path):
    data = None
    with open(path) as f:
      data = json.load(f)
    return data
  @classmethod
  def loadRelative(cls,path):
    path = os.path.join(os.path.dirname(__file__),path)
    return cls.load(path)

class Romaji:
  tree = JsonLoader.load(ROMAJI_DICT_PATH)    
  max_unit_len = max([len(k) for k in tree])
  
  
  #カナ１モウラに変換できるローマ字の並びかどうかを判定する
  @classmethod
  def isUnit(cls, tokens, s=0):
    for i in range(cls.max_unit_len,0,-1):
      if tokens[s:s+i] in cls.tree:
        return True
    return False
  
  @classmethod
  def getUnit(cls, tokens, s=0):
    for i in range(cls.max_unit_len,0,-1):
      if tokens[s:s+i] in cls.tree:
        return cls.tree[tokens[s:s+i]], s+i
    return "",s

  #"ン"に変換すべきかどうかを判定する
  @classmethod
  def isHatsuon(cls, tokens, s=0):
    if s >= len(tokens):
      return False
    if tokens[s] not in ["n","m"]:
      return False
    return True
  
  @classmethod
  def getHatsuon(cls, tokens, s=0):
    return "ン", s+1

  #"ッ"に変換すべきかどうかを判定する
  @classmethod
  def isSokuon(cls, tokens, s=0):
    if s+1 >= len(tokens):
      return False
    if tokens[s] != tokens[s+1]:
      return False
    if not tokens[s].isalpha():
      return False
    return True

  @classmethod
  def getSokuon(cls, tokens, s=0):
    return "ッ", s+1  
  ...
```

#テスト
正しく変換できるかテストしてみます。

```Python:test_Romaji.py
import unittest
from Romaji import Romaji

#テストケース
class Case:
  toKana = [ #入力と期待出力をタプルで定義
    ("Hello","ヘッォ"), 
    ("zyakojika","ジャコジカ"),
    ("I'm","イ'ン"), 
    ("HECCHARA","ヘッチャラ"),
    ("砂糖taiyo","砂糖タイヨ")
    ]

class TestRomaji(unittest.TestCase):
  def test_toKana(self):
    for arg, result in Case.toKana:
      self.assertEqual(Romaji.toKana(arg), result)

if __name__=="__main__":
  unittest.main()  
```

```
% python test_Romaji.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
```