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}