[Add] Implement Transliterator class and katakana to Hepburn conversion function

This commit is contained in:
misyaguziya
2025-09-17 14:09:36 +09:00
parent 3d34b50793
commit 4617954928
3 changed files with 1 additions and 1 deletions

View File

@@ -0,0 +1,215 @@
# katakana_to_hepburn.py
# カタカナ -> ヘボン式ローマ字(パッケージ不要)
def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str:
"""
カタカナ文字列をヘボン式ローマ字に変換する。
use_macron=True のとき ā ī ū ē ō で長音を表現(マクロン)。
use_macron=False のときは単純に連続母音を残す(例: ou, oo
"""
# 基本音の対応(主要なカタカナ)
base = {
'':'a','':'i','':'u','':'e','':'o',
'':'ka','':'ki','':'ku','':'ke','':'ko',
'':'sa','':'shi','':'su','':'se','':'so',
'':'ta','':'chi','':'tsu','':'te','':'to',
'':'na','':'ni','':'nu','':'ne','':'no',
'':'ha','':'hi','':'fu','':'he','':'ho',
'':'ma','':'mi','':'mu','':'me','':'mo',
'':'ya','':'yu','':'yo',
'':'ra','':'ri','':'ru','':'re','':'ro',
'':'wa','':'wo','':'n',
'':'ga','':'gi','':'gu','':'ge','':'go',
'':'za','':'ji','':'zu','':'ze','':'zo',
'':'da','':'ji','':'zu','':'de','':'do',
'':'ba','':'bi','':'bu','':'be','':'bo',
'':'pa','':'pi','':'pu','':'pe','':'po',
# 小書き(単独で使われることは少ないがマップしておく)
'':'a','':'i','':'u','':'e','':'o',
'':'ya','':'yu','':'yo','':'xtsu','':'-',
'':'vu','シェ':'she' # 特殊は下で組合せで処理
}
# 拡張:子音 + 小ャユョ の組合せ(主要なもの)
digraphs = {
('',''):'kya', ('',''):'kyu', ('',''):'kyo',
('',''):'gya', ('',''):'gyu', ('',''):'gyo',
('',''):'sha', ('',''):'shu', ('',''):'sho',
('',''):'ja', ('',''):'ju', ('',''):'jo',
('',''):'cha', ('',''):'chu', ('',''):'cho',
('',''):'nya', ('',''):'nyu', ('',''):'nyo',
('',''):'hya', ('',''):'hyu', ('',''):'hyo',
('',''):'bya', ('',''):'byu', ('',''):'byo',
('',''):'pya', ('',''):'pyu', ('',''):'pyo',
('',''):'mya', ('',''):'myu', ('',''):'myo',
('',''):'rya', ('',''):'ryu', ('',''):'ryo',
# 外来音対応(ファ/フィ/チェ 等)
('',''):'fya', ('',''):'fyu', ('',''):'fyo',
('',''):'tu', ('',''):'du',
# F-sounds (ファ フィ フェ フォ)
('',''):'fa', ('',''):'fi', ('',''):'fe', ('',''):'fo',
# シェ チェ ティ etc.
('',''):'she', ('',''):'che',
('',''):'ti', ('',''):'tu', ('',''):'du',
('',''):'wa', ('',''):'wi', ('',''):'we', ('',''):'wo',
# その他外来語によくある組合せ
('',''):'si', ('',''):'zi', ('',''):'tsa', ('',''):'tsi', ('',''):'tse', ('',''):'tso',
('',''):'kye', ('',''):'gye',
('',''):'va', ('',''):'vi', ('',''):'ve', ('',''):'vo', ('',''):'vyu'
}
# 小文字一覧(ゃゅょぁぃぅぇぉ など)
small_kana = set(['','','','','','','','','','','','','','',''])
# マクロン変換マップ(連続母音 -> マクロン)
macron_map = {
'aa':'ā','ii':'ī','uu':'ū','ee':'ē','oo':'ō',
# ou -> ō という扱いを多くのヘボン式はする(特に日本語由来の長音)
'ou':'ō'
}
# Helper: 次のローマ字の先頭子音を取り出す(促音処理用)
def initial_consonant(rom: str) -> str:
# romはローマ字例 'shi','chi','ta'
# 子音は最初の母音直前までと考える(母音: a,i,u,e,o
for i,ch in enumerate(rom):
if ch in 'aeiou':
return rom[:i]
return rom # 母音がないなら全部
# 変換メイン
res = []
i = 0
kata = kata.strip()
length = len(kata)
while i < length:
ch = kata[i]
# 促音(ッ):次の音の初めの子音を重ねる
if ch == '':
# lookahead
if i+1 < length:
# 先の1文字 or 合字を取り得る(小書きが続く可能性)
# まず合字優先で調べる
next_pair = None
if i+2 < length and (kata[i+1], kata[i+2]) in digraphs:
next_pair = digraphs[(kata[i+1], kata[i+2])]
elif kata[i+1] in base:
next_pair = base.get(kata[i+1])
if next_pair:
cons = initial_consonant(next_pair)
if cons == '':
# もし母音始まりなら促音は無視(稀)
pass
else:
# Hepburnでは "ch" の場合 "cch"matcha等の扱いになるように
# cons の先頭1文字を倍にするより、cons全体の先頭文字を重ねるのが一般的例: 'shi' -> 'ssh' ? いい例は少ない)
# 実務上は先頭子音の最初の文字を重複する:
res.append(cons[0])
# advance only the 促音 itself here; next loop handles next kana
i += 1
continue
# 長音符(ー):前の母音を伸ばす(マクロン処理は後でまとめて)
if ch == '':
# append marker '-' to indicate prolong; we'll post-process
res.append('-')
i += 1
continue
# 合字(子 + 小ャュョ等)
if i+1 < length and (ch, kata[i+1]) in digraphs:
res.append(digraphs[(ch, kata[i+1])])
i += 2
continue
# 小書きが前に独立して出てきた場合(通常は合字で処理されるが念のため)
if ch in small_kana and ch != '':
# 小書きを単独で英字に変換(例: 'ァ' -> 'a'
res.append(base.get(ch, ''))
i += 1
continue
# 普通のカタカナ
if ch in base:
res.append(base[ch])
i += 1
continue
# 英数字や記号・ひらがななどはそのまま(変換対象外)
res.append(ch)
i += 1
# ここまでで res はローマ字パーツのリスト(長音は '-' でマーク)
raw = ''.join(res)
# 撥音(ン)処理: n の前が b/p/m の場合 m にする
# ただし既に 'n' のまま次が母音や y の時は通常 n' を入れるべきだが簡易処理として n のまま保持。
# 我々は 'n' の後に b/p/m が来たら 'm' に置換
import re
raw = re.sub(r'n(?=[bmp])', 'm', raw)
# 長音処理('-' マークを見て前の母音を伸ばす)
# raw 中の '-' を削って該当の母音を伸ばす
while '-' in raw:
idx = raw.find('-')
if idx == 0:
# 先頭に長音符が来るのはおかしいので削除
raw = raw[:idx] + raw[idx+1:]
continue
# 前の文字が母音ならそれを重ねる
prev = raw[idx-1]
if prev in 'aiueo':
# 直前に既に vowel がある場合、後でマクロン処理に任せて母音を2つにする
raw = raw[:idx] + prev + raw[idx+1:]
else:
# 直前が子音なら何もして取り除く
raw = raw[:idx] + raw[idx+1:]
# 小さな例外対応: 'ti' 等の表記は 'chi' と扱いたいが上述マップでカバー済み
# macron の適用(長音の正規化)
if use_macron:
# まず 'ou' を ō に(ただし語による例外はあるが、一般的ヘボンに合わせる)
# その前に 'oo' を 'ō' に(稀)
for pair, mac in macron_map.items():
raw = raw.replace(pair, mac)
# else: leave as is (ou/oo/aa...)
# 仕上げ:小文字統一(ヘボンは小文字)
raw = raw.lower()
# 最後に、n の後に母音または y が来る場合は「んあ->n'a」的扱いが必要だが
# シンプル実装では n の後に母音や y が来るときは n' を入れる(明瞭化)
# ただし多くの実例では省略されることも多いのでコメントアウトしておく
# raw = re.sub(r"n(?=[aiueoy])", "n'", raw)
return raw
# --- テスト例 ---
if __name__ == "__main__":
tests = [
"カタカナ",
"コンピューター",
"キャッチ",
"マッチャ",
"シェア",
"ジェット",
"ヴァイオリン",
"ホテル",
"スーパー",
"ギュウニュウ",
"パーティー",
"トウキョウ", # 東京(トウキョウ -> tōkyō
"オーケー",
"ファイル",
"ニューヨーク",
"ラーメン",
"パン",
"チョコレート",
]
for s in tests:
print(s, "->", katakana_to_hepburn(s, use_macron=True))

