thaiphon 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thaiphon/__init__.py +21 -0
- thaiphon/api.py +139 -0
- thaiphon/derivation/__init__.py +18 -0
- thaiphon/derivation/coda.py +36 -0
- thaiphon/derivation/onset.py +118 -0
- thaiphon/derivation/syllable_type.py +28 -0
- thaiphon/derivation/tone.py +18 -0
- thaiphon/derivation/vowel.py +242 -0
- thaiphon/errors.py +25 -0
- thaiphon/lexicons/__init__.py +1 -0
- thaiphon/lexicons/ai_20.py +39 -0
- thaiphon/lexicons/calendar.py +207 -0
- thaiphon/lexicons/cluster_overrides.py +69 -0
- thaiphon/lexicons/exact.py +31 -0
- thaiphon/lexicons/indic_detector.py +107 -0
- thaiphon/lexicons/indic_learned.py +2201 -0
- thaiphon/lexicons/irregular.py +82 -0
- thaiphon/lexicons/length_overrides.py +69 -0
- thaiphon/lexicons/loan_final_f.py +28 -0
- thaiphon/lexicons/loanword.py +626 -0
- thaiphon/lexicons/loanword_detector.py +332 -0
- thaiphon/lexicons/o_leading.py +17 -0
- thaiphon/lexicons/ror_ror.py +91 -0
- thaiphon/lexicons/royal.py +288 -0
- thaiphon/lexicons/rue.py +48 -0
- thaiphon/lexicons/silent_h.py +35 -0
- thaiphon/lexicons/thor.py +82 -0
- thaiphon/model/__init__.py +29 -0
- thaiphon/model/candidate.py +35 -0
- thaiphon/model/enums.py +56 -0
- thaiphon/model/letters.py +123 -0
- thaiphon/model/phoneme.py +22 -0
- thaiphon/model/syllable.py +26 -0
- thaiphon/model/word.py +24 -0
- thaiphon/normalization/__init__.py +0 -0
- thaiphon/normalization/expand.py +110 -0
- thaiphon/normalization/unicode_norm.py +89 -0
- thaiphon/pipeline/__init__.py +6 -0
- thaiphon/pipeline/context.py +15 -0
- thaiphon/pipeline/runner.py +743 -0
- thaiphon/registry.py +59 -0
- thaiphon/renderers/__init__.py +14 -0
- thaiphon/renderers/base.py +41 -0
- thaiphon/renderers/ipa.py +201 -0
- thaiphon/renderers/mapping.py +113 -0
- thaiphon/renderers/morev.py +157 -0
- thaiphon/renderers/tlc.py +267 -0
- thaiphon/segmentation/__init__.py +5 -0
- thaiphon/segmentation/longest.py +262 -0
- thaiphon/syllabification/__init__.py +12 -0
- thaiphon/syllabification/generator.py +88 -0
- thaiphon/syllabification/ranker.py +36 -0
- thaiphon/syllabification/strategies.py +1041 -0
- thaiphon/tables/__init__.py +1 -0
- thaiphon/tables/clusters.py +52 -0
- thaiphon/tables/consonants.py +84 -0
- thaiphon/tables/final_collapse.py +64 -0
- thaiphon/tables/leaders.py +12 -0
- thaiphon/tables/tone_matrix.py +81 -0
- thaiphon/tokenization/__init__.py +0 -0
- thaiphon/tokenization/tcc.py +75 -0
- thaiphon-0.2.0.dist-info/METADATA +497 -0
- thaiphon-0.2.0.dist-info/RECORD +66 -0
- thaiphon-0.2.0.dist-info/WHEEL +4 -0
- thaiphon-0.2.0.dist-info/licenses/LICENSE +201 -0
- thaiphon-0.2.0.dist-info/licenses/NOTICE +11 -0
thaiphon/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""thaiphon — Thai phonological transliteration engine."""
|
|
2
|
+
|
|
3
|
+
from thaiphon.api import (
|
|
4
|
+
analyze,
|
|
5
|
+
analyze_word,
|
|
6
|
+
list_schemes,
|
|
7
|
+
transcribe,
|
|
8
|
+
transcribe_sentence,
|
|
9
|
+
transcribe_word,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"analyze",
|
|
16
|
+
"analyze_word",
|
|
17
|
+
"list_schemes",
|
|
18
|
+
"transcribe",
|
|
19
|
+
"transcribe_sentence",
|
|
20
|
+
"transcribe_word",
|
|
21
|
+
]
|
thaiphon/api.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Public API: transcribe, analyze, list_schemes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
from thaiphon.errors import UnsupportedSchemeError
|
|
9
|
+
from thaiphon.model.candidate import AnalysisResult
|
|
10
|
+
from thaiphon.registry import RENDERERS
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from thaiphon.pipeline.runner import PipelineRunner
|
|
14
|
+
|
|
15
|
+
ReadingProfile = Literal[
|
|
16
|
+
"everyday", "careful_educated", "learned_full", "etalon_compat"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_runner: PipelineRunner | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_runner() -> PipelineRunner:
|
|
23
|
+
global _runner
|
|
24
|
+
if _runner is None:
|
|
25
|
+
from thaiphon.pipeline.runner import PipelineRunner
|
|
26
|
+
|
|
27
|
+
_runner = PipelineRunner()
|
|
28
|
+
return _runner
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def analyze(
|
|
32
|
+
text: str,
|
|
33
|
+
*,
|
|
34
|
+
is_final_in_compound: bool = True,
|
|
35
|
+
profile: ReadingProfile = "everyday",
|
|
36
|
+
) -> AnalysisResult:
|
|
37
|
+
return _get_runner().analyze(
|
|
38
|
+
text, is_final_in_compound=is_final_in_compound, profile=profile
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def analyze_word(
|
|
43
|
+
text: str, *, profile: ReadingProfile = "everyday"
|
|
44
|
+
) -> AnalysisResult:
|
|
45
|
+
return _get_runner().analyze(text, profile=profile)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_schemes() -> tuple[str, ...]:
|
|
49
|
+
# Ensure built-in renderers are registered before reporting.
|
|
50
|
+
from thaiphon import renderers # noqa: F401
|
|
51
|
+
|
|
52
|
+
return RENDERERS.keys()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def transcribe(
|
|
56
|
+
text: str,
|
|
57
|
+
scheme: str = "tlc",
|
|
58
|
+
*,
|
|
59
|
+
format: Literal["text", "html"] = "text",
|
|
60
|
+
profile: ReadingProfile = "everyday",
|
|
61
|
+
_is_sentence_final: bool = True,
|
|
62
|
+
) -> str:
|
|
63
|
+
from thaiphon import renderers # noqa: F401
|
|
64
|
+
from thaiphon.renderers.base import RenderContext
|
|
65
|
+
|
|
66
|
+
if scheme not in RENDERERS:
|
|
67
|
+
raise UnsupportedSchemeError(scheme)
|
|
68
|
+
renderer = RENDERERS.get(scheme)
|
|
69
|
+
ctx = RenderContext(format=format, profile=profile)
|
|
70
|
+
result = analyze(
|
|
71
|
+
text, is_final_in_compound=_is_sentence_final, profile=profile
|
|
72
|
+
)
|
|
73
|
+
return renderer.render_word(result.best, ctx)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def transcribe_word(
|
|
77
|
+
text: str,
|
|
78
|
+
scheme: str = "tlc",
|
|
79
|
+
*,
|
|
80
|
+
format: Literal["text", "html"] = "text",
|
|
81
|
+
profile: ReadingProfile = "everyday",
|
|
82
|
+
_is_sentence_final: bool = True,
|
|
83
|
+
) -> str:
|
|
84
|
+
return transcribe(
|
|
85
|
+
text,
|
|
86
|
+
scheme=scheme,
|
|
87
|
+
format=format,
|
|
88
|
+
profile=profile,
|
|
89
|
+
_is_sentence_final=_is_sentence_final,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def transcribe_sentence(
|
|
94
|
+
text: str,
|
|
95
|
+
scheme: str = "tlc",
|
|
96
|
+
*,
|
|
97
|
+
format: Literal["text", "html"] = "text",
|
|
98
|
+
profile: ReadingProfile = "everyday",
|
|
99
|
+
segmenter: Callable[[str], Sequence[str]] | None = None,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Segment ``text`` into words, transcribe each, join with spaces.
|
|
102
|
+
|
|
103
|
+
Words appearing mid-compound have their M-602 length overrides
|
|
104
|
+
reverted (see :mod:`thaiphon.lexicons.length_overrides`). Words
|
|
105
|
+
that are the last token pass through with overrides applied.
|
|
106
|
+
"""
|
|
107
|
+
from thaiphon.segmentation import longest as _longest
|
|
108
|
+
|
|
109
|
+
seg = segmenter if segmenter is not None else _longest.segment
|
|
110
|
+
words = tuple(seg(text))
|
|
111
|
+
if not words:
|
|
112
|
+
return ""
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
last = len(words) - 1
|
|
115
|
+
for i, w in enumerate(words):
|
|
116
|
+
if not w or w.isspace():
|
|
117
|
+
continue
|
|
118
|
+
is_last = i == last
|
|
119
|
+
out.append(
|
|
120
|
+
transcribe_word(
|
|
121
|
+
w,
|
|
122
|
+
scheme=scheme,
|
|
123
|
+
format=format,
|
|
124
|
+
profile=profile,
|
|
125
|
+
_is_sentence_final=is_last,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return " ".join(out)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = [
|
|
132
|
+
"ReadingProfile",
|
|
133
|
+
"analyze",
|
|
134
|
+
"analyze_word",
|
|
135
|
+
"list_schemes",
|
|
136
|
+
"transcribe",
|
|
137
|
+
"transcribe_sentence",
|
|
138
|
+
"transcribe_word",
|
|
139
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pure-function derivation primitives: onset, vowel, coda, type, tone."""
|
|
2
|
+
|
|
3
|
+
from thaiphon.derivation.coda import FinalAnalysis, resolve_coda
|
|
4
|
+
from thaiphon.derivation.onset import OnsetAnalysis, resolve_onset
|
|
5
|
+
from thaiphon.derivation.syllable_type import classify
|
|
6
|
+
from thaiphon.derivation.tone import assign_tone
|
|
7
|
+
from thaiphon.derivation.vowel import VowelAnalysis, resolve_vowel
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FinalAnalysis",
|
|
11
|
+
"OnsetAnalysis",
|
|
12
|
+
"VowelAnalysis",
|
|
13
|
+
"assign_tone",
|
|
14
|
+
"classify",
|
|
15
|
+
"resolve_coda",
|
|
16
|
+
"resolve_onset",
|
|
17
|
+
"resolve_vowel",
|
|
18
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Coda resolution via the M-301 26→6 collapse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from thaiphon.model.enums import SyllableType
|
|
8
|
+
from thaiphon.model.phoneme import Phoneme
|
|
9
|
+
from thaiphon.tables import final_collapse
|
|
10
|
+
|
|
11
|
+
# Stops → DEAD; sonorants/semi-vowels → LIVE.
|
|
12
|
+
_STOPS: frozenset[str] = frozenset({"p̚", "t̚", "k̚"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class FinalAnalysis:
|
|
17
|
+
phoneme: Phoneme | None
|
|
18
|
+
syllable_type_hint: SyllableType | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_coda(final_char: str | None) -> FinalAnalysis:
|
|
22
|
+
if final_char is None or final_char == "":
|
|
23
|
+
return FinalAnalysis(phoneme=None, syllable_type_hint=None)
|
|
24
|
+
ipa = final_collapse.collapse(final_char)
|
|
25
|
+
if ipa in _STOPS:
|
|
26
|
+
return FinalAnalysis(
|
|
27
|
+
phoneme=Phoneme(ipa),
|
|
28
|
+
syllable_type_hint=SyllableType.DEAD,
|
|
29
|
+
)
|
|
30
|
+
return FinalAnalysis(
|
|
31
|
+
phoneme=Phoneme(ipa, is_sonorant=True),
|
|
32
|
+
syllable_type_hint=SyllableType.LIVE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["FinalAnalysis", "resolve_coda"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Onset resolution: class, ห/อ-leading, clusters, vowel-initial."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from thaiphon.lexicons import o_leading as o_leading_lex
|
|
9
|
+
from thaiphon.model.enums import ConsonantClass, EffectiveClass
|
|
10
|
+
from thaiphon.model.letters import HO_HIP, O_ANG, YO_YAK
|
|
11
|
+
from thaiphon.model.phoneme import Cluster, Phoneme
|
|
12
|
+
from thaiphon.tables import clusters as clusters_tbl
|
|
13
|
+
from thaiphon.tables import consonants as consonants_tbl
|
|
14
|
+
from thaiphon.tables.clusters import RARE_CLUSTERS as _RARE_CLUSTERS
|
|
15
|
+
from thaiphon.tables.leaders import H_LEADABLE_SONORANTS as _H_LEADABLE
|
|
16
|
+
|
|
17
|
+
# M-510: hard-coded 4-word อ-leading set (full forms).
|
|
18
|
+
_O_LEADING_WORDS: frozenset[str] = o_leading_lex.O_LEADING_WORDS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class OnsetAnalysis:
|
|
23
|
+
onset: Phoneme | Cluster | None
|
|
24
|
+
effective_class: EffectiveClass
|
|
25
|
+
consumed: int
|
|
26
|
+
leading_silent: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _effective(cls: ConsonantClass) -> EffectiveClass:
|
|
30
|
+
if cls is ConsonantClass.HIGH:
|
|
31
|
+
return EffectiveClass.HIGH
|
|
32
|
+
if cls is ConsonantClass.MID:
|
|
33
|
+
return EffectiveClass.MID
|
|
34
|
+
return EffectiveClass.LOW
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _phoneme_for(letter: str, as_onset: bool = True) -> Phoneme:
|
|
38
|
+
info = consonants_tbl.lookup(letter)
|
|
39
|
+
ipa = info.onset_ipa if as_onset else (info.coda_ipa or info.onset_ipa)
|
|
40
|
+
aspirated = ipa.endswith("ʰ")
|
|
41
|
+
sonorant = info.cls is ConsonantClass.LOW_SONORANT
|
|
42
|
+
return Phoneme(ipa, is_aspirated=aspirated, is_sonorant=sonorant)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_onset(chars: Sequence[str]) -> OnsetAnalysis:
|
|
46
|
+
"""Resolve the onset from leading characters of a syllable.
|
|
47
|
+
|
|
48
|
+
`chars` is the raw character sequence starting at a consonant (or vowel
|
|
49
|
+
prefix already stripped). Returns a small analysis: onset value, effective
|
|
50
|
+
class for tone lookup, number of chars consumed, and any silent leader.
|
|
51
|
+
"""
|
|
52
|
+
if not chars:
|
|
53
|
+
# Empty onset — implicit อ at word start.
|
|
54
|
+
return OnsetAnalysis(
|
|
55
|
+
onset=Phoneme("ʔ"),
|
|
56
|
+
effective_class=EffectiveClass.MID,
|
|
57
|
+
consumed=0,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
first = chars[0]
|
|
61
|
+
|
|
62
|
+
# Vowel-initial: leading char is a vowel mark / prevowel → implicit glottal.
|
|
63
|
+
if first not in consonants_tbl.CONSONANTS:
|
|
64
|
+
return OnsetAnalysis(
|
|
65
|
+
onset=Phoneme("ʔ"),
|
|
66
|
+
effective_class=EffectiveClass.MID,
|
|
67
|
+
consumed=0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# M-510: อ-leading check against the 4-word closed set.
|
|
71
|
+
if first == O_ANG and len(chars) >= 2 and chars[1] == YO_YAK:
|
|
72
|
+
full = "".join(chars)
|
|
73
|
+
if full in _O_LEADING_WORDS:
|
|
74
|
+
return OnsetAnalysis(
|
|
75
|
+
onset=Phoneme("j", is_sonorant=True),
|
|
76
|
+
effective_class=EffectiveClass.MID,
|
|
77
|
+
consumed=2,
|
|
78
|
+
leading_silent=O_ANG,
|
|
79
|
+
)
|
|
80
|
+
# Fall through to standard handling.
|
|
81
|
+
|
|
82
|
+
# M-500: ห-leading.
|
|
83
|
+
if first == HO_HIP and len(chars) >= 2 and chars[1] in _H_LEADABLE:
|
|
84
|
+
second = chars[1]
|
|
85
|
+
return OnsetAnalysis(
|
|
86
|
+
onset=_phoneme_for(second),
|
|
87
|
+
effective_class=EffectiveClass.HIGH,
|
|
88
|
+
consumed=2,
|
|
89
|
+
leading_silent=HO_HIP,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# M-520: true clusters (only when a non-consonant follows, i.e. no third
|
|
93
|
+
# consonant that would turn this into onset+initial-consonant pair).
|
|
94
|
+
if len(chars) >= 2 and chars[1] in consonants_tbl.CONSONANTS:
|
|
95
|
+
second = chars[1]
|
|
96
|
+
if (
|
|
97
|
+
clusters_tbl.is_cluster(first, second)
|
|
98
|
+
and (first, second) not in _RARE_CLUSTERS
|
|
99
|
+
):
|
|
100
|
+
info1 = consonants_tbl.lookup(first)
|
|
101
|
+
p1 = _phoneme_for(first)
|
|
102
|
+
p2 = _phoneme_for(second)
|
|
103
|
+
return OnsetAnalysis(
|
|
104
|
+
onset=Cluster(p1, p2),
|
|
105
|
+
effective_class=_effective(info1.cls),
|
|
106
|
+
consumed=2,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Single consonant onset.
|
|
110
|
+
info = consonants_tbl.lookup(first)
|
|
111
|
+
return OnsetAnalysis(
|
|
112
|
+
onset=_phoneme_for(first),
|
|
113
|
+
effective_class=_effective(info.cls),
|
|
114
|
+
consumed=1,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["OnsetAnalysis", "resolve_onset"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""M-303: live/dead syllable classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from thaiphon.derivation.coda import FinalAnalysis
|
|
6
|
+
from thaiphon.model.enums import SyllableType, VowelLength
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def classify(
|
|
10
|
+
vowel_length: VowelLength,
|
|
11
|
+
coda: FinalAnalysis,
|
|
12
|
+
has_written_vowel: bool = True,
|
|
13
|
+
) -> SyllableType:
|
|
14
|
+
"""Classify a syllable as LIVE or DEAD.
|
|
15
|
+
|
|
16
|
+
LIVE: long open vowel, sonorant-final syllable (/m n ŋ w j/),
|
|
17
|
+
or special vowels that are always LIVE regardless of surface form.
|
|
18
|
+
DEAD: stop-final syllable (/p̚ t̚ k̚/) or short open vowel.
|
|
19
|
+
"""
|
|
20
|
+
if coda.syllable_type_hint is not None:
|
|
21
|
+
return coda.syllable_type_hint
|
|
22
|
+
# No coda → determined by vowel length.
|
|
23
|
+
if vowel_length is VowelLength.LONG:
|
|
24
|
+
return SyllableType.LIVE
|
|
25
|
+
return SyllableType.DEAD
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["classify"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Thin typed adapter over the M-410 tone matrix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from thaiphon.model.enums import EffectiveClass, SyllableType, Tone, ToneMark, VowelLength
|
|
6
|
+
from thaiphon.tables import tone_matrix
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def assign_tone(
|
|
10
|
+
effective_class: EffectiveClass,
|
|
11
|
+
syllable_type: SyllableType,
|
|
12
|
+
vowel_length: VowelLength,
|
|
13
|
+
tone_mark: ToneMark,
|
|
14
|
+
) -> Tone:
|
|
15
|
+
return tone_matrix.lookup(effective_class, syllable_type, vowel_length, tone_mark)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["assign_tone"]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Vowel quality + length resolution (M-200, M-203, M-206, M-308, M-600, M-204)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from thaiphon.errors import ParseError
|
|
8
|
+
from thaiphon.model.enums import VowelLength
|
|
9
|
+
from thaiphon.model.letters import (
|
|
10
|
+
MAI_HAN_AKAT,
|
|
11
|
+
MAITAIKHU,
|
|
12
|
+
O_ANG,
|
|
13
|
+
RO_RUA,
|
|
14
|
+
SARA_A,
|
|
15
|
+
SARA_AA,
|
|
16
|
+
SARA_AE,
|
|
17
|
+
SARA_AI_MAIMALAI,
|
|
18
|
+
SARA_AI_MAIMUAN,
|
|
19
|
+
SARA_AM,
|
|
20
|
+
SARA_E,
|
|
21
|
+
SARA_I,
|
|
22
|
+
SARA_II,
|
|
23
|
+
SARA_O,
|
|
24
|
+
SARA_U,
|
|
25
|
+
SARA_UE,
|
|
26
|
+
SARA_UEE,
|
|
27
|
+
SARA_UU,
|
|
28
|
+
TONE_MARKS,
|
|
29
|
+
WO_WAEN,
|
|
30
|
+
YO_YAK,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class VowelAnalysis:
|
|
36
|
+
quality: str # IPA quality symbol, e.g. "a", "iː" not yet — just "i" + length
|
|
37
|
+
length: VowelLength
|
|
38
|
+
inserted_vowel: bool = False
|
|
39
|
+
offglide: str | None = None
|
|
40
|
+
notes: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Simple above/below/after vowel marks: (mark, length, IPA quality).
|
|
44
|
+
_SIMPLE_MARKS: dict[str, tuple[str, VowelLength]] = {
|
|
45
|
+
SARA_I: ("i", VowelLength.SHORT),
|
|
46
|
+
SARA_II: ("i", VowelLength.LONG),
|
|
47
|
+
SARA_UE: ("ɯ", VowelLength.SHORT),
|
|
48
|
+
SARA_UEE: ("ɯ", VowelLength.LONG),
|
|
49
|
+
SARA_U: ("u", VowelLength.SHORT),
|
|
50
|
+
SARA_UU: ("u", VowelLength.LONG),
|
|
51
|
+
MAI_HAN_AKAT: ("a", VowelLength.SHORT),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_tones(s: str) -> str:
|
|
56
|
+
return "".join(c for c in s if c not in TONE_MARKS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_vowel(
|
|
60
|
+
syllable_chars: str,
|
|
61
|
+
onset_consumed: int,
|
|
62
|
+
has_final: bool,
|
|
63
|
+
tone_mark_present: bool,
|
|
64
|
+
final_char: str | None = None,
|
|
65
|
+
) -> VowelAnalysis:
|
|
66
|
+
"""Resolve vowel quality + length for a single syllable.
|
|
67
|
+
|
|
68
|
+
`syllable_chars` is the full raw syllable. `onset_consumed` is the
|
|
69
|
+
absolute offset into the (tone-stripped) string where vowel material
|
|
70
|
+
begins — i.e. any leading pre-vowel plus onset consonants already count.
|
|
71
|
+
"""
|
|
72
|
+
del tone_mark_present # reserved; current analysis doesn't branch on it
|
|
73
|
+
|
|
74
|
+
# Work on a tone-stripped copy so we can pattern-match cleanly.
|
|
75
|
+
s = _strip_tones(syllable_chars)
|
|
76
|
+
|
|
77
|
+
# Pre-base vowels (เ แ โ ใ ไ) — need special handling; they appear before onset.
|
|
78
|
+
pre = s[0] if s else ""
|
|
79
|
+
|
|
80
|
+
if pre == SARA_AI_MAIMUAN:
|
|
81
|
+
# ใ — always short /a/ + /j/ offglide; LIVE syllable.
|
|
82
|
+
return VowelAnalysis("a", VowelLength.SHORT, offglide="j")
|
|
83
|
+
if pre == SARA_AI_MAIMALAI:
|
|
84
|
+
# ไ — same surface behaviour as ใ.
|
|
85
|
+
return VowelAnalysis("a", VowelLength.SHORT, offglide="j")
|
|
86
|
+
|
|
87
|
+
# เ◌ family.
|
|
88
|
+
if pre == SARA_E:
|
|
89
|
+
# `onset_consumed` is absolute (includes the pre-vowel).
|
|
90
|
+
rest = s[onset_consumed:] # chars after เ + onset
|
|
91
|
+
# เ◌า — /aw/, short open, LIVE (special).
|
|
92
|
+
if rest.startswith(SARA_AA):
|
|
93
|
+
# เอา = aw short LIVE. Check for ะ at end → เ◌าะ → /ɔ/ short.
|
|
94
|
+
if rest == SARA_AA + SARA_A:
|
|
95
|
+
return VowelAnalysis("ɔ", VowelLength.SHORT)
|
|
96
|
+
# เ◌าะ + final → ◌็อ closed variant (handled via MAITAIKHU pattern)
|
|
97
|
+
return VowelAnalysis("a", VowelLength.SHORT, offglide="w")
|
|
98
|
+
# เ◌ียะ / เ◌ีย — centring diphthong /iə/.
|
|
99
|
+
if rest.startswith(SARA_II + YO_YAK):
|
|
100
|
+
# R-CENT-001 Case B: เ◌ียว is /iː/ + /w/, NOT centring /iːə/.
|
|
101
|
+
# When ``_find_final`` has extracted ``ว`` as the coda the ย
|
|
102
|
+
# that survives in ``rest`` was orthographic filler for the
|
|
103
|
+
# /iː/ nucleus — there is no centring offglide before /w/.
|
|
104
|
+
if final_char == WO_WAEN:
|
|
105
|
+
return VowelAnalysis("i", VowelLength.LONG)
|
|
106
|
+
# เ◌ียะ short, เ◌ีย long.
|
|
107
|
+
tail = rest[2:]
|
|
108
|
+
if tail == SARA_A:
|
|
109
|
+
return VowelAnalysis("iə", VowelLength.SHORT)
|
|
110
|
+
# long: with or without final.
|
|
111
|
+
return VowelAnalysis("iə", VowelLength.LONG)
|
|
112
|
+
# เ◌ือะ / เ◌ือ — centring diphthong /ɯə/.
|
|
113
|
+
if rest.startswith(SARA_UEE + O_ANG):
|
|
114
|
+
tail = rest[2:]
|
|
115
|
+
if tail == SARA_A:
|
|
116
|
+
return VowelAnalysis("ɯə", VowelLength.SHORT)
|
|
117
|
+
return VowelAnalysis("ɯə", VowelLength.LONG)
|
|
118
|
+
# เ◌อ / เ◌อะ — /ɤ/. With final ย → เ◌ย (short /ɤ/).
|
|
119
|
+
if rest.startswith(O_ANG):
|
|
120
|
+
tail = rest[1:]
|
|
121
|
+
if tail == SARA_A:
|
|
122
|
+
return VowelAnalysis("ɤ", VowelLength.SHORT)
|
|
123
|
+
return VowelAnalysis("ɤ", VowelLength.LONG)
|
|
124
|
+
# เ◌ิ◌ — short /ɤ/ closed (M-203 เ◌อ + final → เ◌ิ + final)
|
|
125
|
+
if rest.startswith(SARA_I):
|
|
126
|
+
return VowelAnalysis("ɤ", VowelLength.SHORT)
|
|
127
|
+
# เ◌ย — short /ɤ/ + /j/ offglide (M-204 allomorph of เ◌อ + ย).
|
|
128
|
+
# Template must fire BEFORE the fallback long /eː/ branch below.
|
|
129
|
+
# ``_find_final`` has already consumed ย as the coda, so ``rest``
|
|
130
|
+
# is empty and the raw ย only survives through ``final_char``.
|
|
131
|
+
if rest == "" and final_char == YO_YAK:
|
|
132
|
+
return VowelAnalysis("ɤ", VowelLength.SHORT, offglide="j")
|
|
133
|
+
# เ◌็◌ — short /e/ closed (M-203 เ◌ะ + final → เ◌็ + final)
|
|
134
|
+
if rest.startswith(MAITAIKHU):
|
|
135
|
+
return VowelAnalysis("e", VowelLength.SHORT)
|
|
136
|
+
# เ◌ะ — short /e/, open.
|
|
137
|
+
if rest == SARA_A:
|
|
138
|
+
return VowelAnalysis("e", VowelLength.SHORT)
|
|
139
|
+
# เ◌ + final or bare เ◌ (long /eː/).
|
|
140
|
+
# If rest is empty → open long /e/; if rest contains consonant(s) → long closed.
|
|
141
|
+
return VowelAnalysis("e", VowelLength.LONG)
|
|
142
|
+
|
|
143
|
+
if pre == SARA_AE:
|
|
144
|
+
rest = s[onset_consumed:]
|
|
145
|
+
# แ◌ะ — short /ɛ/.
|
|
146
|
+
if rest == SARA_A:
|
|
147
|
+
return VowelAnalysis("ɛ", VowelLength.SHORT)
|
|
148
|
+
# แ◌็◌ — short /ɛ/ closed.
|
|
149
|
+
if rest.startswith(MAITAIKHU):
|
|
150
|
+
return VowelAnalysis("ɛ", VowelLength.SHORT)
|
|
151
|
+
# แ◌ / แ◌◌ — long /ɛː/.
|
|
152
|
+
return VowelAnalysis("ɛ", VowelLength.LONG)
|
|
153
|
+
|
|
154
|
+
if pre == SARA_O:
|
|
155
|
+
rest = s[onset_consumed:]
|
|
156
|
+
if rest == SARA_A:
|
|
157
|
+
return VowelAnalysis("o", VowelLength.SHORT)
|
|
158
|
+
# โ◌ / โ◌◌ — long /oː/.
|
|
159
|
+
return VowelAnalysis("o", VowelLength.LONG)
|
|
160
|
+
|
|
161
|
+
# No pre-base vowel. Examine post-onset portion.
|
|
162
|
+
rest = s[onset_consumed:]
|
|
163
|
+
|
|
164
|
+
# Post-base อ carrier: C + อ → long /ɔː/ open. C + อ + final collapses
|
|
165
|
+
# to long /ɔː/ closed (the final char was already extracted by the
|
|
166
|
+
# caller so we only need to check the remaining orthographic fragment).
|
|
167
|
+
if rest.startswith(O_ANG) and (len(rest) == 1):
|
|
168
|
+
return VowelAnalysis("ɔ", VowelLength.LONG)
|
|
169
|
+
|
|
170
|
+
# R-202 closed short /ɔ/ allomorph: C + ◌็ + อ + coda. Must match
|
|
171
|
+
# BEFORE the generic MAITAIKHU fallback below so ล็อค, น็อค, ช็อป
|
|
172
|
+
# read with a SHORT /ɔ/ nucleus rather than /o/.
|
|
173
|
+
if rest.startswith(MAITAIKHU + O_ANG):
|
|
174
|
+
return VowelAnalysis("ɔ", VowelLength.SHORT)
|
|
175
|
+
|
|
176
|
+
# Sara Am ◌ำ — /am/ short, LIVE (special; M-600).
|
|
177
|
+
if SARA_AM in rest:
|
|
178
|
+
return VowelAnalysis("a", VowelLength.SHORT, offglide="m")
|
|
179
|
+
|
|
180
|
+
# ◌ะ — short /a/ open.
|
|
181
|
+
if rest == SARA_A:
|
|
182
|
+
return VowelAnalysis("a", VowelLength.SHORT)
|
|
183
|
+
|
|
184
|
+
# ◌า — long /aː/ open (or closed with trailing consonant).
|
|
185
|
+
if rest.startswith(SARA_AA):
|
|
186
|
+
return VowelAnalysis("a", VowelLength.LONG)
|
|
187
|
+
|
|
188
|
+
# ◌ั◌ — short /a/ closed (M-203 ะ + final → ั + final).
|
|
189
|
+
if rest.startswith(MAI_HAN_AKAT):
|
|
190
|
+
# ◌ัว — /ua/ centring diphthong.
|
|
191
|
+
if rest.startswith(MAI_HAN_AKAT + WO_WAEN):
|
|
192
|
+
tail = rest[2:]
|
|
193
|
+
if tail == "":
|
|
194
|
+
return VowelAnalysis("uə", VowelLength.LONG)
|
|
195
|
+
# ◌ัว◌ closed.
|
|
196
|
+
return VowelAnalysis("uə", VowelLength.LONG)
|
|
197
|
+
return VowelAnalysis("a", VowelLength.SHORT)
|
|
198
|
+
|
|
199
|
+
# R-CD-004: closed /uːə/ written as C + ว + coda with the ◌ั dropped.
|
|
200
|
+
# After `_find_final` has extracted the trailing coda, the only thing
|
|
201
|
+
# left after the onset is the bare ว. Example: ``ชวน`` → onset=ช,
|
|
202
|
+
# final=น, rest=ว → nucleus /uə/ long. Open ว syllables without a
|
|
203
|
+
# coda (e.g. ``วัน``, ``วง``) never land here — those carry ว as the
|
|
204
|
+
# onset, so ``rest`` would not start with ว for them.
|
|
205
|
+
if rest == WO_WAEN:
|
|
206
|
+
return VowelAnalysis("uə", VowelLength.LONG)
|
|
207
|
+
|
|
208
|
+
# Simple above/below marks.
|
|
209
|
+
for mark, (quality, length) in _SIMPLE_MARKS.items():
|
|
210
|
+
if rest.startswith(mark):
|
|
211
|
+
return VowelAnalysis(quality, length)
|
|
212
|
+
|
|
213
|
+
# ◌็◌ — short-vowel marker; depends on pre-vowel (already handled) or
|
|
214
|
+
# bare use after onset is uncommon. Treat as short /o/.
|
|
215
|
+
if rest.startswith(MAITAIKHU):
|
|
216
|
+
return VowelAnalysis("o", VowelLength.SHORT)
|
|
217
|
+
|
|
218
|
+
# No explicit vowel letter at all — M-206 / M-308 inherent-vowel.
|
|
219
|
+
if rest == "":
|
|
220
|
+
# M-206: closed syllable with no written vowel → inherent short /o/.
|
|
221
|
+
# Open syllable → inherent short /a/.
|
|
222
|
+
if has_final:
|
|
223
|
+
return VowelAnalysis("o", VowelLength.SHORT, inserted_vowel=True)
|
|
224
|
+
return VowelAnalysis("a", VowelLength.SHORT, inserted_vowel=True)
|
|
225
|
+
|
|
226
|
+
# Closed with no written vowel.
|
|
227
|
+
# M-308: if coda is ร → long /ɔː/ with /n/ coda (caller still uses coda-collapse).
|
|
228
|
+
# We signal the vowel here; the coda resolver already returns /n/ for ร.
|
|
229
|
+
if len(rest) == 1 and rest in (RO_RUA,):
|
|
230
|
+
return VowelAnalysis("ɔ", VowelLength.LONG, inserted_vowel=True)
|
|
231
|
+
|
|
232
|
+
# Closed syllable, multiple consonants with no written vowel → short /o/.
|
|
233
|
+
if all(ch not in _SIMPLE_MARKS and ch != SARA_A and ch != SARA_AA for ch in rest):
|
|
234
|
+
# Heuristic: if the first trailing char is a "ร" coda → M-308.
|
|
235
|
+
if rest == RO_RUA:
|
|
236
|
+
return VowelAnalysis("ɔ", VowelLength.LONG, inserted_vowel=True)
|
|
237
|
+
return VowelAnalysis("o", VowelLength.SHORT, inserted_vowel=True)
|
|
238
|
+
|
|
239
|
+
raise ParseError(f"unrecognised vowel pattern: {syllable_chars!r}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["VowelAnalysis", "resolve_vowel"]
|
thaiphon/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception hierarchy for thaiphon."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ThaiphonError(Exception):
|
|
5
|
+
"""Base for all thaiphon exceptions."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParseError(ThaiphonError):
|
|
9
|
+
"""Raised when tokenization or orthography rejects input."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NormalizationError(ThaiphonError):
|
|
13
|
+
"""Raised on NFC or script-order normalization failure."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DerivationError(ThaiphonError):
|
|
17
|
+
"""Raised when a rule-table lookup has no entry for given inputs."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedSchemeError(ThaiphonError):
|
|
21
|
+
"""Raised when a requested renderer scheme is not registered."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbiguousAnalysisError(ThaiphonError):
|
|
25
|
+
"""Raised in strict mode when multiple candidates tie after ranking."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lexicon modules for rule islands and irregular readings."""
|