Source code for pythainlp.util.syllable

# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""Syllable tools."""

from __future__ import annotations

import itertools
import re
from re import Pattern
from typing import Optional

from pythainlp import thai_consonants, thai_tonemarks

spelling_class: dict[str, list[str]] = {
    "กง": list("ง"),
    "กม": list("ม"),
    "เกย": list("ย"),
    "เกอว": list("ว"),
    "กน": list("นญณรลฬ"),
    "กก": list("กขคฆ"),
    "กด": list("ดจชซฎฏฐฑฒตถทธศษส"),
    "กบ": list("บปภพฟ"),
}

thai_consonants_all: set[str] = set(thai_consonants)
thai_consonants_all.remove("อ")

_temp: list[str] = list("".join(["".join(v) for v in spelling_class.values()]))
not_spelling_class: list[str] = [
    j for j in thai_consonants_all if j not in _temp
]

# vowel's short sound
short: str = "ะัิึุ"
re_short: Pattern[str] = re.compile(
    "เ(.*)ะ|แ(.*)ะ|เ(.*)อะ|โ(.*)ะ|เ(.*)าะ", re.UNICODE
)
pattern: Pattern[str] = re.compile(
    "เ(.*)า", re.UNICODE
)  # เ-า is live syllable

_check_1: list[str] = []
# These spelling consonants are live syllables.
for i in ["กง", "กน", "กม", "เกย", "เกอว"]:
    _check_1.extend(spelling_class[i])

# These spelling consonants are dead syllables.
_check_2: list[str] = (
    spelling_class["กก"] + spelling_class["กบ"] + spelling_class["กด"]
)

thai_low_sonorants: list[str] = list("งนมยรลว")
thai_low_aspirates: list[str] = list("คชซทพฟฮ")
thai_low_irregular: list[str] = list("ฆญณธภฅฌฑฒฬ")

thai_mid_plains: list[str] = list("กจดตบปอฎฏ")

thai_high_aspirates: list[str] = list("ขฉถผฝสห")
thai_high_irregular: list[str] = list("ศษฃฐ")
thai_initial_consonant_type: dict[str, list[str]] = {
    "low": thai_low_sonorants + thai_low_aspirates + thai_low_irregular,
    "mid": thai_mid_plains,
    "high": thai_high_aspirates + thai_high_irregular,
}
thai_initial_consonant_to_type: dict[str, str] = {}

k: str
v: list[str]
for k, v in thai_initial_consonant_type.items():
    for i in v:
        thai_initial_consonant_to_type[i] = k


_SHORT_SET: frozenset[str] = frozenset(short)
_VOWELS_ANY_1: frozenset[str] = frozenset("าีืแูเโไใำ")
_VOWELS_ANY_2: frozenset[str] = frozenset("าีืแูาเโ")
_VOWELS_ANY_3: frozenset[str] = frozenset("าีืแูาโ")
_VOWELS_ANY_4: frozenset[str] = frozenset("ำใไ")


def _has_any(syllable: str, chars: frozenset[str]) -> bool:
    """Check whether the syllable has any of the given characters."""
    return not chars.isdisjoint(syllable)


def _has_short_sound(syllable: str) -> bool:
    """Check whether the syllable has a short vowel sound."""
    return bool(re_short.search(syllable)) or _has_any(syllable, _SHORT_SET)


def _sound_vowel_only(syllable: str) -> str:
    """Classify a syllable with อ but no other consonant."""
    if _has_any(syllable, _VOWELS_ANY_1):
        return "live"
    if _has_any(syllable, _SHORT_SET):
        return "dead"
    return "live"


def _sound_with_long_vowel(syllable: str, spelling_consonant: str) -> str:
    """Classify a syllable with า, ี, ื, แ, ู, or โ."""
    has_re_short = bool(re_short.search(syllable))
    if not has_re_short and (
        spelling_consonant in _check_1 or spelling_consonant != syllable[-1]
    ):
        return "live"
    if spelling_consonant in _check_2:
        return "dead"
    if _has_short_sound(syllable):
        return "dead"
    return "live"


def _sound_live_final(syllable: str, consonant_count: int) -> str:
    """Classify a syllable whose final consonant is a live final."""
    if _has_short_sound(syllable) and consonant_count < 2:
        return "dead"
    if syllable[-1] in _SHORT_SET:
        return "dead"
    return "live"


