WolfDavid's picture
feat(02-02): okurigana-aligned ruby spans
f7e73d2
Raw History Blame
3.94 kB
"""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. ``<rt>`` 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)
]