Skip to main content

num2words2_core/
lang_zh.rs

1//! Port of the class the `"zh"` code actually resolves to: `Num2Word_ZH_CN`
2//! (`lang_ZH_CN.py`), which subclasses `Num2Word_ZH` (`lang_ZH.py`) →
3//! `Num2Word_Base`.
4//!
5//! `Num2Word_ZH` is an abstract-ish base that nothing registers directly:
6//! `__init__.py` maps `"zh"` (and `"zh_CN"`, `"cn"`) to `Num2Word_ZH_CN`, and
7//! `"zh_TW"`/`"zh_HK"` to their own subclasses. `ZH_CN.setup()` calls
8//! `super().setup()` and then overrides `negword`/`pointword`/`high_numwords`
9//! with the **simplified** forms (负 / 点 / 万,亿,兆…), so this file reproduces
10//! the simplified output the frozen corpus records for `"zh"`. `mid_numwords`
11//! and `low_numwords` are *not* overridden by ZH_CN — they stay as ZH set them
12//! (千/百/十, 九…一, ("零","〇")), whose glyphs are identical in both scripts.
13//!
14//! Engine-style: cards + `merge` drive `Num2Word_Base.to_cardinal`; ZH then
15//! post-processes with `.replace(" ", "")` + `zh_to_cap`.
16//!
17//! Kwargs note: the Python methods take `reading`/`prefer`/`counter`/
18//! `stuff_zero` keywords. The plain (no-kwargs) entry points hold their
19//! defaults (`reading=False`, `prefer=None`, `counter=""`, `stuff_zero=2`),
20//! which collapses `select_text` to a constant choice — see `select_text`
21//! below. The `*_kw` hooks at the bottom of the `Lang` impl carry the live
22//! kwargs: `reading="capital"` swaps in the 大寫 forms via `CAP_map`
23//! (`zh_to_cap`'s capital arm), `prefer=["〇"]` re-selects the one
24//! tuple-valued card `("零", "〇")`, and `stuff_zero` parameterizes `merge`.
25//!
26//! Float/Decimal entry semantics (`ordinal_float_entry` & friends):
27//! `to_ordinal` runs `Num2Word_Base.verify_ordinal`, whose *float* arm is
28//! live for float input — a non-whole value raises TypeError
29//! (`errmsg_floatord`) before the negative check (`errmsg_negord`); note
30//! `-0.0` passes both (`int(-0.0) == -0.0` and `abs(-0.0) == -0.0`), so
31//! `to_ordinal(-0.0)` is "第零" while `to_ordinal(-1.0)` raises. `to_year`
32//! raises its own TypeError (`errmsg_floatyear`) on non-whole values and
33//! renders whole ones digit-by-digit ("一零零零年"). `to_ordinal_num` never
34//! verifies anything: it interpolates `str(value)` — "第-0.0", "第1e+16".
35
36use crate::base::{
37    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, KwVal, Lang, N2WError,
38    Result,
39};
40use crate::currency::CurrencyValue;
41use crate::floatpath::FloatValue;
42use bigdecimal::BigDecimal;
43use num_bigint::BigInt;
44use num_traits::{One, Signed, Zero};
45
46/// `ord_prefix` — a plain `str`, so `select_text` returns it unchanged.
47const ORD_PREFIX: &str = "第";
48/// `year`, `year_bce` — plain `str`s.
49const YEAR: &str = "年";
50const YEAR_BCE: &str = "前";
51/// `year_prefix = ("公元", "西元")` is a *tuple*. With `reading=False` and
52/// `prefer=None`, `select_text` falls through to `text[0]` → "公元".
53const YEAR_PREFIX: &str = "公元";
54/// `select_text(self.low_numwords[-1])` where `low_numwords[-1] == ("零","〇")`
55/// → "零" (same `text[0]` fallthrough).
56const ZERO_WORD: &str = "零";
57
58/// `to_cardinal`'s default `stuff_zero=2` — "discontinuous high numbers".
59const STUFF_ZERO: u8 = 2;
60
61pub struct LangZh {
62    cards: Cards,
63    maxval: BigInt,
64    exclude_title: Vec<String>,
65}
66
67impl Default for LangZh {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Python's `len(str(n))`. Values reaching `merge` are non-negative (base
74/// `to_cardinal` takes `abs` first), so this is the plain decimal digit count.
75fn digit_len(n: &BigInt) -> i64 {
76    n.to_string().len() as i64
77}
78
79/// `Num2Word_ZH.merge`, verbatim, with the two per-call inputs live:
80/// `self.stuff_zero` (set by `to_cardinal`'s kwarg) and
81/// `select_text(self.low_numwords[-1])` (the 零/〇 the `prefer` kwarg picks).
82fn zh_merge(
83    l: (&str, &BigInt),
84    r: (&str, &BigInt),
85    stuff_zero: i64,
86    zero_word: &str,
87) -> (String, BigInt) {
88    let (ltext, lnum) = l;
89    let (rtext, rnum) = r;
90    let ten = BigInt::from(10);
91
92    // ignore lpair if lnum is 1 and rnum is less than 10
93    if lnum.is_one() && rnum < &ten {
94        return (rtext.to_string(), rnum.clone());
95    }
96
97    let with_zero = || (format!("{}{}{}", ltext, zero_word, rtext), lnum + rnum);
98    let no_zero = || (format!("{}{}", ltext, rtext), lnum + rnum);
99
100    let lo_len = digit_len(lnum);
101    let ro_len = digit_len(rnum);
102
103    if lo_len - ro_len > 1 {
104        match stuff_zero {
105            // 凡「零」必讀 — all discontinuous numbers
106            1 => return with_zero(),
107            // discontinuous *high* numbers only
108            2 => {
109                if lo_len - ro_len > 1 && ro_len % 4 != 0 {
110                    return with_zero();
111                }
112                return no_zero();
113            }
114            // 凡「零」不讀 — no zeros
115            3 => return no_zero(),
116            // Python's if/elif chain has no else: any other stuff_zero
117            // (None, 4, "2", ...) falls through to the trailing `return
118            // no_zero` — `None == 1` etc. are all simply False.
119            _ => {}
120        }
121    } else if rnum > lnum {
122        return (format!("{}{}", ltext, rtext), lnum * rnum);
123    }
124    no_zero()
125}
126
127// ---- grammatical kwargs (reading / prefer / stuff_zero / counter) ----------
128
129/// The three states `reading` can put `select_text`/`zh_to_cap` in.
130///
131/// Python tests `reading is True` (identity, so only the bool `True`) and
132/// `reading == "capital"`; every other value — `False`, `None`, other
133/// strings, ints — behaves like the default.
134#[derive(Clone, Copy, PartialEq)]
135enum Reading {
136    Plain,
137    True,
138    Capital,
139}
140
141fn parse_reading(kw: &Kwargs) -> Reading {
142    match kw.get("reading") {
143        Some(KwVal::Bool(true)) => Reading::True,
144        Some(KwVal::Str(s)) if s == "capital" => Reading::Capital,
145        _ => Reading::Plain,
146    }
147}
148
149/// The `prefer` kwarg as the item set `select_text` intersects with.
150///
151/// Python does `set(text) & set(self.prefer or set())`: a list keeps its
152/// members, a *string* iterates characters (`set("〇") == {"〇"}`), and a
153/// non-iterable (int/bool) raises TypeError inside `set()` — returned as
154/// NotImplemented so the dispatcher falls back to Python, which owns that
155/// raise (and, for values whose rendering never touches a tuple card,
156/// silently succeeds — the fallback reproduces both outcomes).
157fn parse_prefer(kw: &Kwargs) -> Result<Option<Vec<String>>> {
158    match kw.get("prefer") {
159        None | Some(KwVal::None) => Ok(None),
160        Some(KwVal::List(l)) => Ok(Some(l.clone())),
161        Some(KwVal::Str(s)) => Ok(Some(s.chars().map(|c| c.to_string()).collect())),
162        Some(_) => Err(N2WError::Fallback("kwargs".into())),
163    }
164}
165
166/// `stuff_zero`, defaulting to 2. `True`/`False` are Python ints (1/0); any
167/// non-int value compares unequal to 1/2/3 and takes `zh_merge`'s
168/// fall-through arm, modelled as the sentinel 0.
169fn parse_stuff_zero(kw: &Kwargs) -> i64 {
170    match kw.get("stuff_zero") {
171        None => 2,
172        Some(KwVal::Int(i)) => *i,
173        Some(KwVal::Bool(b)) => *b as i64,
174        Some(_) => 0,
175    }
176}
177
178/// The `counter` kwarg. Only a `str` reaches the output via `select_text`'s
179/// `strtype` short-circuit; anything else (`None`, an int) hits `len(text)`
180/// inside `select_text` and raises TypeError — delegated back to Python.
181fn parse_counter(kw: &Kwargs) -> Result<&str> {
182    match kw.get("counter") {
183        None => Ok(""),
184        Some(KwVal::Str(s)) => Ok(s),
185        Some(_) => Err(N2WError::Fallback("kwargs".into())),
186    }
187}
188
189/// `select_text` over a tuple of plain-`str` alternatives (the only tuple
190/// shape ZH_CN's tables hold): the single member of `set(alts) & set(prefer)`
191/// if exactly one matches, else the first alternative. `reading` never
192/// matters for these tables — no item is itself a tuple.
193fn select_alt<'a>(alts: &[&'a str], prefer: Option<&[String]>) -> &'a str {
194    if let Some(p) = prefer {
195        let hits: Vec<&str> = alts
196            .iter()
197            .copied()
198            .filter(|a| p.iter().any(|x| x == a))
199            .collect();
200        if hits.len() == 1 {
201            return hits[0];
202        }
203    }
204    alts[0]
205}
206
207/// `zh_to_cap`'s capital arm: sequential `str.replace` over `CAP_map`.
208fn apply_cap_map(value: &str, map: &[(&str, &str)]) -> String {
209    let mut out = value.to_string();
210    for (plain, cap) in map {
211        if out.contains(plain) {
212            out = out.replace(plain, cap);
213        }
214    }
215    out
216}
217
218/// `Num2Word_ZH_CN.CAP_map` — the 大寫 substitution table `zh_to_cap` walks
219/// under `reading == "capital"`. ZH_CN re-declares the parent's table with
220/// simplified capitals and **drops** the `("正", "整")` row, which is why the
221/// cheque suffix stays 正 here ("零圆正") while zh_HK/zh_TW print 整.
222const ZH_CN_CAP_MAP: [(&str, &str); 13] = [
223    ("千", "仟"),
224    ("百", "佰"),
225    ("十", "拾"),
226    ("九", "玖"),
227    ("八", "捌"),
228    ("七", "柒"),
229    ("六", "陆"),
230    ("五", "伍"),
231    ("四", "肆"),
232    ("三", "叁"),
233    ("二", "贰"),
234    ("一", "壹"),
235    ("元", "圆"),
236];
237
238/// The `("零", "〇")` card — the only `prefer`-selectable table entry.
239const ZERO_ALTS: [&str; 2] = ["零", "〇"];
240/// `year_prefix = ("公元", "西元")` — the other `prefer`-selectable tuple.
241const YEAR_PREFIX_ALTS: [&str; 2] = ["公元", "西元"];
242
243/// A per-call view of the engine with the `to_cardinal` kwargs live:
244/// `stuff_zero` parameterizes `merge`, and the zero card is re-selected
245/// under `prefer` (the only card `select_text` can change).
246struct ZhKwEngine<'a> {
247    base: &'a LangZh,
248    cards: Cards,
249    stuff_zero: i64,
250    zero_word: &'static str,
251}
252
253impl Lang for ZhKwEngine<'_> {
254    fn cards(&self) -> &Cards {
255        &self.cards
256    }
257    fn maxval(&self) -> &BigInt {
258        self.base.maxval()
259    }
260    fn negword(&self) -> &str {
261        self.base.negword()
262    }
263    fn pointword(&self) -> &str {
264        self.base.pointword()
265    }
266    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
267        zh_merge(l, r, self.stuff_zero, self.zero_word)
268    }
269}
270
271/// Python's `str(<float>)` for a finite value — used only to fill the `%s`
272/// of the TypeError format strings (`errmsg_floatord`/`errmsg_negord`/
273/// `errmsg_floatyear`); the corpora record the exception *type*, the message
274/// mirrors CPython's spelling.
275fn py_float_str(value: f64, precision: u32) -> String {
276    let a = value.abs();
277    let mag = if a != 0.0 && (a >= 1e16 || a < 1e-4) {
278        let s = format!("{:e}", a);
279        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
280        let exp: i32 = exp.parse().expect("exponent is a small int");
281        format!(
282            "{}e{}{:02}",
283            mant,
284            if exp < 0 { '-' } else { '+' },
285            exp.abs()
286        )
287    } else {
288        format!("{:.*}", precision as usize, a)
289    };
290    if value.is_sign_negative() {
291        format!("-{}", mag)
292    } else {
293        mag
294    }
295}
296
297/// `str(value)` for either FloatValue arm (Decimal's `%s` is its `str`).
298fn py_value_str(value: &FloatValue) -> String {
299    match value {
300        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
301        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
302    }
303}
304
305impl LangZh {
306    pub fn new() -> Self {
307        // ZH_CN.setup() rebuilds high_numwords in simplified script, then
308        // reverses it. Listed here already reversed: 10**72 first, 10**4 last.
309        let high = [
310            "不可说",     // 10 ** 72
311            "无量",       // 10 ** 68
312            "不可思议",   // 10 ** 64
313            "那由他",     // 10 ** 60
314            "阿僧祇",     // 10 ** 56
315            "恒河沙",     // 10 ** 52
316            "极",         // 10 ** 48
317            "载",         // 10 ** 44
318            "正",         // 10 ** 40
319            "涧",         // 10 ** 36
320            "沟",         // 10 ** 32
321            "穣",         // 10 ** 28
322            "秭",         // 10 ** 24
323            "垓",         // 10 ** 20
324            "京",         // 10 ** 16
325            "兆",         // 10 ** 12
326            "亿",         // 10 ** 8
327            "万",         // 10 ** 4
328        ];
329
330        let mut cards = Cards::new();
331
332        // ZH.set_high_numwords: max = 4 * len(high); zip(high, range(max, 0, -4)).
333        // 18 words zip exactly against 72,68,…,4.
334        let mut n: u32 = 4 * high.len() as u32;
335        for word in high.iter() {
336            if n == 0 {
337                break; // mirrors range(max, 0, -4) exhausting before zip does
338            }
339            cards.insert(BigInt::from(10u8).pow(n), *word);
340            n -= 4;
341        }
342
343        set_mid_numwords(&mut cards, &[(1000, "千"), (100, "百"), (10, "十")]);
344        // low_numwords[-1] is the tuple ("零","〇"); select_text pins it to
345        // "零" for every call the four ported modes make, so store "零".
346        set_low_numwords(
347            &mut cards,
348            &["九", "八", "七", "六", "五", "四", "三", "二", "一", ZERO_WORD],
349        );
350
351        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10**72 = 10**75.
352        let maxval = cards
353            .highest()
354            .cloned()
355            .expect("zh cards are non-empty")
356            * BigInt::from(1000);
357
358        LangZh {
359            cards,
360            maxval,
361            // is_title is False for ZH, so exclude_title never gets consulted;
362            // carried over for fidelity with setup().
363            exclude_title: vec!["负".into(), "点".into()],
364        }
365    }
366
367    /// `Num2Word_Base.verify_ordinal`. The float check is unreachable for
368    /// integer input; only the negative check can fire.
369    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
370        if value.is_negative() {
371            return Err(N2WError::Type(format!(
372                "Cannot treat negative num {} as ordinal.",
373                value
374            )));
375        }
376        Ok(())
377    }
378
379    /// `zh_to_cap(value, capital=False)`.
380    ///
381    /// The `capital=True` branch (CAP_map substitution) is unreachable here —
382    /// it needs `reading == "capital"`, which only `to_currency` passes. The
383    /// `False` branch drops a leading 一 from 一十… so 一十 → 十, 一十二万 → 十二万.
384    ///
385    /// Python slices `out[len(one):]`, i.e. it strips only `one` and keeps
386    /// `ten`. `starts_with(one + ten)` guarantees the prefix, so
387    /// `strip_prefix(one)` is the exact equivalent (and stays char-safe).
388    fn zh_to_cap(&self, value: &str) -> String {
389        let one = self.cards.get(&BigInt::one()).unwrap_or("");
390        let ten = self.cards.get(&BigInt::from(10)).unwrap_or("");
391        let prefix = format!("{}{}", one, ten);
392        if value.starts_with(&prefix) {
393            return value.strip_prefix(one).unwrap_or(value).to_string();
394        }
395        value.to_string()
396    }
397
398    /// `Num2Word_ZH.to_cardinal(value, stuff_zero, reading, prefer)` with the
399    /// kwargs live:
400    ///
401    /// ```python
402    /// self.stuff_zero = stuff_zero
403    /// self.set_str_selection(reading, prefer)
404    /// out = super().to_cardinal(value).replace(" ", "")
405    /// return self.zh_to_cap(out, reading == "capital")
406    /// ```
407    ///
408    /// `prefer` can only change the zero card (the sole tuple entry), so the
409    /// engine view swaps that one card; `stuff_zero` rides through `merge`;
410    /// `reading == "capital"` takes `zh_to_cap`'s CAP_map arm, which — unlike
411    /// the plain arm — performs **no** leading-一 strip (11 → "壹拾壹").
412    fn kw_cardinal(
413        &self,
414        value: &BigInt,
415        stuff_zero: i64,
416        reading: Reading,
417        prefer: Option<&[String]>,
418    ) -> Result<String> {
419        let zero = select_alt(&ZERO_ALTS, prefer);
420        let mut cards = self.cards.clone();
421        if zero != ZERO_WORD {
422            cards.insert(BigInt::zero(), zero);
423        }
424        let eng = ZhKwEngine {
425            base: self,
426            cards,
427            stuff_zero,
428            zero_word: zero,
429        };
430        let out = default_to_cardinal(&eng, value)?.replace(' ', "");
431        Ok(match reading {
432            Reading::Capital => apply_cap_map(&out, &ZH_CN_CAP_MAP),
433            _ => self.zh_to_cap(&out),
434        })
435    }
436}
437
438impl Lang for LangZh {
439    /// `Num2Word_ZH_CN.__class__.__name__`, for the NotImplementedError text.
440    fn lang_name(&self) -> &str {
441        "Num2Word_ZH_CN"
442    }
443
444    /// Port of `Num2Word_ZH.to_currency` under ZH_CN's simplified tables.
445    ///
446    /// The four ported entry points never pass `reading`/`prefer`, so they
447    /// hold their defaults (False/None) throughout — same collapse as the
448    /// integer modes. `cents`/`separator`/`adjective` are accepted and
449    /// ignored, exactly as Python ignores them ("CURRENCY_ADJECTIVES are not
450    /// implemented"; `cents`/`separator` are never read in the body).
451    fn to_currency(
452        &self,
453        val: &CurrencyValue,
454        currency: &str,
455        _cents: bool,
456        _separator: Option<&str>,
457        _adjective: bool,
458    ) -> Result<String> {
459        // parse_currency_parts(val, is_int_with_cents=False): ints keep their
460        // magnitude with zero cents; floats quantize to .01 ROUND_HALF_UP.
461        let (left, right, is_negative) =
462            crate::currency::parse_currency_parts(val, false, false, 100);
463
464        let cr = zh_cn_currency_form(currency).ok_or_else(|| {
465            N2WError::NotImplemented(format!(
466                "Currency code \"{}\" not implemented for \"{}\"",
467                currency,
468                self.lang_name()
469            ))
470        })?;
471
472        // minus_str = self.negword (ZH_CN: 负, no trailing space).
473        let minus_str = if is_negative { self.negword() } else { "" };
474        let money_str = self.to_cardinal(&left)?;
475
476        // XXX renders bare; every real code prefixes its name and uses 元.
477        let (mut out, cr_post) = if currency == "XXX" {
478            (String::new(), cr)
479        } else {
480            (cr.to_string(), "元")
481        };
482
483        // to_currency_float(right): "%02d" over the cents, digit + 角/分.
484        let cents_num: u32 = right
485            .with_scale(0)
486            .as_bigint_and_exponent()
487            .0
488            .to_string()
489            .parse()
490            .unwrap_or(0);
491        let mut cents_parts: Vec<String> = Vec::new();
492        if cents_num > 0 {
493            let d0 = cents_num / 10;
494            let d1 = cents_num % 10;
495            // reading != "capital", so the leading zero digit IS emitted.
496            cents_parts.push(ZH_DIGITS[d0 as usize].to_string());
497            if d0 > 0 {
498                cents_parts.push("角".to_string());
499            }
500            if d1 > 0 {
501                cents_parts.push(ZH_DIGITS[d1 as usize].to_string());
502                cents_parts.push("分".to_string());
503            }
504        }
505        // cheque suffix "正" only under reading == "capital" — never here.
506
507        // zh_to_cap(select_text(c), capital=False): strip a leading 一 from
508        // anything that starts 一十 ("一十二" -> "十二").
509        for c in std::iter::once(minus_str.to_string())
510            .chain(std::iter::once(money_str))
511            .chain(std::iter::once(cr_post.to_string()))
512            .chain(cents_parts.into_iter())
513        {
514            let c = match c.strip_prefix("一十") {
515                Some(rest) => format!("十{}", rest),
516                None => c,
517            };
518            out.push_str(&c);
519        }
520        Ok(out)
521    }
522
523    /// `Num2Word_Base.to_cheque` under ZH_CN's single-string CURRENCY_FORMS.
524    ///
525    /// The base does `cr1, _cr2 = self.CURRENCY_FORMS[currency]` — a tuple
526    /// unpack of a *string*, which iterates characters. A 2-char name
527    /// ("欧元") unpacks to cr1="欧"; 1 char raises "not enough values to
528    /// unpack", 3+ chars "too many values to unpack" — both ValueError,
529    /// which the corpus records for CNY (人民币) and CHF (瑞士法郎).
530    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
531        let cr = zh_cn_currency_form(currency).ok_or_else(|| {
532            N2WError::NotImplemented(format!(
533                "Currency code \"{}\" not implemented for \"{}\"",
534                currency,
535                self.lang_name()
536            ))
537        })?;
538        let nchars = cr.chars().count();
539        if nchars != 2 {
540            return Err(N2WError::Value(if nchars < 2 {
541                format!("not enough values to unpack (expected 2, got {})", nchars)
542            } else {
543                format!("too many values to unpack (expected 2)")
544            }));
545        }
546        let unit: String = cr.chars().take(1).collect();
547
548        let is_negative = val.sign() == num_bigint::Sign::Minus;
549        let abs_val = if is_negative { -val } else { val.clone() };
550        let whole = abs_val.with_scale(0).as_bigint_and_exponent().0;
551        let sub = ((&abs_val - BigDecimal::from(whole.clone())) * BigDecimal::from(100))
552            .with_scale(0)
553            .as_bigint_and_exponent()
554            .0;
555        let words = self.to_cardinal(&whole)?;
556        let sign = if is_negative { "MINUS " } else { "" };
557        // .upper() is a no-op on Chinese text.
558        Ok(format!("{}{} AND {:02}/100 {}", sign, words, sub, unit))
559    }
560
561    /// This language's own `to_currency(currency=...)` default,
562    /// read from the live Python signature. Only 44 of 156 use EUR.
563    fn default_currency(&self) -> &str {
564        "XXX"
565    }
566
567    /// This language's own `to_currency(separator=...)` default,
568    /// read from the live Python signature. Base's is ",", but only
569    /// 36 of 149 languages actually use it — most default to " " or a
570    /// conjunction, so inheriting Base's comma silently corrupts them.
571    fn default_separator(&self) -> &str {
572        ""
573    }
574
575    fn cards(&self) -> &Cards {
576        &self.cards
577    }
578    fn maxval(&self) -> &BigInt {
579        &self.maxval
580    }
581    fn negword(&self) -> &str {
582        "负"
583    }
584    fn pointword(&self) -> &str {
585        "点"
586    }
587    fn exclude_title(&self) -> &[String] {
588        &self.exclude_title
589    }
590
591    /// `Num2Word_ZH.merge`.
592    ///
593    /// `select_text(ltext)`/`select_text(rtext)` are identity here: by the time
594    /// merge runs, both texts are plain `str`s (the only tuple card, 0, is
595    /// stored pre-selected as "零"). Shared with the kwargs engine — see
596    /// [`zh_merge`] for the verbatim Python body.
597    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
598        zh_merge(l, r, STUFF_ZERO as i64, ZERO_WORD)
599    }
600
601    /// `Num2Word_ZH.to_cardinal`: run the base engine, strip the spaces the
602    /// engine inserts, then `zh_to_cap`.
603    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
604        let out = default_to_cardinal(self, value)?;
605        // super().to_cardinal(value).replace(" ", "") — kills the space the
606        // base inserts after negword ("负 一" → "负一").
607        let out = out.replace(' ', "");
608        Ok(self.zh_to_cap(&out))
609    }
610
611    /// `Num2Word_ZH.to_cardinal_float`:
612    ///
613    /// ```python
614    /// def to_cardinal_float(self, value):
615    ///     out = super().to_cardinal_float(value).replace(" ", "")
616    ///     return self.zh_to_cap(out, self.capital)
617    /// ```
618    ///
619    /// `super().to_cardinal_float` is `Num2Word_Base.to_cardinal_float`
620    /// (`default_to_cardinal_float`); its per-part `self.to_cardinal(...)`
621    /// calls are ZH's own `to_cardinal`, so each part is already space-free and
622    /// `zh_to_cap`-ed. This method then strips the spaces the base joins with
623    /// (after negword and around the pointword) and runs `zh_to_cap` once more
624    /// over the whole string.
625    ///
626    /// `self.capital` is always False here: only `to_currency(reading=
627    /// "capital")` sets it, and every `to_cardinal` call the base float path
628    /// makes runs `set_str_selection(False, ...)` first, resetting it. So
629    /// `zh_to_cap` takes its capital=False branch — the one already ported.
630    ///
631    /// The outer `zh_to_cap` only fires when the *whole* string starts with
632    /// 一十, but the inner per-part strip already removed that for positives
633    /// ("十二点三四"), and a negative keeps its 一 because the leading 负 pushes
634    /// 一十 off the string start ("负一十二点三四"). Reproduced faithfully — the
635    /// quirk is the point.
636    ///
637    /// `precision_override` is dropped, faithfully: ZH forwards `value` only,
638    /// and `float2tuple` recomputes `self.precision` from the value regardless,
639    /// so Python's `precision=` kwarg is a verified no-op on this path
640    /// (`num2words(2.675, lang="zh", precision=5) == "二点六七五"`).
641    fn to_cardinal_float(
642        &self,
643        value: &FloatValue,
644        _precision_override: Option<u32>,
645    ) -> Result<String> {
646        let out = crate::floatpath::default_to_cardinal_float(self, value, None)?;
647        // super().to_cardinal_float(value).replace(" ", "")
648        let out = out.replace(' ', "");
649        Ok(self.zh_to_cap(&out))
650    }
651
652    /// `Num2Word_ZH.to_ordinal` with `counter=""` (select_text("") == "").
653    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
654        self.verify_ordinal(value)?;
655        let base = self.to_cardinal(value)?;
656        Ok(format!("{}{}", ORD_PREFIX, base))
657    }
658
659    /// `Num2Word_ZH.to_ordinal_num`.
660    ///
661    /// Faithful quirk: unlike `to_ordinal`, this one never calls
662    /// `verify_ordinal`, so a negative is formatted rather than rejected —
663    /// `to_ordinal_num(-1) == "第-1"`.
664    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
665        Ok(format!("{}{}", ORD_PREFIX, value))
666    }
667
668    /// `Num2Word_ZH.to_year`: digit-by-digit, not cardinal.
669    ///
670    /// The `elif reading == "capital"` prefix branch is unreachable
671    /// (`reading` is always False here), so positive years carry no prefix.
672    /// Note zh_to_cap is *not* applied, so 10 → "一零年" keeps its 一.
673    fn to_year(&self, value: &BigInt) -> Result<String> {
674        let mut out = String::new();
675        if value.is_negative() {
676            out.push_str(YEAR_PREFIX);
677            out.push_str(YEAR_BCE);
678        }
679        // [self.cards[int(s)] for s in str(abs(int(value)))] — digits 0-9 are
680        // all present as low numwords, so the lookup cannot miss.
681        for ch in value.abs().to_string().chars() {
682            let d = BigInt::from(ch.to_digit(10).expect("abs(BigInt) is all digits"));
683            out.push_str(self.cards.get(&d).unwrap_or(""));
684        }
685        out.push_str(YEAR);
686        Ok(out)
687    }
688
689    // ---- float/Decimal entry routing --------------------------------------
690
691    /// `to_ordinal(float/Decimal)`: `Num2Word_Base.verify_ordinal` runs with
692    /// both arms live. Non-whole → TypeError(`errmsg_floatord`) *first*;
693    /// whole-but-negative → TypeError(`errmsg_negord`); `-0.0` passes both
694    /// (`int(-0.0) == -0.0`, `abs(-0.0) == -0.0`) and renders "第零". A whole
695    /// value then takes `to_cardinal`'s integer path, prefixed with 第.
696    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
697        match value.as_whole_int() {
698            None => Err(N2WError::Type(format!(
699                "Cannot treat float {} as ordinal.",
700                py_value_str(value)
701            ))),
702            Some(i) => {
703                if i.is_negative() {
704                    return Err(N2WError::Type(format!(
705                        "Cannot treat negative num {} as ordinal.",
706                        py_value_str(value)
707                    )));
708                }
709                Ok(format!("{}{}", ORD_PREFIX, self.to_cardinal(&i)?))
710            }
711        }
712    }
713
714    /// `to_ordinal_num(float/Decimal)`: no verification at all — Python
715    /// interpolates `str(value)` between 第 and the (default-empty) counter,
716    /// so "第-0.0", "第0.5", "第1e+16" are all faithful outputs.
717    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
718        Ok(format!("{}{}", ORD_PREFIX, repr_str))
719    }
720
721    /// `to_year(float/Decimal)`: `if not value == int(value)` raises
722    /// TypeError(`errmsg_floatyear`); a whole value renders digit-by-digit
723    /// through the integer `to_year` (`int(-0.0)` is 0 → no 公元前 prefix).
724    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
725        match value.as_whole_int() {
726            None => Err(N2WError::Type(format!(
727                "Cannot treat float {} as year.",
728                py_value_str(value)
729            ))),
730            Some(i) => self.to_year(&i),
731        }
732    }
733
734    // ---- grammatical kwargs ------------------------------------------------
735
736    /// `to_cardinal(value, stuff_zero=2, reading=False, prefer=None)`.
737    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
738        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
739            return Err(N2WError::Fallback("kwargs".into()));
740        }
741        let prefer = parse_prefer(kw)?;
742        self.kw_cardinal(
743            value,
744            parse_stuff_zero(kw),
745            parse_reading(kw),
746            prefer.as_deref(),
747        )
748    }
749
750    /// `to_ordinal(value, counter="", reading=False, prefer=None)`:
751    /// `verify_ordinal` (negatives raise) then the kwargs cardinal with the
752    /// default `stuff_zero=2` — Python forwards only `reading`/`prefer`.
753    /// The 第 prefix and the counter are plain `str`s through `select_text`.
754    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
755        if !kw.only(&["counter", "reading", "prefer"]) {
756            return Err(N2WError::Fallback("kwargs".into()));
757        }
758        let counter = parse_counter(kw)?;
759        let prefer = parse_prefer(kw)?;
760        let reading = parse_reading(kw);
761        self.verify_ordinal(value)?;
762        let base = self.kw_cardinal(value, 2, reading, prefer.as_deref())?;
763        Ok(format!("{}{}{}", ORD_PREFIX, base, counter))
764    }
765
766    /// `to_ordinal_num(value, counter="", reading=False, prefer=None)`:
767    /// "第" + `str(value)` + counter. `reading`/`prefer` are inert here —
768    /// `select_text` only ever sees the plain strings 第 and the counter —
769    /// so they are accepted and ignored, exactly as any value of them is.
770    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
771        if !kw.only(&["counter", "reading", "prefer"]) {
772            return Err(N2WError::Fallback("kwargs".into()));
773        }
774        let counter = parse_counter(kw)?;
775        Ok(format!("{}{}{}", ORD_PREFIX, value, counter))
776    }
777
778    /// `to_year(value, reading=False, prefer=None)`. The one kwargs-reachable
779    /// branch beyond the defaults: `elif reading == "capital"` prefixes a
780    /// positive year with `year_prefix` ("公元一二三四年") — the digits are
781    /// *not* CAP-mapped (`to_year` never calls `zh_to_cap`). `prefer` can
782    /// re-select the 零/〇 digit and the 公元/西元 prefix.
783    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
784        if !kw.only(&["reading", "prefer"]) {
785            return Err(N2WError::Fallback("kwargs".into()));
786        }
787        let prefer = parse_prefer(kw)?;
788        let reading = parse_reading(kw);
789        let p = prefer.as_deref();
790
791        let mut out = String::new();
792        if value.is_negative() {
793            out.push_str(select_alt(&YEAR_PREFIX_ALTS, p));
794            out.push_str(YEAR_BCE);
795        } else if reading == Reading::Capital {
796            out.push_str(select_alt(&YEAR_PREFIX_ALTS, p));
797        }
798        for ch in value.abs().to_string().chars() {
799            let d = ch.to_digit(10).expect("abs(BigInt) is all digits") as usize;
800            if d == 0 {
801                out.push_str(select_alt(&ZERO_ALTS, p));
802            } else {
803                out.push_str(ZH_DIGITS[d]);
804            }
805        }
806        out.push_str(YEAR);
807        Ok(out)
808    }
809
810    /// `to_currency(val, currency="XXX", ..., reading=False, prefer=None)`
811    /// with the reading/prefer kwargs live. Every component (minus, money,
812    /// the closing 元, each cents word, the cheque suffix) runs through
813    /// `zh_to_cap(select_text(c), capital)`; under capital that CAP-maps
814    /// (元 → 圆) and appends 正 when there are no cents — ZH_CN's CAP_map has
815    /// no ("正", "整") row, so the suffix stays 正 ("零圆正"). The leading
816    /// zero cents digit is *skipped* under capital (`not (int(cents[0]) == 0
817    /// and reading == "capital")`).
818    fn to_currency_kw(
819        &self,
820        val: &CurrencyValue,
821        currency: &str,
822        cents: bool,
823        separator: Option<&str>,
824        adjective: bool,
825        kw: &Kwargs,
826    ) -> Result<String> {
827        if !kw.only(&["reading", "prefer"]) {
828            return Err(N2WError::Fallback("kwargs".into()));
829        }
830        if kw.is_empty() {
831            return self.to_currency(val, currency, cents, separator, adjective);
832        }
833        let prefer = parse_prefer(kw)?;
834        let reading = parse_reading(kw);
835        let p = prefer.as_deref();
836        let capital = reading == Reading::Capital;
837
838        let (left, right, is_negative) =
839            crate::currency::parse_currency_parts(val, false, false, 100);
840        let cr = zh_cn_currency_form(currency).ok_or_else(|| {
841            N2WError::NotImplemented(format!(
842                "Currency code \"{}\" not implemented for \"{}\"",
843                currency,
844                self.lang_name()
845            ))
846        })?;
847
848        let minus_str = if is_negative {
849            self.negword().to_string()
850        } else {
851            String::new()
852        };
853        let money_str = self.kw_cardinal(&left, 2, reading, p)?;
854        let (mut out, cr_post) = if currency == "XXX" {
855            (String::new(), cr)
856        } else {
857            (cr.to_string(), "元")
858        };
859
860        let cents_num: u32 = right
861            .with_scale(0)
862            .as_bigint_and_exponent()
863            .0
864            .to_string()
865            .parse()
866            .unwrap_or(0);
867        let digit = |d: u32| -> String {
868            if d == 0 {
869                select_alt(&ZERO_ALTS, p).to_string()
870            } else {
871                ZH_DIGITS[d as usize].to_string()
872            }
873        };
874        let mut cents_parts: Vec<String> = Vec::new();
875        if cents_num > 0 {
876            let d0 = cents_num / 10;
877            let d1 = cents_num % 10;
878            if !(d0 == 0 && capital) {
879                cents_parts.push(digit(d0));
880            }
881            if d0 > 0 {
882                cents_parts.push("角".to_string());
883            }
884            if d1 > 0 {
885                cents_parts.push(digit(d1));
886                cents_parts.push("分".to_string());
887            }
888        }
889        // cheque_suffix "正" — only in capital readings with no cents.
890        let cheque = if cents_parts.is_empty() && capital {
891            "正".to_string()
892        } else {
893            String::new()
894        };
895
896        for c in std::iter::once(minus_str)
897            .chain(std::iter::once(money_str))
898            .chain(std::iter::once(cr_post.to_string()))
899            .chain(cents_parts)
900            .chain(std::iter::once(cheque))
901        {
902            let piece = if capital {
903                apply_cap_map(&c, &ZH_CN_CAP_MAP)
904            } else {
905                self.zh_to_cap(&c)
906            };
907            out.push_str(&piece);
908        }
909        Ok(out)
910    }
911
912    /// `to_cardinal(float/Decimal, stuff_zero, reading, prefer)`.
913    ///
914    /// A *whole* value passes base's `assert int(value) == value` and runs
915    /// the integer engine with the kwargs still live — identical to the int
916    /// path. A fractional value goes through `to_cardinal_float`, whose
917    /// inner `self.to_cardinal(part)` calls re-run `set_str_selection(False,
918    /// None)` / `self.stuff_zero = 2` for every part, resetting everything —
919    /// the only survivor is the caller's final `zh_to_cap(out, reading ==
920    /// "capital")`, applied over the already-assembled plain string (so
921    /// `12.34` capital reads "拾贰点叁肆": the inner strip already ate the 壹).
922    fn to_cardinal_float_kw(
923        &self,
924        value: &FloatValue,
925        precision_override: Option<u32>,
926        kw: &Kwargs,
927    ) -> Result<String> {
928        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
929            return Err(N2WError::Fallback("kwargs".into()));
930        }
931        if kw.is_empty() {
932            return self.cardinal_float_entry(value, precision_override);
933        }
934        let prefer = parse_prefer(kw)?;
935        let reading = parse_reading(kw);
936        if let Some(i) = value.as_whole_int() {
937            return self.kw_cardinal(&i, parse_stuff_zero(kw), reading, prefer.as_deref());
938        }
939        let out = self.to_cardinal_float(value, None)?;
940        Ok(match reading {
941            Reading::Capital => apply_cap_map(&out, &ZH_CN_CAP_MAP),
942            _ => self.zh_to_cap(&out),
943        })
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use bigdecimal::BigDecimal;
951    use std::str::FromStr;
952
953    /// `precision` is what the Python shim derives from `repr(value)`.
954    fn f(lang: &LangZh, value: f64, precision: u32) -> String {
955        lang.to_cardinal_float(&FloatValue::Float { value, precision }, None)
956            .unwrap()
957    }
958
959    fn d(lang: &LangZh, lit: &str, precision: u32) -> String {
960        lang.to_cardinal_float(
961            &FloatValue::Decimal {
962                value: BigDecimal::from_str(lit).unwrap(),
963                precision,
964            },
965            None,
966        )
967        .unwrap()
968    }
969
970    /// The float `to: "cardinal"` rows of the frozen `zh` corpus.
971    #[test]
972    fn corpus_float_rows() {
973        let l = LangZh::new();
974        assert_eq!(f(&l, 0.5, 1), "零点五");
975        assert_eq!(f(&l, 1.5, 1), "一点五");
976        assert_eq!(f(&l, 2.25, 2), "二点二五");
977        assert_eq!(f(&l, 3.14, 2), "三点一四");
978        assert_eq!(f(&l, 0.01, 2), "零点零一");
979        assert_eq!(f(&l, 0.1, 1), "零点一");
980        assert_eq!(f(&l, 0.99, 2), "零点九九");
981        assert_eq!(f(&l, 1.01, 2), "一点零一");
982        // 12 alone strips its leading 一 (十二); the pre part is rendered
983        // through ZH.to_cardinal, so the float form inherits the strip.
984        assert_eq!(f(&l, 12.34, 2), "十二点三四");
985        assert_eq!(f(&l, 99.99, 2), "九十九点九九");
986        assert_eq!(f(&l, 100.5, 1), "一百点五");
987        assert_eq!(f(&l, 1234.56, 2), "一千二百三十四点五六");
988        // int(-0.5) == 0 carries no minus, so base re-attaches negword.
989        assert_eq!(f(&l, -0.5, 1), "负零点五");
990        assert_eq!(f(&l, -1.5, 1), "负一点五");
991        // A negative keeps its 一: the leading 负 pushes 一十 off the string
992        // start, so neither the inner nor the outer zh_to_cap fires.
993        assert_eq!(f(&l, -12.34, 2), "负一十二点三四");
994        // f64-artefact cases: 1.005*1000 → 4.999999999999893 and
995        // 2.675*1000 → 674.9999999999998, both rescued by the < 0.01 rule.
996        assert_eq!(f(&l, 1.005, 3), "一点零零五");
997        assert_eq!(f(&l, 2.675, 3), "二点六七五");
998    }
999
1000    /// The `to: "cardinal_dec"` rows — Decimal stays exact (issue #603).
1001    #[test]
1002    fn corpus_decimal_rows() {
1003        let l = LangZh::new();
1004        assert_eq!(d(&l, "0.01", 2), "零点零一");
1005        // Trailing zero is preserved: post "10" padded to precision 2.
1006        assert_eq!(d(&l, "1.10", 2), "一点一零");
1007        assert_eq!(d(&l, "12.345", 3), "十二点三四五");
1008        // Trillion-scale Decimal keeps .99 exactly — no float cast.
1009        assert_eq!(
1010            d(&l, "98746251323029.99", 2),
1011            "九十八兆七千四百六十二亿五千一百三十二万三千零二十九点九九"
1012        );
1013        assert_eq!(d(&l, "0.001", 3), "零点零零一");
1014    }
1015
1016    /// Live-interpreter checks beyond the corpus.
1017    #[test]
1018    fn live_interpreter_extras() {
1019        let l = LangZh::new();
1020        // The same value as a float rounds to .984375 → floor → 98, matching
1021        // repr(-98746251323029.99) == '-98746251323029.98' (issue #603's bug,
1022        // reproduced): 负…零二十九点九八.
1023        assert_eq!(
1024            f(&l, -98746251323029.99, 2),
1025            "负九十八兆七千四百六十二亿五千一百三十二万三千零二十九点九八"
1026        );
1027        // precision= is a verified no-op on ZH's float path: float2tuple
1028        // recomputes self.precision from the value regardless.
1029        assert_eq!(
1030            l.to_cardinal_float(
1031                &FloatValue::Float {
1032                    value: 2.675,
1033                    precision: 3
1034                },
1035                Some(5),
1036            )
1037            .unwrap(),
1038            "二点六七五"
1039        );
1040    }
1041}
1042
1043/// `Num2Word_ZH_CN.CURRENCY_FORMS` — simplified names, single strings.
1044fn zh_cn_currency_form(code: &str) -> Option<&'static str> {
1045    Some(match code {
1046        "XXX" => "元",
1047        "CNY" => "人民币",
1048        "NTD" => "新台币",
1049        "HKD" => "港币",
1050        "MOP" => "澳门币",
1051        "SGD" => "新加坡元",
1052        "MYR" => "马来西亚令吉",
1053        "USD" => "美元",
1054        "EUR" => "欧元",
1055        "GBP" => "英镑",
1056        "JPY" => "日元",
1057        "CHF" => "瑞士法郎",
1058        "CAD" => "加元",
1059        "AUD" => "澳币",
1060        "NZD" => "纽西兰元",
1061        "THB" => "泰铢",
1062        "KRW" => "韩元",
1063        _ => return None,
1064    })
1065}
1066
1067/// Digits 0-9 as `select_text(self.cards[d])` yields them with
1068/// reading=False/prefer=None — first form of the ("零","〇") tuple, plain
1069/// glyphs elsewhere (identical in both scripts).
1070const ZH_DIGITS: [&str; 10] = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];