[docs] def sound_syllable(syllable: str) -> str: """ Classify the sound of a Thai syllable as live or dead. :param str syllable: Thai syllable :return: type of the syllable ("live" or "dead") :rtype: str :Example: >>> from pythainlp.util import sound_syllable >>> sound_syllable("มา") 'live' >>> sound_syllable("เลข") 'dead' >>> sound_syllable("ฤๅ") 'live' """ if len(syllable) < 2: return "dead" consonants = [i for i in syllable if i in thai_consonants_all] if len(consonants) == 0 and "อ" in syllable: return _sound_vowel_only(syllable) if not consonants: return "dead" if _has_short_sound(syllable) else "live" spelling_consonant = consonants[-1] if ( spelling_consonant in _check_2 and not _has_any(syllable, _VOWELS_ANY_2) and not _has_any(syllable, _VOWELS_ANY_4) and not pattern.search(syllable) ): return "dead" if _has_any(syllable, _VOWELS_ANY_3): return _sound_with_long_vowel(syllable, spelling_consonant) # ำ, ใ, ไ and เ-า are live if _has_any(syllable, _VOWELS_ANY_4) or pattern.search(syllable): return "live" if spelling_consonant in _check_1: return _sound_live_final(syllable, len(consonants)) return "dead"
[docs] def syllable_open_close_detector(syllable: str) -> str: """ Detect whether a Thai syllable is open or closed. :param str syllable: Thai syllable :return: "open" or "close" :rtype: str :Example: >>> from pythainlp.util import syllable_open_close_detector >>> syllable_open_close_detector("มาก") 'close' >>> syllable_open_close_detector("คะ") 'open' """ consonants = [i for i in syllable if i in thai_consonants] if len(consonants) < 2: return "open" if len(consonants) == 2 and consonants[-1] == "อ": return "open" return "close"
[docs] def syllable_length(syllable: str) -> str: """ Detect the vowel length of a Thai syllable. :param str syllable: Thai syllable :return: length of the syllable ("long" or "short") :rtype: str :Example: >>> from pythainlp.util import syllable_length >>> syllable_length("มาก") 'long' >>> syllable_length("คะ") 'short' """ consonants = [i for i in syllable if i in thai_consonants] if len(consonants) <= 3 and _has_any(syllable, _SHORT_SET): return "short" if bool(re_short.search(syllable)): return "short" return "long"
def _tone_mark_detector(syllable: str) -> str: tone_mark = [i for i in syllable if i in thai_tonemarks] if tone_mark == []: return "" return tone_mark[0] def _check_sonorant_syllable(syllable: str) -> bool: _sonorant = [i for i in syllable if i in thai_low_sonorants] consonants = [i for i in syllable if i in thai_consonants] # Return False if no sonorants or not enough consonants if not _sonorant or len(consonants) < 2: return False if _sonorant[-1] == consonants[-2]: return True return _sonorant[-1] == consonants[-1] def _tone_ah_sonorant(initial: str, sound: str, tone_mark: str) -> str: """Tone rules for อ or ห followed by a sonorant ending.""" if sound == "live": if tone_mark == "่": return "l" if initial in ("อ", "ห") else "" if initial == "ห": return "f" if tone_mark == "้" else "r" return "" return "l" # Ordered rules: (initial type, tone mark, sound, length, open/close, tone). # None matches any value. The first matching rule wins. _TONE_RULES: tuple[ tuple[ Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], str, ], ..., ] = ( ("high", "่", "live", None, None, "l"), ("mid", "่", "live", None, None, "l"), ("low", "้", None, None, None, "h"), ("mid", "๋", None, None, None, "r"), ("mid", "๊", None, None, None, "h"), ("low", "่", None, None, None, "f"), ("mid", "้", None, None, None, "f"), ("high", "้", None, None, None, "f"), ("low", None, "dead", "short", "close", "h"), ("low", None, "dead", "long", "close", "f"), ("low", None, None, "short", "open", "h"), ("low", None, "dead", "long", "open", "f"), ("mid", None, "dead", None, None, "l"), ("high", None, "dead", None, None, "l"), ("low", None, "live", None, None, "m"), ("mid", None, "live", None, None, "m"), ("high", None, "live", None, None, "r"), ) def _match_tone_rule(actual: tuple[str, ...]) -> str: """Return the tone of the first rule that matches; "" if none does.""" for rule in _TONE_RULES: if all(c is None or c == a for c, a in zip(rule, actual)): return rule[-1] return "" def _build_tone_table() -> dict[tuple[str, ...], str]: """Expand ``_TONE_RULES`` into a table for every possible input.""" table: dict[tuple[str, ...], str] = {} for key in itertools.product( thai_initial_consonant_type, ("", *thai_tonemarks), ("live", "dead"), ("short", "long"), ("open", "close"), ): table[key] = _match_tone_rule(key) return table _TONE_TABLE: dict[tuple[str, ...], str] = _build_tone_table()
[docs] def tone_detector(syllable: str) -> str: """ Detect the tone of a Thai syllable. The tone is one of: - l: low - m: mid - r: rising - f: falling - h: high - empty string: cannot be detected :param str syllable: Thai syllable :return: tone of the syllable (l, m, h, r, f), or an empty string if it cannot be detected :rtype: str :Example: >>> from pythainlp.util import tone_detector >>> tone_detector("มา") 'm' >>> tone_detector("ไม้") 'h' """ sound = sound_syllable(syllable) consonants = [i for i in syllable if i in thai_consonants] # Syllables with no consonants (e.g., ฤ, ฦ) if len(consonants) == 0: return "" initial_consonant = consonants[0] tone_mark = _tone_mark_detector(syllable) # Special handling for อ and ห with sonorants if ( len(consonants) > 1 and initial_consonant in ("อ", "ห") and _check_sonorant_syllable(syllable) ): result = _tone_ah_sonorant(initial_consonant, sound, tone_mark) if result: return result initial_type = thai_initial_consonant_to_type[initial_consonant] length = syllable_length(syllable) open_close = syllable_open_close_detector(syllable) actual = (initial_type, tone_mark, sound, length, open_close) return _TONE_TABLE.get(actual, "")