Source code for ctnx.syllable

# -*- coding: utf-8 -*-

from __future__ import annotations

from typing import Union, Iterable, Set, overload
from abc import ABC, abstractmethod
from functools import lru_cache

from .constants import TONES, ALL_RIMES
from .misc import nfc_normalize, place_tone_to_char, separate_tone, is_even_tone


[docs]def find_startswith(text: str, candidates: Iterable): try: return next(filter(text.startswith, candidates)) except StopIteration: return None
[docs]class TonePlacer(ABC): """Controls tone mark placement on Vietnamese vowels."""
[docs] @staticmethod def place_to_vowels_at(vowels, tone, position): return vowels[:position] + place_tone_to_char(vowels[position], tone) + vowels[position+1:]
[docs] @classmethod def place(cls, syllable: Syllable, nucleus) -> str: i = cls.placement_index(syllable) return cls.place_to_vowels_at(nucleus, syllable.tone, i)
[docs] @classmethod @abstractmethod def placement_index(cls, syllable: Syllable) -> int: pass
[docs]class NewStyleTonePlacer(TonePlacer): """Places tone marks according to the new style of Vietnamese spelling (e.g., 'oà', 'uý')."""
[docs] @classmethod def placement_index(cls, syllable: Syllable) -> int: nucleus = syllable.nucleus nucleus_len = len(nucleus) assert nucleus_len > 0 if nucleus_len == 1: return 0 elif nucleus_len == 2: if (nucleus in {'uy', 'uơ'}) or (syllable.onset == 'q'): return 1 elif nucleus in Syllable.CLOSED_DIPHTHONGS: return 0 else: return 1 elif nucleus_len == 3: if nucleus in Syllable.CLOSED_TRIPHTHONGS: return 1 else: return 2 else: return 2
[docs]class OldStyleTonePlacer(NewStyleTonePlacer): """Places tone marks according to the old style of Vietnamese spelling (e.g., 'òa', 'úy')."""
[docs] @classmethod def placement_index(cls, syllable: Syllable) -> int: if (syllable.nucleus in {'oo', 'ôô'}) or ((syllable.rime in {'oa', 'oe', 'uy'}) and (syllable.onset != 'q')): return 0 else: return super().placement_index(syllable)
[docs]class Syllable: """Represents a syllable in the Vietnamese language.""" ONSETS = ('b', 'ch', 'c', 'd', 'đ', 'gh', 'gi', 'g', 'h', 'kh', 'k', 'l', 'm', 'ngh', 'ng', 'nh', 'ng', 'n', 'ph', 'p', 'q', 'r', 's', 'th', 'tr', 't', 'v', 'x', '') MONOPHTHONGS = ('a', 'ă', 'â', 'e', 'ê', 'i', 'o', 'ô', 'ơ', 'u', 'ư', 'y') # 'oo' and 'ôô' are not diphthongs but they're denoted using two characters OPEN_DIPHTHONGS = ('iê', 'oo', 'ôô', 'uâ', 'uô', 'ươ', 'yê') CLOSEABLE_DIPHTHONGS = ('oa', 'oă', 'oe', 'uê', 'uy') CLOSED_DIPHTHONGS = ('ai', 'ao', 'au', 'ay', 'âu', 'ây', 'eo', 'êu', 'ia', 'iu', 'oi', 'ôi', 'ơi', 'ua', 'ui', 'uơ', 'ưa', 'ưi', 'ưu', 'yu') # Also includes combinations of an onglide semivowel and a vowel DIPHTHONGS = OPEN_DIPHTHONGS + CLOSEABLE_DIPHTHONGS + CLOSED_DIPHTHONGS # 'uêu' is the lip-rounded form of 'êu' # 'uơi' is the lip-rounded form of 'ơi' # 'uyêu' is the lip-rounded form of 'iêu'/'yêu' CLOSED_TRIPHTHONGS = ('iêu', 'oai', 'oao', 'oau', 'oay', 'oeo', 'uay', 'uây', 'uêu', 'uôi', 'uya', 'uyu', 'uơi', 'ươi', 'ươu', 'yêu') OPEN_TRIPHTHONGS = ('uyê',) # These "triphthongs" are actually diphthongs + semivowel (oversimplified) TRIPHTHONGS = CLOSED_TRIPHTHONGS + OPEN_TRIPHTHONGS OTHER_CLOSED_NUCLEI = ('uyêu',) OTHER_NUCLEI = OTHER_CLOSED_NUCLEI OPEN_NUCLEI = OPEN_TRIPHTHONGS + OPEN_DIPHTHONGS CLOSED_NUCLEI = OTHER_CLOSED_NUCLEI + CLOSED_TRIPHTHONGS + CLOSED_DIPHTHONGS NUCLEI = OTHER_NUCLEI + TRIPHTHONGS + DIPHTHONGS + MONOPHTHONGS NON_LIP_ROUNDABLE_NUCLEI_SET = {'o', 'oi', 'oo', 'u', 'ua', 'ui', 'uô', 'uôi'} QU_NUCLEUS_PREFIX_REPLACEMENTS = {'ua': 'oa', 'uă' : 'oă', 'ue': 'oe'} CODAS = ('ch', 'c', 'm', 'ng', 'nh', 'n', 'p', 't', '') def __init__(self, onset: str, nucleus: str, coda: str, tone='', auto_correct: bool = True, tone_placer = NewStyleTonePlacer): self.auto_correct: bool = auto_correct self.tone_placer: type[TonePlacer] = tone_placer self._onset = onset self._nucleus = nucleus self._coda = coda self._tone = tone self.update(onset, nucleus, coda, tone) def __repr__(self): if self.tone: return f"Syllable({self.onset}, {self.nucleus}, {self.coda}, {self.tone})" else: return f"Syllable({self.onset}, {self.nucleus}, {self.coda})" def __str__(self): return self.to_string() def __bool__(self): return True @overload def __eq__(self, other: str) -> bool: ... @overload def __eq__(self, other: Syllable) -> bool: ... def __eq__(self, other) -> bool: if isinstance(other, str): other = Syllable.from_string(other) if isinstance(other, Syllable): return self.onset == other.onset and self.i_normalized_nucleus == other.i_normalized_nucleus and self.coda == self.coda and self.tone == other.tone return False @classmethod def _parse_rime(cls, string: str, has_q_onset: bool) -> tuple: if has_q_onset: prefix = string[:2] if prefix in cls.QU_NUCLEUS_PREFIX_REPLACEMENTS: string = cls.QU_NUCLEUS_PREFIX_REPLACEMENTS[prefix] + string[2:] nucleus = find_startswith(string, cls.NUCLEI) if nucleus: string = string[len(nucleus):] # Nucleus may be empty when parsing the 'gìn' syllable. coda = find_startswith(string, cls.CODAS) assert coda is not None string = string[len(coda):] return (nucleus, coda, string) @classmethod @lru_cache def _from_string(cls, string: str, auto_correct=True, tone_placer=NewStyleTonePlacer) -> Syllable: """Creates a Syllable object from a string.""" string = nfc_normalize(string).lower() if ' ' in string: raise Exception(f"The input string must not have whitespaces") original = string onset = nucleus = coda = tone = '' if string in {'gịa', 'gỵa'}: return cls('gi', 'ia', '', '.', auto_correct=auto_correct, tone_placer=tone_placer) string, tone = separate_tone(string) onset = find_startswith(string, cls.ONSETS) assert onset is not None string = string[len(onset):] if onset == 'gi': if (len(string) > 1) and (string[0] == 'ê'): string = 'i' + string nucleus, coda, rest = cls._parse_rime(string, onset == 'q') if not nucleus: if onset == 'gi': nucleus = 'i' else: raise Exception(f"Invalid syllable: {original}") if rest != '': raise Exception( f"Unexpected characters '{rest}' after a syllable (in '{original}'')") return cls(onset, nucleus, coda, tone, auto_correct=auto_correct, tone_placer=tone_placer)
[docs] @classmethod @lru_cache def from_string(cls, string: str, auto_correct=True, tone_placer=NewStyleTonePlacer) -> Syllable: """Creates a Syllable object from a string. This method is cached by default. Use :py:meth:`_from_string` for the uncached version. """ return cls._from_string(string, auto_correct, tone_placer)
[docs] def to_string(self) -> str: """Returns the written form of the syllable.""" onset = self.onset nucleus = self.nucleus coda = self.coda if (onset == 'gi') and ((nucleus == 'i') or (nucleus[:2] in {'iê', 'ia'})): onset = 'g' if onset == 'q' and nucleus[0] == 'o': nucleus = 'u' + nucleus[1:] return ''.join((onset, self.tone_placer.place(self, nucleus), coda))
[docs] def update(self, onset: str, nucleus: str, coda: str, tone: str): onset, nucleus, coda = onset.lower(), nucleus.lower(), coda.lower() orig_onset, orig_nuclues, orig_coda, orig_tone = onset, nucleus, coda, tone if nucleus not in self.NUCLEI: ValueError(f"Invalid nucleus: {nucleus}") elif onset not in self.ONSETS: ValueError(f"Invalid onset: {onset}") elif coda not in self.CODAS: ValueError(f"Invalid coda: {coda}") elif tone not in TONES: ValueError(f"Invalid tone: '{tone}'") if nucleus == 'uô' and coda == 'c': if onset == 'k': onset = 'c' elif self._nucleus_has_onglide_semivowel(nucleus): if onset in {'c', 'k'}: onset = 'q' elif onset == 'q': onset = 'c' if onset in {'c', 'k'}: onset = 'k' if nucleus[0] in {'e', 'ê', 'i', 'y'} else 'c' if onset in {'ng', 'ngh'}: onset = 'ngh' if (nucleus[0] in {'e', 'ê', 'i'}) else 'ng' if onset == '': if nucleus == 'iêu': nucleus = 'yêu' elif nucleus == 'iê': nucleus = 'yê' else: if nucleus == 'yêu': nucleus = 'iêu' elif nucleus == 'yê': nucleus = 'iê' if coda == '': if nucleus in self.OPEN_NUCLEI: raise ValueError( f"Open syllable (nucleus: {nucleus}) must have a coda") elif nucleus in self.CLOSED_NUCLEI: coda = '' if (coda in {'c', 'p', 't'}) and not (tone in {'/', '.'}): if tone in {'\\', '?'}: tone = '.' if tone in {'', '~'}: tone = '/' rime = self._i_normalize_nucleus(nucleus) + coda if rime not in ALL_RIMES: if not self.auto_correct: raise ValueError(f"Invalid rime: {orig_nuclues + orig_coda}") if rime in {'iênh', 'yênh'}: coda = 'ng' # iêng/yêng elif rime in {'iêch', 'yêch'}: coda = 'ng' # iêc/yêc rime = self._i_normalize_nucleus(nucleus) + coda if rime not in ALL_RIMES: raise ValueError(f"Invalid rime: {orig_nuclues + orig_coda} (after auto-fix: {rime})") if not self.auto_correct: if onset != orig_onset: raise ValueError(f"Invalid onset consonant: {orig_onset}") elif nucleus != orig_nuclues: raise ValueError(f"Invalid nucleus: {orig_nuclues}") elif coda != orig_coda: raise ValueError(f"Invalid coda: {orig_coda}") elif tone != orig_tone: raise ValueError(f"Invalid tone: {orig_tone}") self._onset = onset self._nucleus = nucleus self._coda = coda self._tone = tone
@property def onset(self) -> str: """The initial consonant part of the syllable.""" return self._onset @onset.setter def onset(self, value: str): self.update(value, self.nucleus, self.coda, self.tone) @property def nucleus(self) -> str: """The vowel part of the syllable as stored in this object, including semivowels.""" return self._nucleus @nucleus.setter def nucleus(self, value: str): self.update(self.onset, value, self.coda, self.tone) @property def coda(self) -> str: """The final consonant part of the syllable.""" return self._coda @coda.setter def coda(self, value: str): self.update(self.onset, self.nucleus, value, self.tone) @property def tone(self) -> str: """The tone of the syllable. The returned tone is denoted as one of the following: '': unmarked (ngang) '/': acute accent (sắc) '\\': grave accent (huyền) '?': hook above (hỏi) '~': tilde (ngã) '.': dot below (nặng) """ return self._tone @tone.setter def tone(self, value: str): self.update(self.onset, self.nucleus, self.coda, value) @staticmethod def _i_normalize_nucleus(nucleus): if nucleus == 'yêu': return 'iêu' elif nucleus == 'yê': return 'iê' elif nucleus == 'y': return 'i' else: return nucleus @property def i_normalized_nucleus(self) -> str: """The vowel part of the syllable, with i/y-normalized value.""" return self._i_normalize_nucleus(self.nucleus) @property def normalized_nucleus(self) -> str: """The normalized vowel part of the syllable, resolving spelling variants like 'iê' to 'ia' when there is no coda.""" nucleus = self.i_normalized_nucleus if self.coda == '': if nucleus == 'iê': nucleus = 'ia' elif nucleus == 'uô': nucleus = 'ua' elif nucleus == 'ươ': nucleus = 'ưa' return nucleus @property def rime(self) -> str: """The rime of the syllable, which is the combination of the nucleus and the coda.""" return self.nucleus + self.coda @rime.setter def rime(self, rime: str): nucleus, coda, rest = self._parse_rime(rime, self.onset == 'q') if not nucleus: raise Exception('Invalid rime: rime') if rest != '': raise Exception( f"Unexpected characters '{rest}' after a rime (in '{rime}'')") self.update(onset=self.onset, nucleus=nucleus, coda=coda, tone=self.tone) @property def normalized_rime(self) -> str: """The rime of the syllable, with i/y-normalized value.""" return self.i_normalized_nucleus + self.coda @staticmethod def _nucleus_has_onglide_semivowel(nucleus: str) -> bool: return (nucleus[0] in {'o', 'u'}) and (nucleus not in Syllable.NON_LIP_ROUNDABLE_NUCLEI_SET) @property def has_onglide_semivowel(self) -> bool: """Checks whether the syllable has an on-glide semivowel, as present in 'qua' and 'xoa'.""" return self._nucleus_has_onglide_semivowel(self.nucleus) @property def has_even_tone(self) -> bool: """Checks whether the syllable's tone is even (not oblique).""" return is_even_tone(self.tone) @property def has_oblique_tone(self) -> bool: """Checks whether the syllable's tone is oblique (uneven).""" return not self.has_even_tone @staticmethod def _check_rime_groups_exact(rime1: str, rime2: str, rime_groups: Iterable[Set[str]]) -> bool: for rimes in rime_groups: if (rime1 in rimes) and (rime2 in rimes): return True return False
[docs] def is_rhyme_with(self, other: Union[Syllable, str]) -> bool: """Checks whether the syllable rhymes with another syllable. Besides exact rime matching, this method uses predefined rules for detecting similar rimes. Similar-rime rules are based on https://bentinhyeu.forumvi.com/t10-topic and https://hoaanhdao0603082010.violet.vn/entry/van-trong-tho-12076153.html .""" if isinstance(other, str): other = Syllable.from_string(other) self_rime = self.normalized_rime other_rime = other.normalized_rime if self_rime == other_rime: return True if self._check_rime_groups_exact(self_rime, other_rime, ( {'a', 'ơ'}, {'ư', 'ơ'}, {'e', 'ê', 'i'}, {'o', 'ô', 'u'}, {'ai', 'ay', 'ây'}, {'ai', 'oi', 'ôi', 'ơi', 'ươi', 'ui'}, {'ao', 'au'}, {'âu', 'au'}, {'ao', 'eo', 'êu', 'iêu', 'iu', 'ưu'}, {'am', 'ơm'}, {'ăm', 'âm'}, {'em', 'êm', 'im'}, {'an', 'ơn'}, {'on', 'un'}, {'ăn', 'ân', 'uân'}, {'en', 'in', 'iên', 'uyên'}, {'on', 'ôn', 'uôn', }, {'ang', 'ương', }, {'uông', 'ương', }, {'ăng', 'âng', 'ưng'}, {'ong', 'ông', 'ung'}, {'anh', 'ênh', 'inh', 'oanh', 'uynh'}, )): return True elif self.has_oblique_tone and other.has_oblique_tone and \ self._check_rime_groups_exact(self_rime, other_rime, ( {'o', 'ua'}, {'ia', 'uê'}, {'ac', 'ươc'}, {'âc', 'ưc'}, {'at', 'ưt'}, {'ât', 'ăt'}, {'it', 'uyêt'}, {'ut', 'uôt'}, )): return True else: return False
@property def can_apply_onglide_semivowel(self) -> bool: """Checks whether an on-glide semivowel can be applied to the syllable's nucleus.""" return self.i_normalized_nucleus[0] in {'a', 'ă', 'â', 'e', 'ê', 'i', 'ơ', 'y'} @staticmethod def _apply_onglide_semivowel_to_nucleus(nucleus): if nucleus[0] in {'i', 'y'}: nucleus = 'uy' + nucleus[1:] elif nucleus[0] in {'a', 'ă', 'e'}: nucleus = 'o' + nucleus else: # â, ê, ơ nucleus = 'u' + nucleus return nucleus
[docs] def apply_onglide_semivowel(self) -> bool: """Applies an on-glide semivowel to the syllable, modifying the nucleus (and onset if necessary).""" if not self.can_apply_onglide_semivowel: return False if self.onset in {'c', 'k'}: self.onset = 'q' self.nucleus = self._apply_onglide_semivowel_to_nucleus(self.nucleus) return True