# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""
Thai pronunciation transliteration from the Wiktionary th-pron module.
Source code: https://en.wiktionary.org/wiki/Module:th-pron
"""
from __future__ import annotations
import re
import unicodedata
from typing import Optional, cast
from pythainlp import thai_digits, thai_tonemarks
_THAI_RANGE: str = r"[ก-๛̄]"
_SYSTEMS: dict[str, int] = {
"paiboon": 0,
"royin": 1,
"ipa": 2,
}
_INITIAL: dict[str, dict[str, list[str] | str]] = {
"ก": {"seq": ["g", "k", "k"], "class": "mid"},
"จ": {"seq": ["j", "ch", "t͡ɕ"], "class": "mid"},
"ด": {"seq": ["d", "d", "d"], "class": "mid"},
"ฎ": {"seq": ["d", "d", "d"], "class": "mid"},
"ฏ": {"seq": ["dt", "t", "t"], "class": "mid"},
"ต": {"seq": ["dt", "t", "t"], "class": "mid"},
"บ": {"seq": ["b", "b", "b"], "class": "mid"},
"ป": {"seq": ["bp", "p", "p"], "class": "mid"},
"อ": {"seq": ["", "@", "ʔ"], "class": "mid"},
"ง": {"seq": ["ng", "$ng", "ŋ"], "class": "low"},
"ณ": {"seq": ["n", "n", "n"], "class": "low"},
"น": {"seq": ["n", "n", "n"], "class": "low"},
"ม": {"seq": ["m", "m", "m"], "class": "low"},
"ญ": {"seq": ["y", "y", "j"], "class": "low"},
"ย": {"seq": ["y", "y", "j"], "class": "low"},
"ร": {"seq": ["r", "r", "r"], "class": "low"},
"ล": {"seq": ["l", "l", "l"], "class": "low"},
"ฬ": {"seq": ["l", "l", "l"], "class": "low"},
"ว": {"seq": ["w", "w", "w"], "class": "low"},
"ค": {"seq": ["k", "kh", "kʰ"], "class": "low"},
"ฅ": {"seq": ["k", "kh", "kʰ"], "class": "low"},
"ฆ": {"seq": ["k", "kh", "kʰ"], "class": "low"},
"ข": {"seq": ["k", "kh", "kʰ"], "class": "high"},
"ฃ": {"seq": ["k", "kh", "kʰ"], "class": "high"},
"ช": {"seq": ["ch", "ch", "t͡ɕʰ"], "class": "low"},
"ฌ": {"seq": ["ch", "ch", "t͡ɕʰ"], "class": "low"},
"ฉ": {"seq": ["ch", "ch", "t͡ɕʰ"], "class": "high"},
"ฑ": {"seq": ["t", "th", "tʰ"], "class": "low"},
"ฒ": {"seq": ["t", "th", "tʰ"], "class": "low"},
"ท": {"seq": ["t", "th", "tʰ"], "class": "low"},
"ธ": {"seq": ["t", "th", "tʰ"], "class": "low"},
"ฐ": {"seq": ["t", "th", "tʰ"], "class": "high"},
"ถ": {"seq": ["t", "th", "tʰ"], "class": "high"},
"พ": {"seq": ["p", "ph", "pʰ"], "class": "low"},
"ภ": {"seq": ["p", "ph", "pʰ"], "class": "low"},
"ผ": {"seq": ["p", "ph", "pʰ"], "class": "high"},
"ฟ": {"seq": ["f", "f", "f"], "class": "low"},
"ฝ": {"seq": ["f", "f", "f"], "class": "high"},
"ซ": {"seq": ["s", "s", "s"], "class": "low"},
"ศ": {"seq": ["s", "s", "s"], "class": "high"},
"ษ": {"seq": ["s", "s", "s"], "class": "high"},
"ส": {"seq": ["s", "s", "s"], "class": "high"},
"ฮ": {"seq": ["h", "h", "h"], "class": "low"},
"ห": {"seq": ["h", "h", "h"], "class": "high"},
"หง": {"seq": ["ng", "$ng", "ŋ"], "class": "high"},
"หน": {"seq": ["n", "n", "n"], "class": "high"},
"หม": {"seq": ["m", "m", "m"], "class": "high"},
"หญ": {"seq": ["y", "y", "j"], "class": "high"},
"หย": {"seq": ["y", "y", "j"], "class": "high"},
"หร": {"seq": ["r", "r", "r"], "class": "high"},
"หล": {"seq": ["l", "l", "l"], "class": "high"},
"หว": {"seq": ["w", "w", "w"], "class": "high"},
"…": {"seq": ["…", "…", "…"], "class": ""},
"": {"seq": ["", "", ""], "class": ""},
}
_VOWEL: dict[str, dict[str, list[str]]] = {
"open": {
"ะ": ["a", "a", "a"],
"": ["a", "a", "a"],
"ิ": ["i", "i", "i"],
"ึ": ["ʉ", "ue", "ɯ"],
"ุ": ["u", "u", "u"],
"เะ": ["e", "e", "eʔ"],
"แะ": ["ɛ", "ae", "ɛʔ"],
"โะ": ["o", "o", "oʔ"],
"เาะ": ["ɔ", "o", "ɔʔ"],
"็": ["ɔ", "o", "ɔ"],
"เิ": ["ə", "oe", "ɤ"],
"เอะ": ["ə", "oe", "ɤʔ"],
"า": ["aa", "a", "aː"],
"ี": ["ii", "i", "iː"],
"ู": ["uu", "u", "uː"],
"ือ": ["ʉʉ", "ue", "ɯː"],
"เ": ["ee", "e", "eː"],
"แ": ["ɛɛ", "ae", "ɛː"],
"โ": ["oo", "o", "oː"],
"อ": ["ɔɔ", "o", "ɔː"],
"ร": ["ɔɔn", "on", "ɔːn"],
"เอ": ["əə", "oe", "ɤː"],
"เียะ": ["ia", "ia", "ia̯ʔ"],
"เือะ": ["ʉa", "uea", "ɯa̯ʔ"],
"ัวะ": ["ua", "ua", "ua̯ʔ"],
"เีย": ["iia", "ia", "ia̯"],
"เือ": ["ʉʉa", "uea", "ɯa̯"],
"ัว": ["uua", "ua", "ua̯"],
"ิว": ["iu", "io", "iw"],
"ีว": ["iiu", "io", "iːw"],
"เ็ว": ["eo", "eo", "ew"],
"แ็ว": ["ɛo", "aeo", "ɛw"],
"เา": ["ao", "ao", "aw"],
"เว": ["eeo", "eo", "eːw"],
"แว": ["ɛɛo", "aeo", "ɛːw"],
"าว": ["aao", "ao", "aːw"],
"เอว": ["əəo", "oeu", "ɤːw"],
"โว": ["oow", "ou", "oːw"],
"เียว": ["iao", "iao", "ia̯w"],
"ัย": ["ai", "ai", "aj"],
"ใ": ["ai", "ai", "aj"],
"ไ": ["ai", "ai", "aj"],
"ไย": ["ai", "ai", "aj"],
"ึย": ["ʉi", "uei", "ɯj"],
"็อย": ["ɔi", "oi", "ɔj"],
"เิ็ย": ["əi", "oei", "ɤj"],
"ุย": ["ui", "ui", "uj"],
"าย": ["aai", "ai", "aːj"],
"อย": ["ɔɔi", "oi", "ɔːj"],
"โย": ["ooi", "oi", "oːj"],
"เย": ["əəi", "oei", "ɤːj"],
"ูย": ["uui", "ui", "uːj"],
"วย": ["uai", "uai", "ua̯j"],
"เือย": ["ʉai", "ueai", "ɯa̯j"],
"ำ": ["am", "am", "am"],
},
"closed": {
"ั": ["a", "a", "a"],
"รร": ["a", "a", "a"],
"ิ": ["i", "i", "i"],
"ึ": ["ʉ", "ue", "ɯ"],
"ุ": ["u", "u", "u"],
"เ": ["ee", "e", "eː"],
"เ็": ["e", "e", "e"],
"แ็": ["ɛ", "ae", "ɛ"],
"แ": ["ɛɛ", "ae", "ɛː"],
"": ["o", "o", "o"],
"็อ": ["ɔ", "o", "ɔ"],
"เิ็": ["ə", "oe", "ɤ"],
"า": ["aa", "a", "aː"],
"ี": ["ii", "i", "iː"],
"ื": ["ʉʉ", "ue", "ɯː"],
"ู": ["uu", "u", "uː"],
"โ": ["oo", "o", "oː"],
"อ": ["ɔɔ", "o", "ɔː"],
"เิ": ["əə", "oe", "ɤː"],
"เอ": ["əə", "oe", "ɤː"],
"เีย": ["iia", "ia", "ia̯"],
"เือ": ["ʉʉa", "uea", "ɯa̯"],
"ว": ["uua", "ua", "ua̯"],
"ไ": ["ai", "ai", "aj"],
"เา": ["ao", "ao", "aw"],
"็อย": ["ɔi", "oi", "ɔj"],
},
}
_UNROM_LONG: dict[str, bool] = {
"เีย": True,
"เือ": True,
"ัว": True,
"ว": True,
"เือย": True,
"วาย": True,
"เอว": True,
"เียว": True,
}
_LIVE_EXC: dict[str, bool] = {
"ัย": True,
"ใ": True,
"ไ": True,
"ไย": True,
"ุย": True,
"วย": True,
"็อย": True,
"เิ็ย": True,
"เา": True,
"ิว": True,
"เ็ว": True,
"แ็ว": True,
"ำ": True,
}
_CODA: dict[str, list[str]] = {
"ก": ["k", "k", "k̚"],
"ข": ["k", "k", "k̚"],
"ฃ": ["k", "k", "k̚"],
"ค": ["k", "k", "k̚"],
"ฅ": ["k", "k", "k̚"],
"ฆ": ["k", "k", "k̚"],
"จ": ["t", "t", "t̚"],
"ฉ": ["t", "t", "t̚"],
"ช": ["ch", "ch", "t͡ɕʰ"],
"ซ": ["s", "s", "s"],
"ฌ": ["t", "t", "t̚"],
"ฎ": ["t", "t", "t̚"],
"ฏ": ["t", "t", "t̚"],
"ฐ": ["t", "t", "t̚"],
"ฑ": ["t", "t", "t̚"],
"ฒ": ["t", "t", "t̚"],
"ด": ["t", "t", "t̚"],
"ต": ["t", "t", "t̚"],
"ถ": ["t", "t", "t̚"],
"ท": ["t", "t", "t̚"],
"ธ": ["t", "t", "t̚"],
"ศ": ["t", "t", "t̚"],
"ษ": ["t", "t", "t̚"],
"ส": ["s", "s", "s"],
"บ": ["p", "p", "p̚"],
"ป": ["p", "p", "p̚"],
"ผ": ["p", "p", "p̚"],
"ฝ": ["p", "p", "p̚"],
"พ": ["p", "p", "p̚"],
"ฟ": ["f", "f", "f"],
"ภ": ["p", "p", "p̚"],
"ง": ["ng", "ng$", "ŋ"],
"ญ": ["n", "n", "n"],
"ณ": ["n", "n", "n"],
"น": ["n", "n", "n"],
"ร": ["n", "n", "n"],
"ล": ["l", "l", "l"],
"ฬ": ["n", "n", "n"],
"ม": ["m", "m", "m"],
"ฯ": ["ʔ", "ʔ", "ʔ"],
}
_TONE_FROM_MARK: dict[str, dict[str, str]] = {
"่": {"high": "low", "mid": "low", "low": "falling"},
"้": {"high": "falling", "mid": "falling", "low": "high"},
"๊": {"high": "high", "mid": "high", "low": "high"},
"๋": {"high": "rising", "mid": "rising", "low": "rising"},
"̄": {"high": "mid", "mid": "mid", "low": "mid"},
}
_TONE_NO_MARK: dict[str, dict[str, str]] = {
"dead-short": {"high": "low", "mid": "low", "low": "high"},
"dead-long": {"high": "low", "mid": "low", "low": "falling"},
"live": {"high": "rising", "mid": "mid", "low": "mid"},
}
_TONE_ROM_MARKS: dict[str, str] = {
"high": "́",
"mid": "",
"low": "̀",
"rising": "̌",
"falling": "̂",
}
_TONE_LEVELS: dict[str, str] = {
"high": "˦˥",
"mid": "˧",
"low": "˨˩",
"rising": "˩˩˦",
"falling": "˥˩",
}
_DIGIT_MAP: dict[str, str] = dict(zip(thai_digits, "0123456789"))
_RE_THAI_DIGIT: re.Pattern[str] = re.compile(f"[{thai_digits}]")
# Final consonant character class
_FINAL_C: str = "[คฅฆกขฃพฟภบปชฌฑฒทธจฎฏดตฐถศษสมญณนรลฬง]"
_MGVC_PATTERN = re.compile(rf"^([รลว]?)([ิึุ็ีืัำู]?[าอรยว]?[วยร]?ะ?)({_FINAL_C}?)$")
_FULL_PATTERN = re.compile(
rf"^([เแโใไ]?)(หฺ[ก-รลว-ฮ])(ฺ?[รลว]?)([ิึุ็ีืัู]?็?[่้๊๋̄]?[าอรยวำ]?[วยร]?ะ?)({_FINAL_C}?{_FINAL_C}?)$"
)
_PARTIAL_PATTERN = re.compile(
rf"^([เแโใไ]?)([ก-รลว-ฮ])(ฺ?[รลว]?)([ิึุ็ีืัู]?็?[่้๊๋̄]?[าอรยวำ]?[วยร]?ะ?)({_FINAL_C}?{_FINAL_C}?)$"
)
def _c2_decomp(c2_char: str, seq_idx: int) -> str:
return "".join(_CODA.get(char, ["", "", ""])[seq_idx] for char in c2_char)
_TONE_MARK_CHARS: str = thai_tonemarks + "\u0304"
_RE_TONE_MARK: re.Pattern[str] = re.compile(f"[{_TONE_MARK_CHARS}]")
_RE_TWO_TONE_MARKS: re.Pattern[str] = re.compile(
f"[{_TONE_MARK_CHARS}].?[{_TONE_MARK_CHARS}]"
)
_RE_HO_PAIR: re.Pattern[str] = re.compile(r"^ห.$")
_RE_DOUBLED_VOWEL: re.Pattern[str] = re.compile(r"([aiʉueɛoɔə])\1")
_RE_SONORANT: re.Pattern[str] = re.compile(r"[มญณนรลฬง]")
_FIRST_VOWEL_PATTERN: str = r"^([^aiʉueɛoɔə]*)([aiʉueɛoɔə])"
_RE_THAI_RUN: re.Pattern[str] = re.compile(f"{_THAI_RANGE}+")
# Cleanup steps for Royal Institute output, applied in order
_ROYIN_CLEANUPS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
(re.compile(pattern), repl)
for pattern, repl in (
(r"^@", ""),
(r"([\s\W])@", r"\1"),
(r"@", "-"),
(r"^\$ng", "ng"),
(r"([\s\W])\$ng", r"\1ng"),
(r"([aeiou])\$ng", r"\1-ng"),
(r"\$ng", "ng"),
(r"ng\$([^\w\s])", r"ng\1"),
(r"ng\$", "ng"),
)
)
def _apply_ho_rule(
c1: str, g: str, v2: str, c2: str
) -> tuple[str, str, str, str]:
"""Re-split a syllable that starts with HO HIP plus a consonant."""
if _RE_HO_PAIR.match(c1):
mgvc_match = _MGVC_PATTERN.match(c1[1] + g + v2 + c2)
if mgvc_match:
g_new, v2_new, c2_new = mgvc_match.groups()
c1, g, v2, c2 = "ห", g_new, v2_new, c2_new
if g and v2 != "ย":
c1, g = c1 + g, ""
return c1, g, v2, c2
def _lookup_vowel(
v1: str, g: str, v2: str, openness: str, seq_idx: int
) -> tuple[str, str, str]:
"""
Return the vowel output, the original vowel, and the glide output.
The glide output is empty if the glide is part of the vowel.
"""
vowels = _VOWEL[openness]
if (v1 + g + v2) in vowels:
return vowels[v1 + g + v2][seq_idx], v1 + g + v2, ""
v_lookup = vowels.get(v1 + v2)
v = v_lookup[seq_idx] if v_lookup else (v1 + v2)
g_lookup = _INITIAL.get(g.replace("ฺ", ""), _INITIAL[""])
return v, v1 + v2, cast("list[str]", g_lookup["seq"])[seq_idx]
def _vowel_length(v: str, orig_v: str) -> str:
"""Return "long" or "short" for a vowel."""
if _RE_DOUBLED_VOWEL.search(v) or "ː" in v or orig_v in _UNROM_LONG:
return "long"
return "short"
def _syllable_life(c2: str, orig_v: str, v: str, length: str) -> str:
"""Return "live" or "dead" for a syllable."""
if (
_RE_SONORANT.search(c2)
or (orig_v.endswith("ย") and v.endswith("i"))
or (c2 == "" and length == "long")
or _LIVE_EXC.get(orig_v)
):
return "live"
return "dead"
def _tone_name(
tmark: Optional[str], life: str, length: str, cls: str
) -> Optional[str]:
"""Return the tone name from the tone mark, life, length, and class."""
if tmark:
tone_dict = _TONE_FROM_MARK.get(tmark)
else:
tone_dict = _TONE_NO_MARK.get(
f"{life}-{length}", _TONE_NO_MARK.get(life)
)
return tone_dict.get(cls) if tone_dict else None
def _syllable(match: re.Match[str], seq_idx: int, mode: str) -> str:
"""Transliterate one syllable matched by a syllable pattern."""
v1, c1, g, v2, c2 = match.groups()
tmark_match = _RE_TONE_MARK.search(v2)
tmark = tmark_match.group(0) if tmark_match else None
v2 = _RE_TONE_MARK.sub("", v2)
c1, g, v2, c2 = _apply_ho_rule(c1, g, v2, c2)
if g == "ล" and not (v2 + c2):
c2 = g
g = ""
openness = "closed" if c2 != "" else "open"
v, orig_v, g = _lookup_vowel(v1, g, v2, openness, seq_idx)
c1_clean = c1.replace("ฺ", "")
if c1_clean not in _INITIAL:
return match.group(0)
ini = cast("list[str]", _INITIAL[c1_clean]["seq"])[seq_idx]
cls = cast("str", _INITIAL[c1_clean]["class"])
length = _vowel_length(v, orig_v)
life = _syllable_life(c2, orig_v, v, length)
if c2 in _CODA:
c2 = _CODA[c2][seq_idx]
else:
c2 = _c2_decomp(c2, seq_idx)
tone = _tone_name(tmark, life, length, cls)
if mode == "paiboon":
mark = _TONE_ROM_MARKS.get(tone, "") if tone else ""
v = re.sub(_FIRST_VOWEL_PATTERN, f"\\g<1>\\g<2>{mark}", v)
elif mode == "ipa":
c2 = c2 + (_TONE_LEVELS.get(tone, "") if tone else "")
return ini + g + v + c2
def _process_word(word: str, seq_idx: int, mode: str) -> str:
"""Transliterate a run of Thai characters."""
if _RE_TWO_TONE_MARKS.search(word):
return word
def convert(match: re.Match[str]) -> str:
return _syllable(match, seq_idx, mode)
word = _FULL_PATTERN.sub(convert, word)
return _PARTIAL_PATTERN.sub(convert, word)
[docs]
def transliterate_wiktionary(text: str, mode: str = "ipa") -> str:
"""
Transliterate Thai text using Wiktionary th-pron logic.
:param str text: Thai text to be transliterated, a single word or
a text fragment
:param str mode: output mode, one of ``paiboon``, ``royin``, or
``ipa`` (default). An unsupported mode returns the text unchanged
:return: transliterated text
:rtype: str
:Example:
>>> transliterate_wiktionary("แมว", mode="royin")
'maeo'
"""
seq_idx = _SYSTEMS.get(mode)
if seq_idx is None:
return text
text = _RE_THAI_RUN.sub(
lambda m: _process_word(m.group(0), seq_idx, mode), text
)
text = _RE_THAI_DIGIT.sub(lambda m: _DIGIT_MAP[m.group(0)], text)
if mode == "royin":
for pattern, repl in _ROYIN_CLEANUPS:
text = pattern.sub(repl, text)
if mode == "ipa":
text = re.sub(r"[ \-–]", ".", text)
text = re.sub(r"([aiɯu])([˥-˩]+)$", r"\1ʔ\2", text)
return unicodedata.normalize("NFC", text)
[docs]
def get_word_dict(word: str) -> dict[str, str]:
"""
Return Wiktionary transliteration outputs in all supported systems.
:param str word: Thai word to be transliterated
:return: dictionary with the keys ``word``, ``paiboon``, ``royin``,
and ``ipa``
:rtype: dict[str, str]
:Example:
>>> get_word_dict("แมว")
{'word': 'แมว', 'paiboon': 'mɛɛo', 'royin': 'maeo', 'ipa': 'mɛːw˧'}
"""
return {
"word": word,
"paiboon": transliterate_wiktionary(word, mode="paiboon"),
"royin": transliterate_wiktionary(word, mode="royin"),
"ipa": transliterate_wiktionary(word, mode="ipa"),
}