View File

@@ -0,0 +1,187 @@
from sudachipy import tokenizer
from sudachipy import dictionary
try:
from .transliteration_kana_to_hepburn import katakana_to_hepburn
except ImportError:
from transliteration_kana_to_hepburn import katakana_to_hepburn
class Transliterator:
def __init__(self):
self.tokenizer_obj = dictionary.Dictionary().create()
self.mode = tokenizer.Tokenizer.SplitMode.A
@staticmethod
def is_kanji(ch: str) -> bool:
return '\u4e00' <= ch <= '\u9fff'
@staticmethod
def kata_to_hira(text: str) -> str:
return "".join(
chr(ord(c) - 0x60) if '' <= c <= '' else c
for c in text
)
@staticmethod
def split_kanji_okurigana(surface: str, reading_kana: str):
"""
1語の表層形(surface)と読み(reading_kana)を
[ {"orig":..., "kana":..., "hira":..., "hepburn":...}, ... ] に分割
"""
result = []
# 表層を「漢字ブロック」と「非漢字ブロック」に分割
buf = ""
prev_is_kanji = None
blocks = []
for ch in surface:
now_is_kanji = Transliterator.is_kanji(ch)
if prev_is_kanji is None or now_is_kanji == prev_is_kanji:
buf += ch
else:
blocks.append((prev_is_kanji, buf))
buf = ch
prev_is_kanji = now_is_kanji
if buf:
blocks.append((prev_is_kanji, buf))
# 読みを分配
kana_left = reading_kana
for i, (is_kan, part) in enumerate(blocks):
if is_kan:
# 漢字ブロックの処理
if len(blocks) == 1:
# 単一ブロック(全て漢字)の場合
kana_for_kan = kana_left
elif i == len(blocks) - 1:
# 最後のブロック(漢字)の場合
kana_for_kan = kana_left
else:
# 中間の漢字ブロックの場合
# 後続の非漢字ブロックの文字数を計算
remaining_non_kanji = sum(len(p) for is_k, p in blocks[i+1:] if not is_k)
if remaining_non_kanji > 0 and len(kana_left) > remaining_non_kanji:
kana_for_kan = kana_left[:-remaining_non_kanji]
else:
# 漢字1文字あたり最低1文字の読みを割り当て
min_kana = len(part)
kana_for_kan = kana_left[:max(min_kana, len(kana_left) - remaining_non_kanji)]
# 空の読みを避ける
if not kana_for_kan and kana_left:
kana_for_kan = kana_left[:1]
result.append(
{
"orig": part,
"kana": kana_for_kan,
"hira": Transliterator.kata_to_hira(kana_for_kan),
"hepburn": katakana_to_hepburn(kana_for_kan, use_macron=True)
}
)
kana_left = kana_left[len(kana_for_kan):]
else:
# 非漢字部分(送り仮名など)
kana_for_okuri = kana_left[:len(part)]
result.append(
{
"orig": part,
"kana": kana_for_okuri,
"hira": Transliterator.kata_to_hira(kana_for_okuri),
"hepburn": katakana_to_hepburn(kana_for_okuri, use_macron=True)
}
)
kana_left = kana_left[len(kana_for_okuri):]
return result
def analyze(self, text: str, use_macron: bool = True):
tokens = self.tokenizer_obj.tokenize(text, self.mode)
results = []
for t in tokens:
surface = t.surface()
reading = t.reading_form()
# 単純に1文字ずつ処理
if len(surface) == 1:
# 1文字の場合はそのまま
results.append({
"orig": surface,
"kana": reading,
"hira": self.kata_to_hira(reading),
"hepburn": katakana_to_hepburn(reading, use_macron=use_macron)
})
else:
# 複数文字の場合は文字種別で分割
i = 0
reading_pos = 0
while i < len(surface):
char = surface[i]
if self.is_kanji(char):
# 漢字の場合、連続する漢字をまとめて処理
kanji_block = ""
while i < len(surface) and self.is_kanji(surface[i]):
kanji_block += surface[i]
i += 1
# 漢字ブロックの読みを推定
if i < len(surface):
# 後に文字がある場合、送り仮名を考慮
remaining_chars = len(surface) - i
kanji_reading = reading[reading_pos:-remaining_chars] if remaining_chars > 0 else reading[reading_pos:]
else:
# 最後の漢字ブロックの場合
kanji_reading = reading[reading_pos:]
results.append({
"orig": kanji_block,
"kana": kanji_reading,
"hira": self.kata_to_hira(kanji_reading),
"hepburn": katakana_to_hepburn(kanji_reading, use_macron=use_macron)
})
reading_pos += len(kanji_reading)
else:
# 非漢字の場合
non_kanji_block = ""
while i < len(surface) and not self.is_kanji(surface[i]):
non_kanji_block += surface[i]
i += 1
# 非漢字部分の読み(通常は文字数分)
non_kanji_reading = reading[reading_pos:reading_pos + len(non_kanji_block)]
results.append({
"orig": non_kanji_block,
"kana": non_kanji_reading,
"hira": self.kata_to_hira(non_kanji_reading),
"hepburn": katakana_to_hepburn(non_kanji_reading, use_macron=use_macron)
})
reading_pos += len(non_kanji_reading)
return results
# --- テスト ---
if __name__ == "__main__":
test_cases = [
"美しい花を見る",
"東京に行く",
"漢字とカタカナの混在",
"パーティーに行く",
"コンピューターを使う",
"シェアハウスに住む",
"ヴァイオリンを弾く",
"ギュウニュウを飲む",
"ニューヨークに行く",
"ラーメンを食べる",
"チョコレートが好き",
"SessionIDを取得する",
"取り敢えず検索してみる",
"見知らぬ土地で冒険する",
"彼は優れたエンジニアです",
]
transliterator = Transliterator()
for case in test_cases:
print(transliterator.analyze(case))