Skip to main content

num2words2_core/
lang_th.rs

1//! Port of `lang_TH.py` (Thai). Registry key `"th"` → `Num2Word_TH`
2//! (verified in `num2words2/__init__.py`).
3//!
4//! Shape: **self-contained**. `Num2Word_TH` subclasses `Num2Word_Base` and
5//! *does* define `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
6//! `__init__` builds `self.cards` and sets `MAXVAL` — but every one of those
7//! is dead code, because TH overrides `to_cardinal` outright and drives its
8//! own `left_num_to_text` → `split_six` → `splitnum` → `add_text_million`
9//! pipeline. `cards`/`maxval`/`merge` therefore stay at their trait defaults
10//! here, and there is **no overflow check** at any size (see bug 1 below).
11//!
12//! Inherited from `Num2Word_Base` (unchanged by TH, so the trait defaults do
13//! the right thing):
14//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`.
15//!     Corpus confirms `to_ordinal_num(-1)` == "-1" (no sign handling, no
16//!     ordinal suffix — Python literally returns the input unchanged and the
17//!     dispatcher stringifies it).
18//!   * `to_year(value) -> self.to_cardinal(value)` → default delegates through
19//!     `&self`, picking up the `to_cardinal` override below. There is no BC/AD
20//!     handling whatsoever: `to_year(-500)` == "ติดลบห้าร้อย" ("minus five
21//!     hundred"), verified against the interpreter.
22//!
23//! # Thai numeral system, as this module models it
24//!
25//! Thai groups by **six** digits (ล้าน = 10^6), not three, and stacks the
26//! ล้าน marker for higher powers: 10^12 is "million million"
27//! (หนึ่งล้านล้าน), 10^18 is "million million million". `split_six` chops the
28//! digit string into 6-digit groups, and `add_text_million` rejoins them with
29//! a literal "ล้าน" between each. This scales indefinitely, which is why the
30//! absent overflow check never actually bites.
31//!
32//! Two morphological rules live in `splitnum`:
33//!   * **เอ็ด** — a trailing 1 in a multi-digit group is "et", not "one"
34//!     (11 → สิบเอ็ด, 21 → ยี่สิบเอ็ด, 101 → หนึ่งร้อยเอ็ด).
35//!   * **ยี่** — 2 in the tens place is "yi", not "song" (20 → ยี่สิบ).
36//!   * A bare 1 in the tens place gets no unit word at all (10 → สิบ, not
37//!     "หนึ่งสิบ"), via the `index != 1 or num != 1` guard.
38//!
39//! # Faithfully reproduced Python quirks
40//!
41//! This is a port, not a rewrite. All of the following are verified against
42//! the interpreter and preserved verbatim:
43//!
44//! 1. **`cards` is garbage and `MAXVAL` is 9000.** TH's `low_numwords` is
45//!    listed *ascending* (ศูนย์=0 … เก้า=9), but `Num2Word_Base.set_low_numwords`
46//!    assumes *descending* and zips against `range(len-1, -1, -1)`. The
47//!    resulting table maps `cards[9] = "ศูนย์"` ("zero"), `cards[8] = "หนึ่ง"`
48//!    ("one"), … `cards[0] = "เก้า"` ("nine") — every entry inverted. `MAXVAL`
49//!    is then `1000 * 9 == 9000`. None of this is ever read, because
50//!    `to_cardinal` is overridden and never calls `splitnum`(base)/`clean`/
51//!    `merge`. Reproduced here only by *omission*: we leave `cards`/`maxval`
52//!    at their trait defaults rather than modelling a table nothing consults.
53//!    The consequence that *is* observable: TH never raises `OverflowError`,
54//!    at any magnitude (corpus goes to 10^21 and returns fine).
55//!
56//! 2. **Three hardcoded string special-cases in `left_num_to_text`**, compared
57//!    against the *decimal string*, not the value:
58//!      * `"100"` → "ร้อย"   (the pipeline would emit "หนึ่งร้อย")
59//!      * `"100000000"` → "ร้อยล้าน"   (pipeline: "หนึ่งร้อยล้าน")
60//!      * `"1000000000"` → "พันล้าน"   (pipeline: "หนึ่งพันล้าน")
61//!    These drop the leading "หนึ่ง" ("one") for exactly three magnitudes and
62//!    nothing else, which is inconsistent: 1000 stays "หนึ่งพัน", 10^6 stays
63//!    "หนึ่งล้าน", 10^12 stays "หนึ่งล้านล้าน". Colloquial Thai does drop the
64//!    "one" here, so it is arguably a partial fix rather than a bug — but it
65//!    is applied to a string, so it fires *after* the minus sign is stripped:
66//!    `to_cardinal(-100)` == "ติดลบร้อย" (corpus-confirmed), and it fires only
67//!    on the whole number, never on an internal group (1000100 is unaffected).
68//!    Modelled by [`left_num_to_text`].
69//!
70//! 3. **`length` is a `bool` compared against `0`.** `splitnum` does
71//!    `length = len(six_num) > 1` and later tests `length == 0`, which in
72//!    Python is `False == 0` → `True`. It means "this group is a single
73//!    digit". Ported as `!length`.
74//!
75//! 4. **`word_num += "เอ็ด"` appends where every sibling branch prepends.**
76//!    Harmless in practice: the branch is guarded by `index == 0`, and at
77//!    index 0 the `if index:` prefix never ran, so `word_num` is always empty
78//!    there. Ported literally regardless.
79//!
80//! 5. **Zero-suppression asymmetry.** A group of all zeros yields `""`, and
81//!    `add_text_million` still welds "ล้าน" onto it — which is exactly how
82//!    10^6 becomes "หนึ่งล้าน" (`"หนึ่ง" + "ล้าน" + ""`). Only a *single-digit*
83//!    group of "0" produces the word ศูนย์, so the string "0" is the sole way
84//!    to ever see it in an integer result.
85//!
86//! # The currency surface
87//!
88//! `Num2Word_TH` overrides `to_currency` **wholesale** and shares almost
89//! nothing with `Num2Word_Base`'s version — no `pluralize`, no `has_decimal`
90//! guard, no `CURRENCY_PRECISION`, no adjective support, and its own
91//! `NotImplementedError` wording. See [`LangTh::to_currency`] for the
92//! divergences, each of which is corpus- or interpreter-confirmed.
93//!
94//! `to_cheque` is **not** overridden: TH inherits `Num2Word_Base.to_cheque`
95//! verbatim, so the trait default (`currency::default_to_cheque`) is left in
96//! place and only `lang_name` + `currency_forms` are supplied for it to read.
97//! That inheritance is why the two error messages differ — see
98//! [`LangTh::to_currency`].
99//!
100//! `round_2_decimal` is **dead library code**: `lang_TH.py` defines it, but no
101//! conversion path calls it (`to_currency` inlines its own rounding via
102//! `parse_currency_parts`). Only `tests/lang/test_th.py` pokes it directly, and
103//! it is unreachable from every `to_*` entry point the dispatcher exposes, so
104//! it is deliberately not ported.
105//!
106//! # The float / Decimal cardinal path
107//!
108//! `Num2Word_TH.to_cardinal` is a single method covering *both* integer and
109//! non-integer input. The integer half (above) leaves `post == "0"` and skips
110//! the "จุด" branch — provably dead for `int` input, since `float2tuple` on an
111//! `int` returns `post == 0` with `self.precision = 0`. Float and `Decimal`
112//! input open that branch instead.
113//!
114//! The trait splits the one Python method in two: [`to_cardinal`] takes the
115//! `BigInt` path, and the trait hook [`LangTh::to_cardinal_float`] takes the
116//! `FloatValue` path. Both run the same `left_num_to_text` pipeline and the
117//! same "จุด" + per-digit `low_numwords` rendering, shared through
118//! [`cardinal_float_from_value`]. TH never delegates to
119//! `Num2Word_Base.to_cardinal_float`, which is why the byte shape differs from
120//! the base's — no spaces, no titled pointword ("หนึ่งจุดหนึ่ง", not
121//! "หนึ่ง จุด หนึ่ง") — and why the trait's `cardinal_from_decimal` default is
122//! left unused.
123//!
124//! The *currency* fractional-cents branch reaches the same rendering by a
125//! separate door — Python's `self.to_cardinal(float(right))` — ported as the
126//! f64-only [`to_cardinal_float`] free helper that [`LangTh::to_currency`]
127//! calls. It is kept distinct from the `FloatValue` core only because the
128//! subunits it sees are always in `[0, 100)`, so it can safely recompute
129//! precision from `repr` (see [`cardinal_float_from_value`] for why the general
130//! path cannot).
131//!
132//! # Cross-call mutable state
133//!
134//! `float2tuple` writes `self.precision` (to 0 for integer input) and
135//! `to_cardinal` reads it back on the next line. It is a genuine
136//! write-then-read handshake on instance state, but it is confined to a single
137//! call and, for integer input, the value it computes is never used — `post`
138//! is `"0"` and the padding branch `len(post) < precision` is `1 < 0`, i.e.
139//! false. No state survives a call, so the stateless Rust path is safe here.
140
141use crate::base::{Lang, N2WError, Result};
142use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
143use crate::floatpath::{float2tuple, FloatValue};
144use bigdecimal::BigDecimal;
145use num_bigint::BigInt;
146use num_traits::{Signed, ToPrimitive, Zero};
147use std::collections::HashMap;
148
149/// `self.negword`, set in `setup`. Used as a bare prefix with **no space** —
150/// TH concatenates it directly (`"ติดลบ" + result`) rather than going through
151/// the base's `"%s " % negword.strip()` spacing.
152///
153/// `to_currency` is the one exception: it builds `self.negword + " "`, so the
154/// currency path *does* get a separating space. See [`LangTh::to_currency`].
155const NEGWORD: &str = "ติดลบ";
156
157/// `self.pointword`, set in `setup`. Concatenated with no surrounding spaces,
158/// unlike the base's `" ".join` float path.
159const POINTWORD: &str = "จุด";
160
161/// `self.mid_numwords`. Indexed by digit position within a 6-digit group, so
162/// only 0..=5 are ever reachable from `splitnum` (a group is at most 6 chars).
163/// Index 6 ("ล้าน") is dead here — `add_text_million` hardcodes that literal
164/// instead of reading this table.
165const MID_NUMWORDS: [&str; 7] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน", "ล้าน"];
166
167/// `self.low_numwords`, ascending 0..=9 (note: the base class expects this
168/// *descending* — see bug 1 in the module docs).
169const LOW_NUMWORDS: [&str; 10] = [
170    "ศูนย์", "หนึ่ง", "สอง", "สาม", "สี่", "ห้า", "หก", "เจ็ด", "แปด", "เก้า",
171];
172
173/// "et" — replaces "one" in the units slot of a multi-digit group.
174const ET: &str = "เอ็ด";
175/// "yi" — replaces "song" ("two") in the tens slot.
176const YI: &str = "ยี่";
177/// "million" (10^6), the group separator stacked by [`add_text_million`].
178const MILLION: &str = "ล้าน";
179
180/// `self.CURRENCY_FORMS`, assigned in `setup()`.
181///
182/// TH **rebinds** the attribute (`self.CURRENCY_FORMS = {...}`) rather than
183/// mutating it, and subclasses `Num2Word_Base` directly rather than
184/// `Num2Word_EUR`. Both facts matter: the shared-class-dict mutation that
185/// `Num2Word_EN.__init__` performs on `Num2Word_EUR.CURRENCY_FORMS` — the trap
186/// that hands Swedish and Hungarian English plurals — cannot reach TH. Three
187/// codes is genuinely all there is; verified against the live interpreter.
188///
189/// Both forms of every pair are identical because Thai does not inflect for
190/// number. The arity is kept at 2 anyway to match the Python tuples: TH's
191/// `to_currency` only ever reads index `[0]`, but `Num2Word_Base.to_cheque` —
192/// which TH inherits — reads `cr1[-1]`, so a 1-tuple would not be equivalent.
193fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
194    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
195    m.insert("THB", CurrencyForms::new(&["บาท", "บาท"], &["สตางค์", "สตางค์"]));
196    m.insert(
197        "USD",
198        CurrencyForms::new(&["ดอลลาร์", "ดอลลาร์"], &["เซนต์", "เซนต์"]),
199    );
200    m.insert("EUR", CurrencyForms::new(&["ยูโร", "ยูโร"], &["เซนต์", "เซนต์"]));
201    m
202}
203
204pub struct LangTh {
205    /// Built once in `new()`. `to_currency` and the inherited `to_cheque` only
206    /// ever read it; rebuilding it per call is what made an earlier revision of
207    /// this port slower than the Python it replaces.
208    currency_forms: HashMap<&'static str, CurrencyForms>,
209}
210
211impl Default for LangTh {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217impl LangTh {
218    pub fn new() -> Self {
219        LangTh {
220            currency_forms: build_currency_forms(),
221        }
222    }
223}
224
225/// Port of `utils.splitbyx(n, x, format_int=False)`.
226///
227/// Yields left-to-right chunks of `x` chars, with any short remainder first.
228/// `format_int=False` means chunks stay strings (leading zeros preserved),
229/// which the rest of the pipeline depends on — `splitnum` reads digit
230/// *positions* out of them.
231///
232/// Python's `range(start, length, x)` with `start = length % x` guarantees
233/// every subsequent chunk is exactly `x` long, so no bounds check is needed.
234fn splitbyx(n: &[char], x: usize) -> Vec<Vec<char>> {
235    let length = n.len();
236    let mut out: Vec<Vec<char>> = Vec::new();
237    if length > x {
238        let start = length % x;
239        if start > 0 {
240            out.push(n[..start].to_vec());
241        }
242        let mut i = start;
243        while i < length {
244            out.push(n[i..i + x].to_vec());
245            i += x;
246        }
247    } else {
248        out.push(n.to_vec());
249    }
250    out
251}
252
253/// Port of `Num2Word_TH.split_six`.
254///
255/// Splits into 6-digit groups, reverses the *group order* (least-significant
256/// group first), then reverses the *digits within* each group (least-
257/// significant digit first). The double reversal is what lets `splitnum`
258/// use the loop index directly as a power-of-ten position.
259fn split_six(num_txt: &[char]) -> Vec<Vec<char>> {
260    let mut result = splitbyx(num_txt, 6);
261    result.reverse();
262    result
263        .into_iter()
264        .map(|mut group| {
265            group.reverse();
266            group
267        })
268        .collect()
269}
270
271/// Port of `Num2Word_TH.splitnum`.
272///
273/// `six_num` is a digit group **already reversed** by [`split_six`], so
274/// `index` is the power of ten and `MID_NUMWORDS[index]` is its unit word.
275///
276/// The branch order is load-bearing and reproduced exactly:
277///   1. `length && num == 1 && index == 0` → เอ็ด (trailing "one" → "et")
278///   2. `index == 1 && num == 2`           → ยี่ prefix ("twenty", not "two-ten")
279///   3. `index != 1 || num != 1`           → the ordinary digit word
280///
281/// Falling through all three (i.e. `index == 1 && num == 1`) deliberately
282/// emits *no* digit word, leaving a bare "สิบ" — that is how 10 → "สิบ".
283fn splitnum(six_num: &[char]) -> String {
284    // Python: `length = len(six_num) > 1` — a bool, later compared `== 0`.
285    let length = six_num.len() > 1;
286    let mut word_num = String::new();
287
288    for (index, ch) in six_num.iter().enumerate() {
289        // Python's `map(int, six_num)`. Callers only ever pass decimal digit
290        // strings (built from BigInt::to_string), so this cannot fail.
291        let num = ch.to_digit(10).expect("split_six only yields decimal digits") as usize;
292
293        if num != 0 {
294            if index != 0 {
295                word_num = format!("{}{}", MID_NUMWORDS[index], word_num);
296            }
297
298            if length && num == 1 && index == 0 {
299                // Python appends here while every other branch prepends; see
300                // bug 4. Equivalent because word_num is empty at index 0.
301                word_num.push_str(ET);
302            } else if index == 1 && num == 2 {
303                word_num = format!("{}{}", YI, word_num);
304            } else if index != 1 || num != 1 {
305                word_num = format!("{}{}", LOW_NUMWORDS[num], word_num);
306            }
307        } else if index == 0 && !length {
308            // Python: `elif num == 0 and index == 0 and length == 0`.
309            // `length == 0` is `False == 0` → True, i.e. "single-digit group".
310            // The only integer that reaches this is 0 itself.
311            word_num = LOW_NUMWORDS[0].to_string();
312        }
313    }
314
315    word_num
316}
317
318/// Port of `Num2Word_TH.add_text_million`.
319///
320/// Welds the per-group words back together with "ล้าน" between each, walking
321/// the (already least-significant-first) list in reverse so the output reads
322/// most-significant-first. An empty group contributes an empty string but
323/// still gets its separator — that is how 10^6 → "หนึ่ง" + "ล้าน" + "".
324fn add_text_million(word_num: &[String]) -> String {
325    let mut result = String::new();
326    for (index, t) in word_num.iter().rev().enumerate() {
327        if index == 0 {
328            result = t.clone();
329        } else {
330            result = format!("{}{}{}", result, MILLION, t);
331        }
332    }
333    result
334}
335
336/// Port of `Num2Word_TH.left_num_to_text`.
337///
338/// `number` is the *decimal string* of the (already sign-stripped) integer.
339/// The three special cases compare against that string — see bug 2.
340fn left_num_to_text(number: &str) -> String {
341    // Special case for exactly 100
342    if number == "100" {
343        return "ร้อย".to_string();
344    }
345
346    // Special cases for 100 million and 1 billion
347    if number == "100000000" {
348        return "ร้อยล้าน".to_string();
349    }
350    if number == "1000000000" {
351        return "พันล้าน".to_string();
352    }
353
354    let chars: Vec<char> = number.chars().collect();
355    let left_num_list = split_six(&chars);
356
357    let left_text_list: Vec<String> = left_num_list.iter().map(|g| splitnum(g)).collect();
358
359    add_text_million(&left_text_list)
360}
361
362/// Port of `Num2Word_TH.to_cardinal`, integer path only.
363///
364/// Python does:
365/// ```text
366/// negative = number < 0
367/// pre, post = self.float2tuple(number)   # int input -> (number, 0), precision := 0
368/// pre = "{}".format(pre)                 # e.g. "-100"
369/// post = "{}".format(post)               # always "0" for ints
370/// if negative: pre = pre.lstrip("-")     # -> "100"
371/// result = self.left_num_to_text(pre)
372/// if not post == "0": ...                # dead for ints
373/// if negative: result = "ติดลบ" + result
374/// ```
375/// So for integers this reduces to: stringify, strip the sign, run the
376/// pipeline, re-attach the sign word with **no separating space**.
377fn to_cardinal(value: &BigInt) -> String {
378    let negative = value.is_negative();
379
380    // `pre = "{}".format(int(value))`, then `pre.lstrip("-")` when negative.
381    // BigInt::to_string emits at most one leading '-', so trim_start_matches
382    // matches lstrip exactly. Note this runs *before* left_num_to_text, which
383    // is why the "100" special-case fires for -100 → "ติดลบร้อย".
384    let pre = value.to_string();
385    let pre = if negative {
386        pre.trim_start_matches('-')
387    } else {
388        pre.as_str()
389    };
390
391    let result = left_num_to_text(pre);
392
393    if negative {
394        format!("{}{}", NEGWORD, result)
395    } else {
396        result
397    }
398}
399
400/// `abs(Decimal(str(f)).as_tuple().exponent)` — the `self.precision` that
401/// `float2tuple` assigns as a side effect and `to_cardinal` reads back.
402///
403/// Mirrors `floatpath::float_repr_precision`, which is private to that module.
404/// Rust's `{}` for f64 is shortest-round-trip, the same contract as Python's
405/// `repr`, so counting digits after the point agrees. The two disagree only in
406/// exponent form (`str(1e21)` is "1e+21" → precision 21, while Rust prints all
407/// 22 digits → precision 0), which is unreachable here: this is only ever fed
408/// subunits in `[0, 100)`.
409fn repr_precision(f: f64) -> u32 {
410    let s = format!("{}", f);
411    match s.split_once('.') {
412        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
413        _ => 0,
414    }
415}
416
417/// Port of `Num2Word_TH.to_cardinal`, **float path** — the other half of the
418/// method [`to_cardinal`] ports.
419///
420/// Reached only from the fractional-cents branch of [`LangTh::to_currency`],
421/// as Python's `self.to_cardinal(float(right))`. That dispatches to *TH's* own
422/// `to_cardinal`, which handles floats itself and never delegates to
423/// `Num2Word_Base.to_cardinal_float`. The trait's `cardinal_from_decimal` hook
424/// is therefore unusable here: its default routes through the base's float
425/// path, which joins words with spaces and titles the pointword —
426/// "หนึ่ง จุด หนึ่ง" where TH says "หนึ่งจุดหนึ่ง". So this is a local helper
427/// and `cardinal_from_decimal` is left at its (for TH, dead) default.
428///
429/// Python:
430/// ```text
431/// negative = number < 0
432/// pre, post = self.float2tuple(number)   # sets self.precision
433/// precision = self.precision
434/// pre = "{}".format(pre); post = "{}".format(post)
435/// if negative: pre = pre.lstrip("-")
436/// if len(post) < precision: post = "0" * (precision - len(post)) + post
437/// result = self.left_num_to_text(pre)
438/// if not post == "0":
439///     result += "จุด" + "".join(self.low_numwords[i] for i in map(int, post))
440/// if negative: result = "ติดลบ" + result
441/// ```
442/// Note this is *not* the base's loop: TH iterates every char of `post` rather
443/// than `range(self.precision)`, and pads on the left instead of the right.
444///
445/// The `negative` arm is dead on the only live call path —
446/// `parse_currency_parts` returns `right` already absolute — but is ported
447/// anyway so the helper is a faithful whole.
448fn to_cardinal_float(number: f64) -> String {
449    let negative = number < 0.0;
450
451    let precision = repr_precision(number);
452    let (pre, post) = float2tuple(&FloatValue::Float {
453        value: number,
454        precision,
455    });
456
457    let pre = pre.to_string();
458    let pre = if negative {
459        pre.trim_start_matches('-')
460    } else {
461        pre.as_str()
462    };
463
464    // `post` is non-negative (float2tuple takes abs), so this is pure padding.
465    let mut post = post.to_string();
466    if post.chars().count() < precision as usize {
467        let add_zero = precision as usize - post.chars().count();
468        post = format!("{}{}", "0".repeat(add_zero), post);
469    }
470
471    let mut result = left_num_to_text(pre);
472
473    // Python compares against the *padded* string, so a zero fraction at
474    // precision >= 2 ("00") passes this guard and renders "ศูนย์ศูนย์".
475    if post != "0" {
476        let mut right_text = String::new();
477        for ch in post.chars() {
478            let d = ch.to_digit(10).expect("float2tuple emits decimal digits") as usize;
479            right_text.push_str(LOW_NUMWORDS[d]);
480        }
481        result = format!("{}{}{}", result, POINTWORD, right_text);
482    }
483
484    if negative {
485        result = format!("{}{}", NEGWORD, result);
486    }
487    result
488}
489
490/// Port of `Num2Word_TH.to_cardinal`, **float/Decimal path**, driving the trait
491/// hook [`LangTh::to_cardinal_float`].
492///
493/// This is the same Python method [`to_cardinal`] ports, taken down its
494/// non-integer branch — the branch the f64-only [`to_cardinal_float`] above
495/// ports for the currency caller. The two differ only in where `precision`
496/// comes from:
497///   * the currency helper recomputes it from `repr(f64)`, safe because it is
498///     only ever handed subunits in `[0, 100)`;
499///   * here it is read from the [`FloatValue`] — `abs(exponent)` for the
500///     Decimal arm, the Python-`repr`-derived digit count for the Float arm.
501///     That source is load-bearing: a plain f64 like `1e21` prints with no `.`
502///     (Rust `repr` precision 0), yet Python's `str(1e21)` is `"1e+21"` →
503///     precision 21, so `num2words(1e21, lang="th")` renders the integer then
504///     "จุด" and twenty-one ศูนย์. Only the carried precision reproduces that.
505///
506/// Python (`number` is a `float` or a `Decimal`):
507/// ```text
508/// negative = number < 0
509/// pre, post = self.float2tuple(number)   # sets self.precision from the value
510/// precision = self.precision
511/// pre = "{}".format(pre); post = "{}".format(post)
512/// if negative: pre = pre.lstrip("-")
513/// if len(post) < precision: post = "0" * (precision - len(post)) + post
514/// result = self.left_num_to_text(pre)
515/// if not post == "0":
516///     result += "จุด" + "".join(self.low_numwords[i] for i in map(int, post))
517/// if negative: result = "ติดลบ" + result
518/// ```
519/// Unlike the base's float path this iterates *every* char of `post` (not
520/// `range(precision)`) and left-pads instead of right-truncating; here the two
521/// coincide because `post` is exactly `precision` digits after padding.
522fn cardinal_float_from_value(v: &FloatValue) -> String {
523    // Python: `negative = number < 0` — a *numeric* comparison, so -0.0 is
524    // NOT negative (unlike FloatValue::is_negative, which reads the sign
525    // bit). float2tuple's pre for -0.0 is 0 and never prints a '-', so the
526    // lstrip below stays consistent either way.
527    let negative = match v {
528        FloatValue::Float { value, .. } => *value < 0.0,
529        FloatValue::Decimal { value, .. } => value.is_negative(),
530    };
531    let precision = v.precision();
532    let (pre, post) = float2tuple(v);
533
534    // `pre = "{}".format(pre)`, then `pre.lstrip("-")` when negative — the sign
535    // is re-attached as a whole word at the end, exactly as in [`to_cardinal`].
536    let pre = pre.to_string();
537    let pre = if negative {
538        pre.trim_start_matches('-')
539    } else {
540        pre.as_str()
541    };
542
543    // `post` is non-negative (float2tuple takes abs), so this is pure left pad.
544    let mut post = post.to_string();
545    if post.chars().count() < precision as usize {
546        let add_zero = precision as usize - post.chars().count();
547        post = format!("{}{}", "0".repeat(add_zero), post);
548    }
549
550    let mut result = left_num_to_text(pre);
551
552    // Python compares against the *padded* string, so a zero fraction at
553    // precision >= 2 (e.g. Decimal("5.00") -> post "00") passes this guard and
554    // renders "ศูนย์ศูนย์"; only a genuine precision-0/whole value stays "0".
555    if post != "0" {
556        let mut right_text = String::new();
557        for ch in post.chars() {
558            let d = ch.to_digit(10).expect("float2tuple emits decimal digits") as usize;
559            right_text.push_str(LOW_NUMWORDS[d]);
560        }
561        result = format!("{}{}{}", result, POINTWORD, right_text);
562    }
563
564    if negative {
565        result = format!("{}{}", NEGWORD, result);
566    }
567    result
568}
569
570impl Lang for LangTh {
571
572    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
573        // Python class attribute MAXVAL (self-contained converter).
574        Some(num_bigint::BigInt::from(9000u64))
575    }
576    /// This language's own `to_currency(currency=...)` default,
577    /// read from the live Python signature. Only 44 of 156 use EUR.
578    fn default_currency(&self) -> &str {
579        "THB"
580    }
581
582    /// This language's own `to_currency(separator=...)` default,
583    /// read from the live Python signature. Base's is ",", but only
584    /// 36 of 149 languages actually use it — most default to " " or a
585    /// conjunction, so inheriting Base's comma silently corrupts them.
586    fn default_separator(&self) -> &str {
587        " "
588    }
589
590    fn negword(&self) -> &str {
591        NEGWORD
592    }
593
594    fn pointword(&self) -> &str {
595        POINTWORD
596    }
597
598    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
599        Ok(to_cardinal(value))
600    }
601
602    /// Port of `Num2Word_TH.to_cardinal`'s non-integer branch — the hook the
603    /// dispatcher reaches for float and `Decimal` cardinal input. TH overrides
604    /// `to_cardinal` (not `to_cardinal_float`) and inlines this itself, so the
605    /// base's `default_to_cardinal_float` — which space-joins and titles the
606    /// pointword — is deliberately *not* used. See [`cardinal_float_from_value`].
607    ///
608    /// `precision_override` (the `precision=` kwarg, issue #580) is **ignored**,
609    /// and that is a faithful port rather than an oversight: the dispatcher
610    /// stashes the override on `self.precision`, but TH's `to_cardinal` then
611    /// calls `float2tuple`, which unconditionally recomputes `self.precision`
612    /// from the value and clobbers it. So `num2words(12.34, lang="th",
613    /// precision=1)` still renders both decimals — verified against the
614    /// interpreter. See `concerns`.
615    fn to_cardinal_float(
616        &self,
617        value: &FloatValue,
618        _precision_override: Option<u32>,
619    ) -> Result<String> {
620        Ok(cardinal_float_from_value(value))
621    }
622
623    /// `to_cardinal(float/Decimal)` — the FULL entry, whole values included.
624    ///
625    /// TH's `to_cardinal` runs `float2tuple` unconditionally and compares the
626    /// *padded* post string against "0", so a whole value whose precision is
627    /// >= 2 still speaks its zeros: `Decimal("5.00")` → "ห้าจุดศูนย์ศูนย์",
628    /// `Decimal("1E+2")` → "ร้อยจุดศูนย์ศูนย์" (a positive exponent is a
629    /// positive `abs(exponent)` precision), and `1e+16` (str-precision 16)
630    /// gets จุด plus sixteen ศูนย์. Base's whole-value integer route would
631    /// silently drop all of that, so the default entry is overridden.
632    fn cardinal_float_entry(
633        &self,
634        value: &FloatValue,
635        _precision_override: Option<u32>,
636    ) -> Result<String> {
637        Ok(cardinal_float_from_value(value))
638    }
639
640    /// `to_ordinal(float/Decimal)`: `num = int(number)` — truncation toward
641    /// zero — then "ที่" + cardinal(int): `2.5` → "ที่สอง", `-1.5` →
642    /// "ที่ติดลบหนึ่ง", `Decimal("5.00")` → "ที่ห้า". The `except
643    /// (ValueError, TypeError): return str(number)` rescue only fires for
644    /// `int(nan)`; `int(inf)`'s OverflowError is not caught and propagates.
645    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
646        let num = match value {
647            FloatValue::Float { value, .. } => {
648                if value.is_nan() {
649                    // int(nan) raises ValueError, which to_ordinal catches
650                    // and answers with str(number).
651                    return Ok("nan".to_string());
652                }
653                if value.is_infinite() {
654                    return Err(N2WError::Overflow(
655                        "cannot convert float infinity to integer".into(),
656                    ));
657                }
658                <BigInt as num_traits::FromPrimitive>::from_f64(value.trunc())
659                    .ok_or_else(|| N2WError::Value("cannot convert float to integer".into()))?
660            }
661            FloatValue::Decimal { value, .. } => {
662                // int(Decimal) truncates toward zero.
663                value.with_scale(0).as_bigint_and_exponent().0
664            }
665        };
666        self.to_ordinal(&num)
667    }
668
669    /// `str_to_number` stays Base's `Decimal(value)`, but TH's `to_cardinal`
670    /// then calls `float2tuple`, whose `int(Decimal('NaN'))` raises
671    /// **decimal.InvalidOperation** (Infinity keeps the shared sentinel's
672    /// OverflowError — `int(Decimal('Infinity'))`). No digit present → the
673    /// dispatcher propagates it.
674    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
675        match crate::strnum::python_decimal_parse(s)? {
676            crate::strnum::ParsedNumber::NaN => Err(N2WError::Custom {
677                module: "decimal",
678                class: "InvalidOperation",
679                msg: "[<class 'decimal.InvalidOperation'>]".into(),
680            }),
681            other => Ok(other),
682        }
683    }
684
685    /// Port of `Num2Word_TH.to_ordinal`.
686    ///
687    /// Python wraps `int(number)` in `try/except (ValueError, TypeError)` and
688    /// falls back to `str(number)`. With a `BigInt` in hand the conversion has
689    /// already succeeded, so the fallback is unreachable and the whole method
690    /// is a prefix: "ที่" + cardinal.
691    ///
692    /// Note there is no `verify_ordinal` call, so negatives pass straight
693    /// through: `to_ordinal(-1)` == "ที่ติดลบหนึ่ง" ("th-minus-one"), which the
694    /// corpus confirms. Most languages raise TypeError here; TH does not.
695    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
696        Ok(format!("ที่{}", to_cardinal(value)))
697    }
698
699    // to_ordinal_num: inherited from Num2Word_Base (`return value`), which the
700    // trait default renders as `value.to_string()`. Corpus-confirmed for 0, 1
701    // and -1.
702    //
703    // to_year: inherited from Num2Word_Base (`return self.to_cardinal(value)`).
704    // The trait default delegates through &self and picks up the override
705    // above, including the negative handling. No BC/AD suffix exists.
706
707    // ---- currency -------------------------------------------------------
708    //
709    // TH overrides `to_currency` outright but inherits `to_cheque`,
710    // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `pluralize` from
711    // `Num2Word_Base`, so only the data table, the class name and
712    // `to_currency` itself appear here. Deliberately NOT overridden:
713    //
714    //   * `currency_precision` — TH never defines `CURRENCY_PRECISION`, so it
715    //     inherits Base's empty dict and `.get(code, 100)` is always 100. The
716    //     trait default already returns 100. TH's `to_currency` does not even
717    //     consult it (it hardcodes 100; see below), and the inherited
718    //     `to_cheque` reading 100 is exactly right.
719    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is likewise Base's
720    //     empty dict, and TH's `to_currency` ignores its `adjective` parameter
721    //     entirely. `to_currency(12.34, "THB", adjective=True)` is confirmed to
722    //     be byte-identical to the `adjective=False` call.
723    //   * `pluralize` — abstract in Base, and unreachable for TH: the
724    //     overridden `to_currency` never calls it (Thai does not inflect for
725    //     number, so it indexes `cr1[0]`/`cr2[0]` directly) and `to_cheque`
726    //     takes `cr1[-1]` without consulting it. Left raising NotImplemented,
727    //     which is what Python would do if anything ever reached it.
728    //   * `cardinal_from_decimal` — see [`to_cardinal_float`].
729
730    fn lang_name(&self) -> &str {
731        "Num2Word_TH"
732    }
733
734    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
735        self.currency_forms.get(code)
736    }
737
738    /// Port of `Num2Word_TH.to_currency`. Shares its *shape* with
739    /// `Num2Word_Base.to_currency` and almost none of its behaviour.
740    ///
741    /// Every divergence below is confirmed against the live interpreter:
742    ///
743    /// 1. **No space before the unit, and no `pluralize`.** Base emits
744    ///    `"%s%s %s"` and pluralizes; TH emits `"%s%s%s"` against `cr1[0]`.
745    ///    So `to_currency(100)` is "ร้อยยูโร", not "ร้อย ยูโร".
746    /// 2. **`minus_str` is `self.negword + " "`, not `negword.strip() + " "`.**
747    ///    Identical in practice — TH's negword carries no whitespace — but this
748    ///    is the one place TH puts a space after "ติดลบ" at all. Corpus:
749    ///    `-12.34` → "ติดลบ สิบสองยูโร สามสิบสี่เซนต์".
750    /// 3. **The divisor is hardcoded 100**, both in `has_fractional_cents` and
751    ///    in the (defaulted) `parse_currency_parts` argument. TH never reads
752    ///    `CURRENCY_PRECISION`, so there is no 3-decimal or 0-decimal handling
753    ///    and no `divisor == 1` pre-round. Unobservable in practice: KWD/BHD
754    ///    and JPY are not in TH's table and raise before reaching any of it.
755    /// 4. **No `has_decimal` guard.** Base skips the cents segment when the
756    ///    input has no decimal point *and* zero cents; TH has no such test, so
757    ///    every non-int renders cents. `1.0` → "หนึ่งยูโร ศูนย์เซนต์" (corpus).
758    ///    The `isinstance(val, int)` early return is the only thing suppressing
759    ///    cents.
760    /// 5. **Zero cents come from `low_numwords[0]`, not `_cents_verbose`.** The
761    ///    `right > 0` ternary short-circuits to the literal "ศูนย์", which
762    ///    happens to equal `to_cardinal(0)` — so this is a distinction without
763    ///    an output difference, but it is why `_cents_verbose` is dead code for
764    ///    TH.
765    /// 6. **`cents=False` yields `str(int(right))`, unpadded.** Base's
766    ///    `_cents_terse` zero-pads to the divisor width; TH bypasses it, so
767    ///    `to_currency(1.0, cents=False)` is "หนึ่งบาท 0สตางค์" — a bare "0",
768    ///    not "00". Verified.
769    /// 7. **The `left == 0 and right >= 50` special case.** Sub-unit amounts of
770    ///    half a unit or more drop the unit entirely: `0.5` → "ห้าสิบเซนต์",
771    ///    while `0.49` keeps it → "ศูนย์บาท สี่สิบเก้าสตางค์". Both corpus- or
772    ///    interpreter-confirmed. The cutoff is on `right`, i.e. the subunit
773    ///    count, so it is a "≥ 50 cents" rule and `minus_str` survives it
774    ///    (`-0.5` USD → "ติดลบ ห้าสิบเซนต์").
775    /// 8. **A different NotImplementedError message from the one `to_cheque`
776    ///    raises.** TH's is `Currency "X" not implemented for "Y"`; the
777    ///    inherited `Num2Word_Base.to_cheque` says `Currency code "X" not
778    ///    implemented for "Y"`. The missing word "code" is not a typo on my
779    ///    part — the two really do differ, because only `to_currency` is
780    ///    overridden. Both verified against the interpreter.
781    fn to_currency(
782        &self,
783        val: &CurrencyValue,
784        currency: &str,
785        cents: bool,
786        separator: Option<&str>,
787        // TH accepts `adjective` and never reads it.
788        _adjective: bool,
789    ) -> Result<String> {
790        let separator = separator.unwrap_or(self.default_separator());
791
792        // `is_integer_input = isinstance(val, int)` — a true int, never a float
793        // that happens to be whole. This is the whole of the cents suppression.
794        let is_integer_input = matches!(val, CurrencyValue::Int(_));
795
796        // `decimal_val = Decimal(str(val))`; `(decimal_val * 100) % 1 != 0`.
797        // Decimal's `%` is truncated (sign follows the dividend) rather than
798        // floored, but the `!= 0` test is sign-blind, so subtracting the
799        // truncation is equivalent for either sign.
800        let decimal_val: BigDecimal = match val {
801            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
802            CurrencyValue::Decimal { value, .. } => value.clone(),
803        };
804        let scaled = &decimal_val * BigDecimal::from(100);
805        let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
806
807        // Python passes no `divisor`, taking currency.py's default of 100.
808        let (left, right, is_negative) = parse_currency_parts(val, false, has_fractional_cents, 100);
809
810        // Python looks the forms up *after* parse_currency_parts. Order kept,
811        // though it is unobservable — parse_currency_parts cannot raise here.
812        let forms = self.currency_forms(currency).ok_or_else(|| {
813            N2WError::NotImplemented(format!(
814                "Currency \"{}\" not implemented for \"{}\"",
815                currency,
816                self.lang_name()
817            ))
818        })?;
819        let cr1 = &forms.unit;
820        let cr2 = &forms.subunit;
821
822        // `self.negword + " "` — no .strip(); see divergence 2.
823        let minus_str = if is_negative {
824            format!("{} ", NEGWORD)
825        } else {
826            String::new()
827        };
828        let money_str = to_cardinal(&left);
829        let currency_str = &cr1[0];
830
831        if is_integer_input {
832            return Ok(format!("{}{}{}", minus_str, money_str, currency_str));
833        }
834
835        // `int(right)`. Already integral unless keep_precision kept the
836        // fraction, and non-negative either way (parse_currency_parts abs'd it).
837        let right_int = right.with_scale(0).as_bigint_and_exponent().0;
838
839        let cents_str = if has_fractional_cents {
840            // Python guards this with `isinstance(right, Decimal) and
841            // has_fractional_cents`, but `right` is a Decimal exactly when
842            // keep_precision was set, and keep_precision *is* has_fractional_cents
843            // — the two conjuncts are the same condition. Note this branch is
844            // checked before `cents`, so `cents=False` is ignored for
845            // fractional subunits.
846            let f = right
847                .to_f64()
848                .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", right)))?;
849            to_cardinal_float(f)
850        } else if cents {
851            if right_int.is_positive() {
852                to_cardinal(&right_int)
853            } else {
854                LOW_NUMWORDS[0].to_string()
855            }
856        } else {
857            // `str(int(right))` — no zero padding; see divergence 6.
858            right_int.to_string()
859        };
860
861        let cents_currency = &cr2[0];
862
863        // See divergence 7. `right` (not `right_int`) is compared, so a
864        // fractional 50.5 cents also trips it.
865        if left.is_zero() && right >= BigDecimal::from(50) {
866            return Ok(format!("{}{}{}", minus_str, cents_str, cents_currency));
867        }
868
869        Ok(format!(
870            "{}{}{}{}{}{}",
871            minus_str, money_str, currency_str, separator, cents_str, cents_currency
872        ))
873    }
874
875    // to_cheque: inherited from `Num2Word_Base` verbatim, so the trait default
876    // (`currency::default_to_cheque`) is correct as-is. It reads `lang_name`
877    // and `currency_forms` above, takes `cr1[-1]` for the unit (both TH forms
878    // are identical, so the plural-unconditional convention is invisible), and
879    // routes the whole part through the default `money_verbose` →
880    // `to_cardinal`. `.upper()` is a no-op on Thai script, which has no case.
881    // Corpus: 1234.56 EUR → "หนึ่งพันสองร้อยสามสิบสี่ AND 56/100 ยูโร".
882}