"""Okurigana alignment: a unit's surface + its hiragana reading -> per-kanji-run ruby spans. The reading comes from Sudachi (``units.build_units`` already converted it to hiragana with ``jaconv.kata2hira``); this module only ALIGNS it - it never guesses a reading (research § Anti-Patterns: no furigana by regex or by romaji). The standard trick: split the surface into kana runs and non-kana runs, force every kana run to match the reading verbatim, and let each non-kana run absorb whatever lies between with a non-greedy group. ```` then sits over 食, not over 食べ. Verified on the 23 pairs in ``tests/test_ruby.py``. Ruby is per kanji run: 待ち合わせ -> [待,ま] [ち,-] [合,あ] [わせ,-]. A run's reading cannot be split further (大阪駅 -> one span おおさかえき), which is also why the kanji-axis gate (02-05) is evaluated per run. Fallback is honest: when the kana runs cannot be matched (a reading that does not fit the surface) the whole unit gets one ruby span carrying the whole reading. Never raises. ``jaconv.normalize`` must NOT be used on readings - it folds ー and small kana. Pure function over strings; no Sudachi import. """ from __future__ import annotations import re import jaconv #: Character class body for kana: hiragana ぁ-ゖ, the hiragana iteration marks ゝゞ, katakana #: ァ-ヶ, the prolonged-sound mark ー and the katakana iteration marks ヽヾ. Everything else - #: kanji, 々, digits, Latin, punctuation - is a "non-kana run" and receives ruby. KANA = "ぁ-ゖゝゞァ-ヶーヽヾ" _RUNS = re.compile(f"[{KANA}]+|[^{KANA}]+") _KANA_START = re.compile(f"[{KANA}]") #: Kanji code point ranges: CJK Unified Ideographs Extension A, CJK Unified Ideographs, CJK #: Compatibility Ideographs, the supplementary planes (Extensions B-F, e.g. 𠮷), and U+3005 々, #: the iteration mark, which repeats the kanji before it and therefore belongs to its run. _KANJI_RANGES = ( (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x2FA1F), ) _ITERATION_MARK = 0x3005 def is_kanji(ch: str) -> bool: """True for a single kanji character (including 々). False for kana, Latin, digits, marks.""" cp = ord(ch) return cp == _ITERATION_MARK or any(lo <= cp <= hi for lo, hi in _KANJI_RANGES) def _runs(surface: str) -> list[tuple[str, bool]]: """``[(text, is_kana), ...]`` - the surface split into maximal kana / non-kana runs.""" return [(m.group(0), bool(_KANA_START.match(m.group(0)))) for m in _RUNS.finditer(surface)] def kanji_runs(surface: str) -> list[str]: """The non-kana runs of ``surface`` that contain at least one kanji, in order. These are the spans that receive ruby and that the kanji-axis gate evaluates: if any kanji in a run is above the learner's level, the whole run shows its reading. """ return [text for text, kana in _runs(surface) if not kana and any(is_kanji(c) for c in text)] def align_ruby(surface: str, reading_hira: str) -> list[list[str | None]]: """Split ``surface`` into ``[text, rt]`` spans: ``rt`` is None over kana, hiragana otherwise. ``reading_hira`` must already be hiragana (the caller converts Sudachi's katakana with ``jaconv.kata2hira``). Returns one bare span for an all-kana surface, one whole-unit ruby span when the reading cannot be aligned, and never raises. """ runs = _runs(surface) if all(kana for _, kana in runs): return [[surface, None]] if not reading_hira: return [[surface, reading_hira]] pattern = "".join( "(" + re.escape(jaconv.kata2hira(text)) + ")" if kana else "(.+?)" for text, kana in runs ) match = re.fullmatch(pattern, reading_hira) if not match: return [[surface, reading_hira]] # honest fallback: whole-unit ruby return [ [text, None if kana else group] for (text, kana), group in zip(runs, match.groups(), strict=True) ]