diff --git a/LDDC/common/data/config.py b/LDDC/common/data/config.py index 9f75513..29a875b 100644 --- a/LDDC/common/data/config.py +++ b/LDDC/common/data/config.py @@ -68,6 +68,7 @@ def __init__(self) -> None: "desktop_lyrics_rect": (), # 默认为空自动移动到屏幕中央 "desktop_lyrics_font_size": 30.0, "desktop_lyrics_show_furigana": True, + "kana": False, "language": "auto", "color_scheme": "auto", @@ -120,7 +121,7 @@ def __setitem__(self, key: Any, value: Any) -> None: super().__setitem__(key, value) self.write_config() - if key in ("langs_order", "lrc_ms_digit_count", "add_end_timestamp_line", "last_ref_line_time_sty", "lrc_tag_info_src"): + if key in ("langs_order", "lrc_ms_digit_count", "add_end_timestamp_line", "last_ref_line_time_sty", "lrc_tag_info_src", "kana"): self.lyrics_changed.emit((key, value)) elif key in ( "desktop_lyrics_font_family", diff --git a/LDDC/core/converter/__init__.py b/LDDC/core/converter/__init__.py index 7395a10..b3df7fd 100644 --- a/LDDC/core/converter/__init__.py +++ b/LDDC/core/converter/__init__.py @@ -4,8 +4,9 @@ from LDDC.common.data.config import cfg from LDDC.common.logger import logger -from LDDC.common.models import Language, LyricsBase, LyricsFormat, LyricsType, Source +from LDDC.common.models import FSLyricsData, FSLyricsLine, FSLyricsWord, Language, LyricsBase, LyricsFormat, LyricsType, Source from LDDC.core.algorithm import find_closest_match +from LDDC.core.romaji import romaji_to_hiragana from .ass import ass_converter from .lrc import lrc_converter @@ -58,6 +59,15 @@ def handle_info(info: dict) -> dict: if "LDDC_ts" in lyrics_dict: # 使用LDDC的翻译覆盖原本的翻译 lyrics_dict["ts"] = lyrics_dict.pop("LDDC_ts") + if cfg["kana"] and "roma" in lyrics_dict: + lyrics_dict["roma"] = FSLyricsData([ + FSLyricsLine(line.start, line.end, [ + FSLyricsWord(word.start, word.end, romaji_to_hiragana(word.text)) + for word in line.words + ]) + for line in lyrics_dict["roma"] + ]) + langs_order: list[str] = [lang for lang in cfg["langs_order"] if lang in langs and lang in lyrics_dict] langs_order += [lang for lang in langs if lang not in langs_order and lang in lyrics_dict] diff --git a/LDDC/core/romaji.py b/LDDC/core/romaji.py new file mode 100644 index 0000000..3bf6cc3 --- /dev/null +++ b/LDDC/core/romaji.py @@ -0,0 +1,264 @@ +# SPDX-FileCopyrightText: Copyright (C) 2024-2025 沉默の金 +# SPDX-License-Identifier: GPL-3.0-only +"""Romaji to hiragana conversion module. + +Port of the Rust romaji-to-hiragana mapping from UtaBuild. + +Splits input by whitespace, maps each word to hiragana using the +longest-match-first strategy, and joins all results without spaces. +Unmappable tokens (non-Japanese text, numbers, punctuation) pass through +unchanged. +""" +def _build_romaji_map() -> dict[str, str]: + pairs: list[tuple[str, str]] = [ + ("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", "ふ"), + ("fa", "ふぁ"), + ("fi", "ふぃ"), + ("fe", "ふぇ"), + ("fo", "ふぉ"), + ("fya", "ふゃ"), + ("fyu", "ふゅ"), + ("fyo", "ふょ"), + ("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", "だ"), + ("di", "ぢ"), + ("du", "づ"), + ("de", "で"), + ("do", "ど"), + ("ba", "ば"), + ("bi", "び"), + ("bu", "ぶ"), + ("be", "べ"), + ("bo", "ぼ"), + ("pa", "ぱ"), + ("pi", "ぴ"), + ("pu", "ぷ"), + ("pe", "ぺ"), + ("po", "ぽ"), + ("kya", "きゃ"), + ("kyu", "きゅ"), + ("kyo", "きょ"), + ("sha", "しゃ"), + ("shu", "しゅ"), + ("sho", "しょ"), + ("cha", "ちゃ"), + ("chu", "ちゅ"), + ("cho", "ちょ"), + ("nya", "にゃ"), + ("nyu", "にゅ"), + ("nyo", "にょ"), + ("hya", "ひゃ"), + ("hyu", "ひゅ"), + ("hyo", "ひょ"), + ("mya", "みゃ"), + ("myu", "みゅ"), + ("myo", "みょ"), + ("rya", "りゃ"), + ("ryu", "りゅ"), + ("ryo", "りょ"), + ("gya", "ぎゃ"), + ("gyu", "ぎゅ"), + ("gyo", "ぎょ"), + ("ja", "じゃ"), + ("ju", "じゅ"), + ("jo", "じょ"), + ("bya", "びゃ"), + ("byu", "びゅ"), + ("byo", "びょ"), + ("pya", "ぴゃ"), + ("pyu", "ぴゅ"), + ("pyo", "ぴょ"), + ("kka", "っか"), + ("kki", "っき"), + ("kku", "っく"), + ("kke", "っけ"), + ("kko", "っこ"), + ("ssa", "っさ"), + ("sshi", "っし"), + ("ssu", "っす"), + ("sse", "っせ"), + ("sso", "っそ"), + ("tta", "った"), + ("tchi", "っち"), + ("ttsu", "っつ"), + ("tte", "って"), + ("tto", "っと"), + ("ppa", "っぱ"), + ("ppi", "っぴ"), + ("ppu", "っぷ"), + ("ppe", "っぺ"), + ("ppo", "っぽ"), + ("dda", "っだ"), + ("ddi", "っぢ"), + ("ddu", "っづ"), + ("dde", "っで"), + ("ddo", "っど"), + ("gga", "っが"), + ("ggi", "っぎ"), + ("ggu", "っぐ"), + ("gge", "っげ"), + ("ggo", "っご"), + ("bba", "っば"), + ("bbi", "っび"), + ("bbu", "っぶ"), + ("bbe", "っべ"), + ("bbo", "っぼ"), + ("ffa", "っふぁ"), + ("ffi", "っふぃ"), + ("ffe", "っふぇ"), + ("ffo", "っふぉ"), + ("ffya", "っふゃ"), + ("ffyu", "っふゅ"), + ("ffyo", "っふょ"), + ("aa", "あー"), + ("ii", "いー"), + ("uu", "うー"), + ("ee", "えー"), + ("oo", "おー"), + ("kkya", "っきゃ"), + ("kkyu", "っきゅ"), + ("kkyo", "っきょ"), + ("ssha", "っしゃ"), + ("sshu", "っしゅ"), + ("ssho", "っしょ"), + ("tcha", "っちゃ"), + ("tchu", "っちゅ"), + ("tcho", "っちょ"), + ("ppya", "っぴゃ"), + ("ppyu", "っぴゅ"), + ("ppyo", "っぴょ"), + ("bbya", "っびゃ"), + ("bbyu", "っびゅ"), + ("bbyo", "っびょ"), + ("ggya", "っぎゃ"), + ("ggyu", "っぎゅ"), + ("ggyo", "っぎょ"), + ("jja", "っじゃ"), + ("jju", "っじゅ"), + ("jjo", "っじょ"), + # Apostrophe-sokuon: 't, 's, 'k, etc. mark gemination in some romaji notations + ("'t", "っ"), + ("'s", "っ"), + ("'k", "っ"), + ("'p", "っ"), + ("'b", "っ"), + ("'d", "っ"), + ("'g", "っ"), + ("'j", "っ"), + ("'c", "っ"), + ] + return dict(pairs) + + +_ROMANJI_MAP: dict[str, str] = _build_romaji_map() + +# Precompute length-based lookup keys for longest-match-first strategy +_MAX_KEY_LEN: int = max(len(k) for k in _ROMANJI_MAP) + + +def romaji_to_hiragana(text: str) -> str: + """Convert space-separated romaji text to hiragana (no spaces). + + Each whitespace-delimited token is matched against the romaji mapping + table. Unmappable tokens pass through unchanged. Results are joined + without any separator. + + Args: + text: Space-separated romaji string. + + Returns: + Hiragana string without spaces. + + """ + if not text or not text.strip(): + return "" + + result_parts: list[str] = [] + + for word in text.split(): + if not word: + continue + result_parts.append(_convert_word(word)) + + return "".join(result_parts) + + +def _convert_word(word: str) -> str: + """Convert a single romaji word token to hiragana. + + Returns the hiragana conversion if the entire word can be fully + consumed by the romaji mapping. If any character cannot be mapped, + the original word is returned unchanged (e.g. "Hello" → "Hello"). + """ + i = 0 + result: list[str] = [] + while i < len(word): + matched = False + # Try longest match first (from max key length down to 1) + for length in range(min(_MAX_KEY_LEN, len(word) - i), 0, -1): + chunk = word[i:i + length] + if hiragana := _ROMANJI_MAP.get(chunk): + result.append(hiragana) + i += length + matched = True + break + if not matched: + # This word contains an unmappable character — pass through + # the whole token unchanged. + return word + + return "".join(result) diff --git a/LDDC/gui/service.py b/LDDC/gui/service.py index 1850013..1d8cefc 100644 --- a/LDDC/gui/service.py +++ b/LDDC/gui/service.py @@ -51,6 +51,7 @@ from LDDC.common.task_manager import TaskManager from LDDC.common.thread import cross_thread_func, in_main_thread, in_other_thread from LDDC.common.utils import has_content +from LDDC.core.romaji import romaji_to_hiragana from LDDC.core.algorithm import assign_lyrics_positions, find_closest_match from LDDC.core.api.lyrics import get_lyrics from LDDC.core.auto_fetch import auto_fetch @@ -847,6 +848,8 @@ def update_lyrics(self) -> None: continue text = "".join(word.text for word in display_line.words) # 获取当前行歌词文本 + if lang == "roma" and self.config.get("kana"): + text = romaji_to_hiragana(text) if lang == "orig" and self.rubys_mapping: rubys = self.rubys_mapping.get(orig_index, [(0, 0, "")]) if not rubys: diff --git a/LDDC/gui/ui/local_match_ui.py b/LDDC/gui/ui/local_match_ui.py index 22cb6be..e78950e 100644 --- a/LDDC/gui/ui/local_match_ui.py +++ b/LDDC/gui/ui/local_match_ui.py @@ -72,6 +72,13 @@ def setupUi(self, local_match): self.gridLayout.addWidget(self.romanized_checkBox, 1, 11, 1, 1) + self.kana_checkBox = QCheckBox(self.control_bar) + self.kana_checkBox.setObjectName("kana_checkBox") + sizePolicy2.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth()) + self.kana_checkBox.setSizePolicy(sizePolicy2) + + self.gridLayout.addWidget(self.kana_checkBox, 1, 12, 1, 1) + self.label_8 = QLabel(self.control_bar) self.label_8.setObjectName("label_8") sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) @@ -285,6 +292,7 @@ def retranslateUi(self, local_match): self.select_dirs_button.setText(QCoreApplication.translate("local_match", "\u9009\u62e9\u6587\u4ef6\u5939", None)) self.save_path_button.setText(QCoreApplication.translate("local_match", "\u9009\u62e9\u4fdd\u5b58\u8def\u5f84", None)) self.romanized_checkBox.setText(QCoreApplication.translate("local_match", "\u7f57\u9a6c\u97f3", None)) + self.kana_checkBox.setText(QCoreApplication.translate("local_match", "\u5e73\u5047\u540d", None)) self.label_8.setText(QCoreApplication.translate("local_match", "\u6b4c\u8bcd\u6587\u4ef6\u540d:", None)) self.lyricsformat_comboBox.setItemText(0, QCoreApplication.translate("local_match", "LRC(\u9010\u5b57)", None)) self.lyricsformat_comboBox.setItemText(1, QCoreApplication.translate("local_match", "LRC(\u9010\u884c)", None)) diff --git a/LDDC/gui/ui/local_song_lyrics_db_manager/export_lyrics_ui.py b/LDDC/gui/ui/local_song_lyrics_db_manager/export_lyrics_ui.py index 37258c0..021a829 100644 --- a/LDDC/gui/ui/local_song_lyrics_db_manager/export_lyrics_ui.py +++ b/LDDC/gui/ui/local_song_lyrics_db_manager/export_lyrics_ui.py @@ -119,6 +119,13 @@ def setupUi(self, export_lyrics): self.gridLayout.addWidget(self.romanized_checkBox, 2, 3, 1, 2) + self.kana_checkBox = QCheckBox(export_lyrics) + self.kana_checkBox.setObjectName("kana_checkBox") + sizePolicy1.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth()) + self.kana_checkBox.setSizePolicy(sizePolicy1) + + self.gridLayout.addWidget(self.kana_checkBox, 3, 3, 1, 2) + self.retranslateUi(export_lyrics) self.buttonBox.accepted.connect(export_lyrics.accept) @@ -146,5 +153,6 @@ def retranslateUi(self, export_lyrics): self.label_9.setText(QCoreApplication.translate("export_lyrics", "\u6b4c\u8bcd\u7c7b\u578b:", None)) self.original_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u539f\u6587", None)) self.romanized_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u7f57\u9a6c\u97f3", None)) + self.kana_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u5e73\u5047\u540d", None)) # retranslateUi diff --git a/LDDC/gui/ui/open_lyrics_ui.py b/LDDC/gui/ui/open_lyrics_ui.py index ec9c9f9..5ddf522 100644 --- a/LDDC/gui/ui/open_lyrics_ui.py +++ b/LDDC/gui/ui/open_lyrics_ui.py @@ -65,6 +65,13 @@ def setupUi(self, open_lyrics): self.horizontalLayout_2.addWidget(self.romanized_checkBox, 0, Qt.AlignmentFlag.AlignLeft) + self.kana_checkBox = QCheckBox(open_lyrics) + self.kana_checkBox.setObjectName("kana_checkBox") + sizePolicy.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth()) + self.kana_checkBox.setSizePolicy(sizePolicy) + + self.horizontalLayout_2.addWidget(self.kana_checkBox, 0, Qt.AlignmentFlag.AlignLeft) + self.label_5 = QLabel(open_lyrics) self.label_5.setObjectName("label_5") sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) @@ -156,6 +163,7 @@ def retranslateUi(self, open_lyrics): self.original_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u539f\u6587", None)) self.translate_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u8bd1\u6587", None)) self.romanized_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u7f57\u9a6c\u97f3", None)) + self.kana_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u5e73\u5047\u540d", None)) self.label_5.setText(QCoreApplication.translate("open_lyrics", "\u504f\u79fb\u91cf:", None)) self.label_2.setText(QCoreApplication.translate("open_lyrics", "\u8f6c\u6362\u7684\u683c\u5f0f\uff1a", None)) self.lyricsformat_comboBox.setItemText(0, QCoreApplication.translate("open_lyrics", "LRC(\u9010\u5b57)", None)) diff --git a/LDDC/gui/ui/search_base_ui.py b/LDDC/gui/ui/search_base_ui.py index 35e6d03..894f556 100644 --- a/LDDC/gui/ui/search_base_ui.py +++ b/LDDC/gui/ui/search_base_ui.py @@ -231,6 +231,13 @@ def setupUi(self, search_base): self.horizontalLayout_8.addWidget(self.romanized_checkBox) + self.kana_checkBox = QCheckBox(search_base) + self.kana_checkBox.setObjectName("kana_checkBox") + sizePolicy.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth()) + self.kana_checkBox.setSizePolicy(sizePolicy) + + self.horizontalLayout_8.addWidget(self.kana_checkBox) + self.label_5 = QLabel(search_base) self.label_5.setObjectName("label_5") sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) @@ -320,6 +327,7 @@ def retranslateUi(self, search_base): self.original_checkBox.setText(QCoreApplication.translate("search_base", "\u539f\u6587", None)) self.translate_checkBox.setText(QCoreApplication.translate("search_base", "\u8bd1\u6587", None)) self.romanized_checkBox.setText(QCoreApplication.translate("search_base", "\u7f57\u9a6c\u97f3", None)) + self.kana_checkBox.setText(QCoreApplication.translate("search_base", "\u5e73\u5047\u540d", None)) self.label_5.setText(QCoreApplication.translate("search_base", "\u504f\u79fb\u91cf:", None)) self.label_9.setText(QCoreApplication.translate("search_base", "\u6b4c\u8bcd\u683c\u5f0f:", None)) self.lyricsformat_comboBox.setItemText(0, QCoreApplication.translate("search_base", "LRC(\u9010\u5b57)", None)) diff --git a/LDDC/gui/view/desktop_lyrics.py b/LDDC/gui/view/desktop_lyrics.py index 2f38ce4..91fc5d6 100644 --- a/LDDC/gui/view/desktop_lyrics.py +++ b/LDDC/gui/view/desktop_lyrics.py @@ -180,6 +180,8 @@ def show(self, infos: dict | None = None) -> None: self.romanized_checkBox.setChecked(True) else: self.romanized_checkBox.setChecked(False) + self.kana_checkBox.setChecked(cfg["kana"]) + self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()) self.raise_() if self.isMinimized(): self.showNormal() diff --git a/LDDC/gui/view/local_match.py b/LDDC/gui/view/local_match.py index 2e6a4a4..26920c4 100644 --- a/LDDC/gui/view/local_match.py +++ b/LDDC/gui/view/local_match.py @@ -33,6 +33,7 @@ def __init__(self) -> None: self.setupUi(self) self.setAcceptDrops(True) # 启用拖放功能 self.connect_signals() + self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()) self.songs_table.set_proportions([0.2, 0.1, 0.1, 0.3, 2, 0.3, 2]) # 设置列宽比例 self.source_listWidget.set_soures(["QM", "KG", "NE"]) @@ -59,6 +60,9 @@ def connect_signals(self) -> None: self.save_path_button.clicked.connect(self.select_save_root_path) self.start_cancel_pushButton.clicked.connect(self.start_cancel) + self.romanized_checkBox.stateChanged.connect(lambda: self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked())) + self.kana_checkBox.stateChanged.connect(lambda: cfg.setitem("kana", self.kana_checkBox.isChecked())) + self.save_mode_comboBox.currentIndexChanged.connect(self.update_save_paths) self.filename_mode_comboBox.currentIndexChanged.connect(self.update_save_paths) self.save2tag_mode_comboBox.currentIndexChanged.connect(self.update_save_paths) diff --git a/LDDC/gui/view/local_song_lyrics_db_manager.py b/LDDC/gui/view/local_song_lyrics_db_manager.py index ab818c6..681846c 100644 --- a/LDDC/gui/view/local_song_lyrics_db_manager.py +++ b/LDDC/gui/view/local_song_lyrics_db_manager.py @@ -118,7 +118,10 @@ def __init__(self, parent: "LocalSongLyricsDBManager") -> None: self.save_path_button.clicked.connect(self.select_path) self.accepted.connect(self.to_export) + self.romanized_checkBox.stateChanged.connect(lambda: self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked())) + self.kana_checkBox.stateChanged.connect(lambda: cfg.setitem("kana", self.kana_checkBox.isChecked())) self._parent = parent + self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()) def get_langs(self) -> list[str]: """获取选择的的语言""" diff --git a/LDDC/gui/view/open_lyrics.py b/LDDC/gui/view/open_lyrics.py index 76faaff..4e844f6 100644 --- a/LDDC/gui/view/open_lyrics.py +++ b/LDDC/gui/view/open_lyrics.py @@ -26,6 +26,7 @@ def __init__(self) -> None: super().__init__() self.setupUi(self) self.connect_signals() + self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()) self.setAcceptDrops(True) # 启用拖放功能 self.lyrics_type = None self.path: Path | None = None @@ -45,7 +46,8 @@ def connect_signals(self) -> None: self.save2tag_pushButton.clicked.connect(self.save2tag) self.translate_checkBox.stateChanged.connect(self.update_lyrics) - self.romanized_checkBox.stateChanged.connect(self.update_lyrics) + self.romanized_checkBox.stateChanged.connect(lambda: (self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()), self.update_lyrics())) + self.kana_checkBox.stateChanged.connect(lambda: (cfg.setitem("kana", self.kana_checkBox.isChecked()), self.update_lyrics())) self.original_checkBox.stateChanged.connect(self.update_lyrics) self.lyricsformat_comboBox.currentIndexChanged.connect(self.update_lyrics) diff --git a/LDDC/gui/view/search.py b/LDDC/gui/view/search.py index 5d3bea1..5595bfb 100644 --- a/LDDC/gui/view/search.py +++ b/LDDC/gui/view/search.py @@ -43,6 +43,8 @@ def __init__(self, parent: QWidget | None = None) -> None: self._init_task_manager() self._connect_signals() + self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()) + self.path: list[APIResultList | None] = [] # 页面路径 self.lyrics: None | Lyrics = None @@ -79,7 +81,8 @@ def _connect_signals(self) -> None: self.search_pushButton.clicked.connect(self.search) self.translate_checkBox.stateChanged.connect(lambda: self.set_lyrics()) - self.romanized_checkBox.stateChanged.connect(lambda: self.set_lyrics()) + self.romanized_checkBox.stateChanged.connect(lambda: (self.kana_checkBox.setEnabled(self.romanized_checkBox.isChecked()), self.set_lyrics())) + self.kana_checkBox.stateChanged.connect(lambda: (cfg.setitem("kana", self.kana_checkBox.isChecked()), self.set_lyrics())) self.original_checkBox.stateChanged.connect(lambda: self.set_lyrics()) self.lyricsformat_comboBox.currentTextChanged.connect(lambda: self.set_lyrics()) self.offset_spinBox.valueChanged.connect(lambda: self.set_lyrics()) diff --git a/tests/test_romaji.py b/tests/test_romaji.py new file mode 100644 index 0000000..a397f36 --- /dev/null +++ b/tests/test_romaji.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Copyright (C) 2024-2025 沉默の金 +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for LDDC.core.romaji module.""" + +import pytest + +from LDDC.core.romaji import romaji_to_hiragana + + +@pytest.mark.parametrize( + ("input_text", "expected"), + [ + # Basic syllables + ("a", "あ"), + ("i", "い"), + ("u", "う"), + ("e", "え"), + ("o", "お"), + ("ka", "か"), + ("ki", "き"), + ("ku", "く"), + ("ke", "け"), + ("ko", "こ"), + ("na", "な"), + ("shi", "し"), + ("tsu", "つ"), + ("fu", "ふ"), + # Dakuon (voiced) + ("ga", "が"), + ("ji", "じ"), + ("zu", "ず"), + ("de", "で"), + ("bo", "ぼ"), + # Youon (contracted) + ("kya", "きゃ"), + ("shu", "しゅ"), + ("cho", "ちょ"), + ("nya", "にゃ"), + ("ryo", "りょ"), + # Sokuon (geminate consonant) + ("kka", "っか"), + ("tta", "った"), + ("ssa", "っさ"), + ("ppi", "っぴ"), + ("sshi", "っし"), + ("tchi", "っち"), + ("ttsu", "っつ"), + # Sokuon + youon + ("kkya", "っきゃ"), + ("sshu", "っしゅ"), + ("tcho", "っちょ"), + # Space-separated words → no spaces in output + ("a ka", "あか"), + ("ha tsu na", "はつな"), + ("a za ya ka", "あざやか"), + ("na ru", "なる"), + ("sen bon za ku ra", "せんぼんざくら"), + ("i tsu mo na ga me te i ta", "いつもながめていた"), + # Non-Japanese text preserved + ("Hello", "Hello"), + ("ha tsu na Hello", "はつなHello"), + ("Hello World", "HelloWorld"), + ("123", "123"), + ("!?", "!?"), + ("ha tsu na 123", "はつな123"), + # Dakuten youon + ("gya", "ぎゃ"), + ("ja", "じゃ"), + ("ju", "じゅ"), + ("bya", "びゃ"), + ("pya", "ぴゃ"), + # Particle wa vs ha note: "wa" maps to わ + ("wa", "わ"), + ("wo", "を"), + ("n", "ん"), + # Extended vowels + ("aa", "あー"), + ("ii", "いー"), + ("uu", "うー"), + # Apostrophe-sokuon + ("'t", "っ"), + ("'s", "っ"), + ("'k", "っ"), + # Apostrophe-sokuon in context + ("i 't te", "いって"), + ("i 't ta", "いった"), + ("fu e te i 't te", "ふえていって"), + # Mixed complex + ("ha tsu na no ni", "はつなのに"), + ("shi ki sa i", "しきさい"), + # Empty string + ("", ""), + ], +) +def test_romaji_to_hiragana(input_text: str, expected: str) -> None: + assert romaji_to_hiragana(input_text) == expected + + +def test_empty_string() -> None: + assert romaji_to_hiragana("") == "" + + +def test_only_spaces() -> None: + assert romaji_to_hiragana(" ") == "" + + +def test_non_japanese_only() -> None: + assert romaji_to_hiragana("Hello World 123") == "HelloWorld123" + + +def test_long_sentence() -> None: + input_text = "ko re wa ha na ga sa ku" + expected = "これわはながさく" + assert romaji_to_hiragana(input_text) == expected + + +def test_apostrophe_sokuon_in_context() -> None: + """Test that 't adjacent to te produces tte (gemination).""" + assert romaji_to_hiragana("i 't te") == "いって" + assert romaji_to_hiragana("fu e te i 't te") == "ふえていって" + assert romaji_to_hiragana("ma 't te") == "まって" + + +def test_mixed_with_punctuation() -> None: + input_text = "ha i ka ta i" + expected = "はいかたい" + assert romaji_to_hiragana(input_text) == expected + + +def test_no_spaces_preserves_text() -> None: + """Input with no spaces at all should still be treated as one token.""" + assert romaji_to_hiragana("konnnitiwa") != ""