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.
Files changed (66) hide show
  1. thaiphon/__init__.py +21 -0
  2. thaiphon/api.py +139 -0
  3. thaiphon/derivation/__init__.py +18 -0
  4. thaiphon/derivation/coda.py +36 -0
  5. thaiphon/derivation/onset.py +118 -0
  6. thaiphon/derivation/syllable_type.py +28 -0
  7. thaiphon/derivation/tone.py +18 -0
  8. thaiphon/derivation/vowel.py +242 -0
  9. thaiphon/errors.py +25 -0
  10. thaiphon/lexicons/__init__.py +1 -0
  11. thaiphon/lexicons/ai_20.py +39 -0
  12. thaiphon/lexicons/calendar.py +207 -0
  13. thaiphon/lexicons/cluster_overrides.py +69 -0
  14. thaiphon/lexicons/exact.py +31 -0
  15. thaiphon/lexicons/indic_detector.py +107 -0
  16. thaiphon/lexicons/indic_learned.py +2201 -0
  17. thaiphon/lexicons/irregular.py +82 -0
  18. thaiphon/lexicons/length_overrides.py +69 -0
  19. thaiphon/lexicons/loan_final_f.py +28 -0
  20. thaiphon/lexicons/loanword.py +626 -0
  21. thaiphon/lexicons/loanword_detector.py +332 -0
  22. thaiphon/lexicons/o_leading.py +17 -0
  23. thaiphon/lexicons/ror_ror.py +91 -0
  24. thaiphon/lexicons/royal.py +288 -0
  25. thaiphon/lexicons/rue.py +48 -0
  26. thaiphon/lexicons/silent_h.py +35 -0
  27. thaiphon/lexicons/thor.py +82 -0
  28. thaiphon/model/__init__.py +29 -0
  29. thaiphon/model/candidate.py +35 -0
  30. thaiphon/model/enums.py +56 -0
  31. thaiphon/model/letters.py +123 -0
  32. thaiphon/model/phoneme.py +22 -0
  33. thaiphon/model/syllable.py +26 -0
  34. thaiphon/model/word.py +24 -0
  35. thaiphon/normalization/__init__.py +0 -0
  36. thaiphon/normalization/expand.py +110 -0
  37. thaiphon/normalization/unicode_norm.py +89 -0
  38. thaiphon/pipeline/__init__.py +6 -0
  39. thaiphon/pipeline/context.py +15 -0
  40. thaiphon/pipeline/runner.py +743 -0
  41. thaiphon/registry.py +59 -0
  42. thaiphon/renderers/__init__.py +14 -0
  43. thaiphon/renderers/base.py +41 -0
  44. thaiphon/renderers/ipa.py +201 -0
  45. thaiphon/renderers/mapping.py +113 -0
  46. thaiphon/renderers/morev.py +157 -0
  47. thaiphon/renderers/tlc.py +267 -0
  48. thaiphon/segmentation/__init__.py +5 -0
  49. thaiphon/segmentation/longest.py +262 -0
  50. thaiphon/syllabification/__init__.py +12 -0
  51. thaiphon/syllabification/generator.py +88 -0
  52. thaiphon/syllabification/ranker.py +36 -0
  53. thaiphon/syllabification/strategies.py +1041 -0
  54. thaiphon/tables/__init__.py +1 -0
  55. thaiphon/tables/clusters.py +52 -0
  56. thaiphon/tables/consonants.py +84 -0
  57. thaiphon/tables/final_collapse.py +64 -0
  58. thaiphon/tables/leaders.py +12 -0
  59. thaiphon/tables/tone_matrix.py +81 -0
  60. thaiphon/tokenization/__init__.py +0 -0
  61. thaiphon/tokenization/tcc.py +75 -0
  62. thaiphon-0.2.0.dist-info/METADATA +497 -0
  63. thaiphon-0.2.0.dist-info/RECORD +66 -0
  64. thaiphon-0.2.0.dist-info/WHEEL +4 -0
  65. thaiphon-0.2.0.dist-info/licenses/LICENSE +201 -0
  66. 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."""