Skip to main content

num2words2_core/
lang_zh_tw.rs

1//! Port of `lang_ZH_TW.py` (Traditional Chinese, Taiwan).
2//!
3//! Registry check: `CONVERTER_CLASSES["zh_TW"]` → `lang_ZH_TW.Num2Word_ZH_TW`,
4//! so this file ports the class the key actually resolves to.
5//!
6//! Inheritance chain: `Num2Word_ZH_TW` → `Num2Word_ZH` → `Num2Word_Base`.
7//!
8//! Shape: **engine**. `Num2Word_ZH.setup` (overridden wholesale by
9//! `Num2Word_ZH_TW.setup`) defines `high_numwords`/`mid_numwords`/
10//! `low_numwords`, so `Num2Word_Base.__init__` builds `self.cards` and sets
11//! `MAXVAL = 1000 * 10**72 == 10**75`. `Num2Word_ZH.merge` folds the
12//! `splitnum`/`clean` tree.
13//!
14//! `Num2Word_ZH_TW.to_cardinal` does **not** call `super()`. It re-implements
15//! `Num2Word_Base.to_cardinal` inline (only to run `select_text` over
16//! `negword`) and then post-processes, bypassing `Num2Word_ZH.to_cardinal`
17//! entirely. The resulting order is load-bearing — see the `一十` note below:
18//!
19//! ```text
20//! out = title(negword_prefix + words)   # is_title is False → identity
21//! out = zh_to_cap(out, capital)         # BEFORE the space strip
22//! return out.replace(" ", "")
23//! ```
24//!
25//! # The tuple tables, and why this port stores plain strings
26//!
27//! Where `Num2Word_ZH` holds bare strings, `Num2Word_ZH_TW` re-declares every
28//! numword as a nested tuple of `((hanzi, alt...), (bopomofo...))` — e.g.
29//! `10` is `(("十", "拾"), ("ㄕˊ",))`. `select_text` resolves one at use time:
30//!
31//!   * `reading is True`  → `text[1]`, the 注音 (bopomofo) forms;
32//!   * otherwise          → `text[0]`, the hanzi forms;
33//!   * then, among the remaining alternatives, the single member of
34//!     `set(text) & set(self.prefer)` if exactly one matches, else `text[0]`.
35//!
36//! The Rust `Lang` trait has no `reading`/`prefer` parameters, so this port
37//! fixes them at their defaults (`reading=False`, `prefer=None`). Under those
38//! defaults `select_text` is always "first form of the first tuple", which is
39//! a pure function of the table — so the tables are pre-resolved to plain
40//! strings here and `select_text` collapses to the identity. The capital
41//! (`大寫`) forms "拾/玖/捌/…", the bopomofo readings, and `CAP_map` are
42//! therefore intentionally absent: no in-scope call can reach them. See
43//! `concerns` in the report.
44//!
45//! # Faithfully reproduced Python quirks
46//!
47//! 1. **`zh_to_cap` strips a leading `一` only from non-negative values.**
48//!    `splitnum`/`merge` render 10 as "一十", and the non-capital branch of
49//!    `zh_to_cap` fixes that up with
50//!    `elif out.startswith(one + ten): out = out[len(one):]`. But for a
51//!    negative value `to_cardinal` has already prefixed "負 ", so the string
52//!    no longer *starts with* "一十" and the strip never fires:
53//!      * `to_cardinal(12)`  == "十二"    (一 stripped)
54//!      * `to_cardinal(-12)` == "負一十二" (一 survives)
55//!    Confirmed against the frozen corpus via the float path, which routes
56//!    through `to_cardinal(-12)`: `cardinal(-12.34)` == "負一十二點三四".
57//!    The asymmetry is preserved exactly. See [`LangZhTw::zh_to_cap`].
58//! 2. **The strip is prefix-anchored, not positional**, so it only ever
59//!    touches a leading "一十…" — "一十萬" → "十萬" (100000), but "一千零一十"
60//!    (1010) keeps its interior "一十", and "一百一十" (110) is untouched
61//!    because it starts with "一百".
62//! 3. `Num2Word_ZH.merge`'s `stuff_zero == 2` arm re-tests
63//!    `len(str(lnum)) - len(str(rnum)) > 1`, which the enclosing `if` has
64//!    already established. The re-test is dead but harmless; kept verbatim in
65//!    [`LangZhTw::merge`] as a comment rather than as redundant code.
66//! 4. `merge` falls through to `return no_zero` when `stuff_zero` is not one
67//!    of 1/2/3. Unreachable at the default `stuff_zero == 2`.
68//!
69//! # Scope notes
70//!
71//! * `to_ordinal_num` never calls `verify_ordinal`, so negatives pass through
72//!   unguarded: `to_ordinal_num(-1)` == "第-1" (corpus-confirmed).
73//! * `to_ordinal` *does* call `verify_ordinal` → `TypeError` on negatives.
74//! * `to_year`'s ROC-era branch (`民國…年`) is gated on the `era=True`
75//!   keyword, which the Rust trait cannot express; the default `era=False`
76//!   path (`Num2Word_ZH.to_year`) is what is ported. See `concerns`.
77//!
78//! # The currency surface
79//!
80//! `Num2Word_ZH` overrides `to_currency` wholesale and reaches almost none of
81//! the `Num2Word_Base` currency machinery — no `pluralize`, no cent words, no
82//! `CURRENCY_PRECISION`, no adjectives, no separator. What it does instead is
83//! concatenate `[currency name] [負] [cardinal] 元 [角/分 subunits]`. See
84//! [`LangZhTw::to_currency`].
85//!
86//! `CURRENCY_FORMS` is likewise not the `(unit_forms, subunit_forms)` table
87//! the rest of the library keeps: sixteen of its entries are bare currency
88//! *name* strings and the seventeenth ("XXX") is a nested tuple. `to_cheque`
89//! is inherited from Base unchanged and destructures that table with
90//! `cr1, _cr2 = ...`, which on a `str` iterates characters — so it returns
91//! half a currency name for the 2-character entries and raises `ValueError`
92//! for the longer ones. Both behaviours are corpus-frozen. See
93//! [`Form`] and [`LangZhTw::to_cheque`].
94
95use crate::base::{
96    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, KwVal, Lang, N2WError,
97    Result,
98};
99use crate::currency::{parse_currency_parts, CurrencyValue};
100use crate::floatpath::{float2tuple, FloatValue};
101use bigdecimal::BigDecimal;
102use num_bigint::BigInt;
103use num_traits::{One, Signed, ToPrimitive, Zero};
104use std::collections::HashMap;
105
106/// `negword`, `select_text`-resolved: `(("負",), ("ㄈㄨˋ",))` → "負".
107const NEGWORD: &str = "負";
108/// `pointword`, resolved: `(("點",), ("ㄉㄧㄢˇ",))` → "點". Float path only.
109const POINTWORD: &str = "點";
110/// `ord_prefix`, resolved: `(("第",), ("ㄉㄧˋ",))` → "第".
111const ORD_PREFIX: &str = "第";
112/// `year`, resolved: `(("年",), ("ㄋㄧㄢˊ",))` → "年".
113const YEAR: &str = "年";
114/// `year_prefix`, resolved: `(("公元", "西元"), (...))` → "公元".
115///
116/// Two alternatives, but with `prefer=None` the `set(text) & set(prefer)`
117/// intersection is empty, so `select_text` falls back to `text[0]`.
118const YEAR_PREFIX: &str = "公元";
119/// `year_bce`, resolved: `(("前",), ("ㄑㄧㄢˊ",))` → "前".
120const YEAR_BCE: &str = "前";
121
122/// `Num2Word_ZH_TW.to_cardinal`'s `stuff_zero` default.
123///
124/// 2 == "discontinuous high numbers" — insert 零 between discontinuous
125/// magnitudes only when the right operand's decimal width is not a multiple
126/// of 4 (i.e. it does not sit flush on a 萬-group boundary). The trait has no
127/// parameter for it, so the default is baked in.
128const STUFF_ZERO: u8 = 2;
129
130/// `low_numwords[-1]` resolved: `(("零", "〇"), ("ㄌㄧㄥˊ",))` → "零".
131/// `merge` reaches for this as the infix zero.
132const ZERO_WORD: &str = "零";
133
134/// `Num2Word_ZH.CURRENCY_FLOATS` — the generic subunit words, 角 (tenths) and
135/// 分 (hundredths). Plain `str`s that `ZH_TW` does not re-declare as tuples, so
136/// `select_text` short-circuits on `strtype` and returns them unchanged.
137const CURRENCY_FLOATS: [&str; 2] = ["角", "分"];
138
139/// `CURRENCY_FORMS["XXX"]` — after `CURRENCY_FORMS_CHILD` replaces it — as both
140/// of its consumers resolve it. They arrive at the same string by different
141/// routes, which is why one constant serves both:
142///
143///   * `to_currency` → `select_text((("元",), ("ㄩㄢˊ",)))`: every item is a
144///     tuple, and `reading is not True`, so `text = text[0] == ("元",)`; that
145///     is still not a `str`, and `prefer` is empty, so `text = text[0]` → "元".
146///   * `to_cheque` → `cr1, _cr2 = (("元",), ("ㄩㄢˊ",))` binds `cr1 = ("元",)`,
147///     which `isinstance(cr1, tuple)` routes through `cr1[-1]` → "元".
148const XXX_UNIT: &str = "元";
149
150/// One `CURRENCY_FORMS` value.
151///
152/// Python's table is heterogeneous, and that is load-bearing. Sixteen entries
153/// are plain `str` currency *names* ("歐元") rather than the `(unit_forms,
154/// subunit_forms)` pair every other language stores, and
155/// `Num2Word_ZH_TW.CURRENCY_FORMS_CHILD` replaces "XXX" with the nested tuple
156/// `(("元",), ("ㄩㄢˊ",))`.
157///
158/// The two shapes diverge under `Num2Word_Base.to_cheque`'s `cr1, _cr2 = ...`
159/// unpack, so flattening them would change observable behaviour. The proof is
160/// the parent class: `Num2Word_ZH` leaves "XXX" as the *one-character* str
161/// "元", and `zh.to_cheque(1234.56, currency="XXX")` raises
162/// `ValueError: not enough values to unpack (expected 2, got 1)`, whereas
163/// ZH_TW's tuple unpacks cleanly and yields "一千二百三十四 AND 56/100 元".
164/// Both verified against the live interpreter.
165#[derive(Debug, Clone, Copy)]
166enum Form {
167    /// A `str` entry. `select_text` returns it unchanged.
168    Name(&'static str),
169    /// The nested `(("元",), ("ㄩㄢˊ",))` tuple — see [`XXX_UNIT`].
170    Nested,
171}
172
173/// `CURRENCY_FORMS` as `Num2Word_ZH_TW` sees it at runtime.
174///
175/// Built in two steps on purpose, mirroring Python: `Num2Word_ZH`'s class-body
176/// dict first, then the `CURRENCY_FORMS_CHILD` entry that
177/// `Num2Word_ZH_TW.__init__` copies over it. Pre-merging the two would hide
178/// that the child's sole contribution is a *shape* change to "XXX", which is
179/// exactly what makes `to_cheque("XXX")` work here and fail on the parent.
180///
181/// No `lang_EUR`-style mutation applies: `Num2Word_ZH` declares its own
182/// `CURRENCY_FORMS` in its class body, so it never shares the dict that
183/// `Num2Word_EN.__init__` rewrites in place — checked with
184/// `Num2Word_ZH.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` → False, and
185/// the live table carries none of EN's ~24 added codes. Conversely, ZH_TW's
186/// own `self.CURRENCY_FORMS = self.CURRENCY_FORMS.copy()` means its write to
187/// "XXX" cannot escape back into `Num2Word_ZH`.
188fn build_currency_forms() -> HashMap<&'static str, Form> {
189    // ---- Num2Word_ZH.CURRENCY_FORMS (class body) ----
190    let mut m: HashMap<&'static str, Form> = [
191        ("XXX", "元"), // Generic dollar — replaced by the child below.
192        ("CNY", "人民幣"),
193        ("NTD", "新台幣"),
194        ("HKD", "港幣"),
195        ("MOP", "澳門幣"),
196        ("SGD", "新加坡元"),
197        ("MYR", "馬來西亞令吉"),
198        ("USD", "美元"),
199        ("EUR", "歐元"),
200        ("GBP", "英鎊"),
201        ("JPY", "日元"),
202        ("CHF", "瑞士法郎"),
203        ("CAD", "加元"),
204        ("AUD", "澳幣"),
205        ("NZD", "紐西蘭元"),
206        ("THB", "泰銖"),
207        ("KRW", "韓元"),
208    ]
209    .into_iter()
210    .map(|(code, name)| (code, Form::Name(name)))
211    .collect();
212
213    // ---- Num2Word_ZH_TW.__init__: CURRENCY_FORMS_CHILD copied on top ----
214    // A single key, and only its shape changes: "元" → (("元",), ("ㄩㄢˊ",)).
215    m.insert("XXX", Form::Nested);
216
217    m
218}
219
220pub struct LangZhTw {
221    cards: Cards,
222    maxval: BigInt,
223    /// `select_text(self.cards[1])` — cached for `zh_to_cap`.
224    one: String,
225    /// `select_text(self.cards[10])` — cached for `zh_to_cap`.
226    ten: String,
227    /// `CURRENCY_FORMS`. Built once here, never per call.
228    currency_forms: HashMap<&'static str, Form>,
229}
230
231impl Default for LangZhTw {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl LangZhTw {
238    pub fn new() -> Self {
239        let mut cards = Cards::new();
240
241        // Num2Word_ZH_TW.setup: high_numwords, listed 10**4 → 10**72 and then
242        // `.reverse()`d. Num2Word_ZH.set_high_numwords zips the reversed list
243        // against range(4 * len(high), 0, -4) == range(72, 0, -4), i.e.
244        // high[0] ("不可說") ↦ 10**72 down to high[17] ("萬") ↦ 10**4.
245        // Both sequences are 18 long, so zip() truncates nothing.
246        const HIGH: [&str; 18] = [
247            "不可說",     // 10**72
248            "無量",       // 10**68
249            "不可思議",   // 10**64
250            "那由他",     // 10**60
251            "阿僧祇",     // 10**56
252            "恆河沙",     // 10**52
253            "極",         // 10**48
254            "載",         // 10**44
255            "正",         // 10**40
256            "澗",         // 10**36
257            "溝",         // 10**32
258            "穣",         // 10**28
259            "秭",         // 10**24
260            "垓",         // 10**20
261            "京",         // 10**16
262            "兆",         // 10**12
263            "億",         // 10**8
264            "萬",         // 10**4
265        ];
266        let max_exp: u32 = 4 * HIGH.len() as u32; // 72
267        for (i, word) in HIGH.iter().enumerate() {
268            let exp = max_exp - 4 * i as u32;
269            cards.insert(BigInt::from(10u8).pow(exp), *word);
270        }
271
272        // mid_numwords. Note ZH_TW drops the parent's (10, "十") entry —
273        // 10 arrives via low_numwords instead (see below). Same card either
274        // way; only the OrderedDict insertion order differs, and that order
275        // stays descending in both classes, which is all splitnum relies on.
276        set_mid_numwords(&mut cards, &[(1000, "千"), (100, "百")]);
277
278        // low_numwords: 11 entries → set_low_numwords assigns 10 down to 0.
279        set_low_numwords(
280            &mut cards,
281            &[
282                "十", // 10
283                "九", // 9
284                "八", // 8
285                "七", // 7
286                "六", // 6
287                "五", // 5
288                "四", // 4
289                "三", // 3
290                "二", // 2
291                "一", // 1
292                "零", // 0
293            ],
294        );
295
296        // MAXVAL = 1000 * list(self.cards.keys())[0] == 1000 * 10**72 == 10**75
297        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
298
299        let one = cards.get(&BigInt::one()).unwrap_or("").to_string();
300        let ten = cards.get(&BigInt::from(10)).unwrap_or("").to_string();
301
302        LangZhTw {
303            cards,
304            maxval,
305            one,
306            ten,
307            currency_forms: build_currency_forms(),
308        }
309    }
310
311    /// `Num2Word_Base.verify_ordinal`, negative arm only.
312    ///
313    /// The float arm (`errmsg_floatord`) is unreachable: input is an integer.
314    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
315        if value.is_negative() {
316            return Err(N2WError::Type(format!(
317                "Cannot treat negative num {} as ordinal.",
318                value
319            )));
320        }
321        Ok(())
322    }
323
324    /// `Num2Word_ZH.zh_to_cap`, non-capital branch.
325    ///
326    /// ```python
327    /// one, ten = self.select_text(self.cards[1]), self.select_text(self.cards[10])
328    /// if capital: ...            # unreachable here: reading != "capital"
329    /// elif out.startswith(one + ten):
330    ///     out = out[len(one):]
331    /// return out
332    /// ```
333    ///
334    /// `capital` is `reading == "capital"`, and `reading` is fixed at `False`
335    /// for every in-scope call, so only the `elif` can fire.
336    ///
337    /// Python's `out[len(one):]` slices by *character*. Here the strip only
338    /// runs once `one` is known to be a prefix of `value`, so `one.len()`
339    /// (bytes) is exactly the byte offset of that character boundary — no
340    /// char-index arithmetic needed, and no panic risk.
341    fn zh_to_cap(&self, value: &str) -> String {
342        let prefix = format!("{}{}", self.one, self.ten);
343        if value.starts_with(&prefix) {
344            return value[self.one.len()..].to_string();
345        }
346        value.to_string()
347    }
348
349    /// `select_text(self.cards[d])` for a single digit.
350    ///
351    /// `low_numwords` populates 0..=10, so 0..=9 always hit and Python's
352    /// `KeyError` on `self.cards[...]` is unreachable — same reasoning, and
353    /// same `unwrap_or("")`, as [`LangZhTw::to_year`]'s digit loop.
354    fn card_digit(&self, d: u32) -> String {
355        self.cards.get(&BigInt::from(d)).unwrap_or("").to_string()
356    }
357
358    /// `Num2Word_ZH.to_currency_float`.
359    ///
360    /// ```python
361    /// cents = "%02d" % value
362    /// out = []
363    /// if int(cents) > 0:
364    ///     if not (int(cents[0]) == 0 and reading == "capital"):
365    ///         out += [self.cards[int(cents[0])]]
366    ///     if int(cents[0]) > 0:
367    ///         out += [self.CURRENCY_FLOATS[0]]
368    ///     if int(cents[1]) > 0:
369    ///         out += [self.cards[int(cents[1])], self.CURRENCY_FLOATS[1]]
370    /// return out
371    /// ```
372    ///
373    /// 角 (tenths) is spoken only when the tens digit is non-zero, 分
374    /// (hundredths) only when the units digit is; but the tens digit itself is
375    /// always emitted, so a lone 零 survives as a placeholder. That is what
376    /// makes `0.01` read "零元零一分" while `0.5` stops at "零元五角".
377    ///
378    /// `value` is the subunit count `parse_currency_parts` yields, hence
379    /// non-negative — so `int("%02d" % value) == value` and the `int(cents) > 0`
380    /// guard is a plain positivity test.
381    fn to_currency_float(&self, value: &BigInt) -> Vec<String> {
382        let mut out: Vec<String> = Vec::new();
383        if !value.is_positive() {
384            return out;
385        }
386
387        // "%02d". Formatting `to_string()` rather than the BigInt directly
388        // keeps the zero-fill independent of BigInt's Display width handling.
389        // A hypothetical value >= 100 widens the string exactly as Python's
390        // %02d does, and cents[0]/cents[1] still read the first two chars.
391        let cents = format!("{:0>2}", value.to_string());
392        let digits: Vec<u32> = cents
393            .chars()
394            .map(|c| c.to_digit(10).expect("%02d of a non-negative int is digits"))
395            .collect();
396        let (d0, d1) = (digits[0], digits[1]);
397
398        // Python guards this with `not (int(cents[0]) == 0 and reading ==
399        // "capital")`. `reading` is fixed at False for every in-scope call, so
400        // the guard is always True and the 零 placeholder is unconditional.
401        out.push(self.card_digit(d0));
402        if d0 > 0 {
403            out.push(CURRENCY_FLOATS[0].to_string());
404        }
405        if d1 > 0 {
406            out.push(self.card_digit(d1));
407            out.push(CURRENCY_FLOATS[1].to_string());
408        }
409        out
410    }
411
412    /// `Num2Word_ZH_TW.to_cardinal(value, stuff_zero, reading, prefer)` with
413    /// the kwargs live. Same body as the plain [`Lang::to_cardinal`], but the
414    /// card table, negword, merge-zero and `zh_to_cap` inputs are all
415    /// `select_text`-resolved under (`reading`, `prefer`), and `stuff_zero`
416    /// rides through `merge`. Order preserved: `zh_to_cap` runs BEFORE the
417    /// space strip, and the capital arm performs **no** leading-一 strip
418    /// (11 → "壹拾壹"; reading=True strips ㄧ off "ㄧㄕˊㄧ" → "ㄕˊㄧ").
419    fn kw_cardinal(
420        &self,
421        value: &BigInt,
422        stuff_zero: i64,
423        reading: Reading,
424        prefer: Option<&[String]>,
425    ) -> Result<String> {
426        let eng = TwKwEngine {
427            base: self,
428            cards: resolved_cards(reading, prefer),
429            negword: select2(NEG_ALTS, reading, prefer),
430            stuff_zero,
431            zero_word: select2(TW_LOW[0], reading, prefer),
432        };
433        let out = default_to_cardinal(&eng, value)?;
434        let out = match reading {
435            Reading::Capital => apply_cap_map(&out, &TW_CAP_MAP),
436            _ => {
437                let one = select2(TW_LOW[1], reading, prefer);
438                let ten = select2(TW_LOW[10], reading, prefer);
439                let prefix = format!("{}{}", one, ten);
440                if out.starts_with(&prefix) {
441                    out[one.len()..].to_string()
442                } else {
443                    out
444                }
445            }
446        };
447        Ok(out.replace(' ', ""))
448    }
449
450    /// `Num2Word_ZH_TW.to_cardinal_float(value, reading, prefer)` with the
451    /// kwargs live — unlike ZH/ZH_HK, this override *forwards* `reading`/
452    /// `prefer` into every inner `to_cardinal` call, so the kwargs survive
453    /// the float path. `stuff_zero` does not: the inner calls leave it at
454    /// its default 2. Same float-cast/precision semantics as the plain
455    /// [`Lang::to_cardinal_float`] — see its docs.
456    fn kw_cardinal_float(
457        &self,
458        value: &FloatValue,
459        reading: Reading,
460        prefer: Option<&[String]>,
461    ) -> Result<String> {
462        let f: f64 = match value {
463            FloatValue::Float { value, .. } => *value,
464            FloatValue::Decimal { value, .. } => value
465                .to_f64()
466                .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?,
467        };
468        let precision = float_repr_precision(f);
469        let (pre, post) = float2tuple(&FloatValue::Float {
470            value: f,
471            precision,
472        });
473
474        let post_str = post.to_string();
475        let post_str = format!(
476            "{}{}",
477            "0".repeat((precision as usize).saturating_sub(post_str.len())),
478            post_str
479        );
480
481        let mut out: Vec<String> = vec![self.kw_cardinal(&pre, 2, reading, prefer)?];
482
483        let is_negative = match value {
484            FloatValue::Float { value, .. } => *value < 0.0,
485            FloatValue::Decimal { value, .. } => value.is_negative(),
486        };
487        if is_negative && pre.is_zero() {
488            // select_text(self.negword).strip() — no whitespace to strip in
489            // either script, kept for shape.
490            out.insert(0, select2(NEG_ALTS, reading, prefer).trim().to_string());
491        }
492
493        if precision > 0 {
494            out.push(select2(POINT_ALTS, reading, prefer).to_string());
495        }
496
497        for ch in post_str.chars().take(precision as usize) {
498            let d = ch
499                .to_digit(10)
500                .ok_or_else(|| N2WError::Value(format!("non-digit {:?} in fractional part", ch)))?;
501            out.push(self.kw_cardinal(&BigInt::from(d), 2, reading, prefer)?);
502        }
503
504        // out = zh_to_cap(" ".join(out), self.capital); return out.replace(" ","")
505        let joined = out.join(" ");
506        let capped = match reading {
507            Reading::Capital => apply_cap_map(&joined, &TW_CAP_MAP),
508            _ => {
509                let one = select2(TW_LOW[1], reading, prefer);
510                let ten = select2(TW_LOW[10], reading, prefer);
511                let prefix = format!("{}{}", one, ten);
512                if joined.starts_with(&prefix) {
513                    joined[one.len()..].to_string()
514                } else {
515                    joined
516                }
517            }
518        };
519        Ok(capped.replace(' ', ""))
520    }
521
522    /// `Num2Word_ZH.zh_to_cap(value, capital)` with the (`reading`, `prefer`)
523    /// selection live — the per-component form `to_currency`'s loop applies.
524    /// Capital arm: sequential `CAP_map` replacement. Otherwise: strip the
525    /// leading `one` of a `one + ten` prefix, both resolved under the call's
526    /// reading (`"一十"` for hanzi, `"ㄧㄕˊ"` for bopomofo).
527    fn kw_zh_to_cap(&self, value: &str, reading: Reading, prefer: Option<&[String]>) -> String {
528        match reading {
529            Reading::Capital => apply_cap_map(value, &TW_CAP_MAP),
530            _ => {
531                let one = select2(TW_LOW[1], reading, prefer);
532                let ten = select2(TW_LOW[10], reading, prefer);
533                let prefix = format!("{}{}", one, ten);
534                if value.starts_with(&prefix) {
535                    value[one.len()..].to_string()
536                } else {
537                    value.to_string()
538                }
539            }
540        }
541    }
542
543    /// `Num2Word_ZH.to_year(value, reading, prefer)` with the kwargs live —
544    /// the `era=False` path of `Num2Word_ZH_TW.to_year`. Digit-by-digit
545    /// through `select_text(self.cards[int(s)])`, so 2025 is 二零二五年 (or
546    /// ㄦˋㄌㄧㄥˊㄦˋㄨˇㄋㄧㄢˊ under `reading=True`); negative years get
547    /// 公元前, and — unreachable at the plain default but live here —
548    /// `reading == "capital"` prefixes 公元 on *positive* years too.
549    fn kw_year(&self, value: &BigInt, reading: Reading, prefer: Option<&[String]>) -> Result<String> {
550        let mut out = String::new();
551        if value.is_negative() {
552            out.push_str(select2(YEAR_PREFIX_ALTS, reading, prefer));
553            out.push_str(select2(YEAR_BCE_ALTS, reading, prefer));
554        } else if reading == Reading::Capital {
555            out.push_str(select2(YEAR_PREFIX_ALTS, reading, prefer));
556        }
557        for ch in value.abs().to_string().chars() {
558            let d = ch.to_digit(10).expect("BigInt::to_string emits digits") as usize;
559            out.push_str(select2(TW_LOW[d], reading, prefer));
560        }
561        out.push_str(select2(YEAR_W_ALTS, reading, prefer));
562        Ok(out)
563    }
564
565    /// `Num2Word_ZH.to_currency_float(value, reading, prefer)` with the
566    /// kwargs live. Same shape as [`LangZhTw::to_currency_float`], but the
567    /// digit words come from the (reading/prefer)-selected card table and the
568    /// capital guard `not (int(cents[0]) == 0 and reading == "capital")` is
569    /// real: capital mode drops the lone 零 placeholder before 分.
570    fn kw_currency_float(
571        &self,
572        value: &BigInt,
573        reading: Reading,
574        prefer: Option<&[String]>,
575    ) -> Vec<String> {
576        let mut out: Vec<String> = Vec::new();
577        if !value.is_positive() {
578            return out;
579        }
580        let cents = format!("{:0>2}", value.to_string());
581        let digits: Vec<u32> = cents
582            .chars()
583            .map(|c| c.to_digit(10).expect("%02d of a non-negative int is digits"))
584            .collect();
585        let (d0, d1) = (digits[0], digits[1]);
586
587        if !(d0 == 0 && reading == Reading::Capital) {
588            out.push(select2(TW_LOW[d0 as usize], reading, prefer).to_string());
589        }
590        if d0 > 0 {
591            out.push(CURRENCY_FLOATS[0].to_string());
592        }
593        if d1 > 0 {
594            out.push(select2(TW_LOW[d1 as usize], reading, prefer).to_string());
595            out.push(CURRENCY_FLOATS[1].to_string());
596        }
597        out
598    }
599}
600
601/// `len(str(n))` for a non-negative `n`: its decimal digit count.
602///
603/// `merge` compares magnitudes by decimal width, so this has to match
604/// Python's `len(str(...))` exactly. `BigInt::to_string` emits bare ASCII
605/// digits for non-negative values (no separators, no sign), so byte length is
606/// the digit count. Every `lnum`/`rnum` reaching `merge` is a card value or a
607/// sum/product of card values, hence non-negative — `to_cardinal` strips the
608/// sign before `splitnum` ever runs.
609fn dec_len(n: &BigInt) -> usize {
610    n.to_string().len()
611}
612
613/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the precision
614/// `base.float2tuple`'s float branch derives on every call.
615///
616/// `Num2Word_ZH_TW.to_cardinal_float` recomputes precision from
617/// `float(value)` even for Decimal input, so this is the one true precision
618/// for both arms — not the value's stored precision.
619///
620/// Counting `format!("{}", f)`'s fractional digits (what
621/// `floatpath::float_repr_precision` does) is NOT enough here: Python's
622/// `repr` switches to scientific notation outside `1e-4 ..= <1e16`, and
623/// `Decimal('1e+20').as_tuple().exponent` is **+20**, so
624/// `float(Decimal("99999999999999999999.99")) == 1e20` carries precision 20
625/// — "一垓點" plus twenty 零 — where Rust's always-positional `{}` would give
626/// 0. Decimal input is the reachable route: non-integral floats are all
627/// `< 2**53 < 1e16`, but a Decimal's float cast can land anywhere.
628///
629/// Both `{:e}` and Python's `repr` emit the (unique) shortest round-trip
630/// digits, so the mantissa/exponent split here reproduces repr exactly:
631///
632///   * scientific form (`exp10 < -4 || exp10 >= 16`): Decimal exponent is
633///     `exp10 - (nd - 1)` — e.g. `'1.5e+20'` → 19, `'1e-07'` → 7;
634///   * positional integral (`nd - 1 <= exp10`): repr appends `'.0'` → 1
635///     (e.g. `repr(5e15) == '5000000000000000.0'`);
636///   * positional fractional: `nd - 1 - exp10` digits after the point.
637fn float_repr_precision(f: f64) -> u32 {
638    if f == 0.0 {
639        return 1; // repr(0.0) == '0.0' (and '-0.0') → exponent -1.
640    }
641    let sci = format!("{:e}", f.abs()); // "d[.ddd]e<exp>", shortest digits
642    let (mant, exp) = sci.split_once('e').expect("{:e} always carries an exponent");
643    let exp10: i64 = exp.parse().expect("{:e} exponent is an integer");
644    let nd = (mant.len() - usize::from(mant.contains('.'))) as i64;
645    if exp10 < -4 || exp10 >= 16 {
646        (exp10 - (nd - 1)).unsigned_abs() as u32
647    } else if nd - 1 <= exp10 {
648        1
649    } else {
650        (nd - 1 - exp10) as u32
651    }
652}
653
654/// `Num2Word_ZH.merge`, verbatim, with the two per-call inputs live:
655/// `self.stuff_zero` (the `to_cardinal` kwarg) and
656/// `select_text(self.low_numwords[-1])` (the reading/prefer-selected zero).
657fn zh_merge(
658    l: (&str, &BigInt),
659    r: (&str, &BigInt),
660    stuff_zero: i64,
661    zero_word: &str,
662) -> (String, BigInt) {
663    let (ltext, lnum) = l;
664    let (rtext, rnum) = r;
665
666    // Ignore lpair when lnum is 1 and rnum is a single digit: 一 + 二 → 二.
667    if lnum.is_one() && rnum < &BigInt::from(10) {
668        return (rtext.to_string(), rnum.clone());
669    }
670
671    let sum = lnum + rnum;
672    let with_zero = (format!("{}{}{}", ltext, zero_word, rtext), sum.clone());
673    let no_zero = (format!("{}{}", ltext, rtext), sum);
674
675    let (llen, rlen) = (dec_len(lnum), dec_len(rnum));
676
677    if llen as isize - rlen as isize > 1 {
678        // Discontinuous magnitudes — decide whether 零 is spoken.
679        // http://www.hkame.org.hk/uploaded_files/magazine/15/271.pdf
680        match stuff_zero {
681            // 凡「零」必讀 — every discontinuity gets a 零.
682            1 => with_zero,
683            // Discontinuous *high* numbers only. Python re-tests
684            // `llen - rlen > 1` here; the enclosing `if` already
685            // guarantees it, so only the `% 4` test can decide.
686            2 => {
687                if rlen % 4 != 0 {
688                    with_zero
689                } else {
690                    no_zero
691                }
692            }
693            // 凡「零」不讀 — never.
694            3 => no_zero,
695            // Python's if/elif chain has no else: any other stuff_zero
696            // (None, 4, "2", ...) falls through to the trailing
697            // `return no_zero` — `None == 1` etc. are all simply False.
698            _ => no_zero,
699        }
700    } else if rnum > lnum {
701        // Multiplicative: 二 × 十 → 二十, 一百 × 萬 → 一百萬.
702        (format!("{}{}", ltext, rtext), lnum * rnum)
703    } else {
704        no_zero
705    }
706}
707
708// ---- grammatical kwargs (reading / prefer / stuff_zero / counter / era) ----
709
710/// One tuple-table entry as `lang_ZH_TW.py` declares it:
711/// `((hanzi, alt...), (bopomofo...))`. `select_text` picks side 1 when
712/// `reading is True`, side 0 otherwise, then intersects with `prefer`.
713type Alts = (&'static [&'static str], &'static [&'static str]);
714
715/// `low_numwords` indexed by value 0..=10.
716const TW_LOW: [Alts; 11] = [
717    (&["零", "〇"], &["ㄌㄧㄥˊ"]), // 0
718    (&["一", "壹"], &["ㄧ"]),      // 1
719    (&["二", "貳"], &["ㄦˋ"]),     // 2
720    (&["三", "參"], &["ㄙㄢ"]),    // 3
721    (&["四", "肆"], &["ㄙˋ"]),     // 4
722    (&["五", "伍"], &["ㄨˇ"]),     // 5
723    (&["六", "陸"], &["ㄌㄧㄡˋ"]), // 6
724    (&["七", "柒"], &["ㄑㄧ"]),    // 7
725    (&["八", "捌"], &["ㄅㄚ"]),    // 8
726    (&["九", "玖"], &["ㄐㄧㄡˇ"]), // 9
727    (&["十", "拾"], &["ㄕˊ"]),     // 10
728];
729
730const TW_MID: [(i64, Alts); 2] = [
731    (1000, (&["千"], &["ㄑㄧㄢ"])),
732    (100, (&["百"], &["ㄅㄞˇ"])),
733];
734
735/// `high_numwords`, keyed by the power of ten `set_high_numwords` assigns.
736const TW_HIGH: [(u32, Alts); 18] = [
737    (72, (&["不可說"], &["ㄅㄨˋㄎㄜˇㄕㄨㄛ"])),
738    (68, (&["無量"], &["ㄨˊㄌㄧㄤˋ"])),
739    (64, (&["不可思議"], &["ㄅㄨˋㄎㄜˇㄙㄧˋ"])),
740    (60, (&["那由他"], &["ㄋㄚˋㄧㄡˊㄊㄚ"])),
741    (56, (&["阿僧祇"], &["ㄚㄙㄥㄑㄧˊ"])),
742    (52, (&["恆河沙"], &["ㄏㄥˊㄏㄜˊㄕㄚ"])),
743    (48, (&["極"], &["ㄐㄧˊ"])),
744    (44, (&["載"], &["ㄗㄞˇ"])),
745    (40, (&["正"], &["ㄓㄥˋ"])),
746    (36, (&["澗"], &["ㄐㄧㄢˋ"])),
747    (32, (&["溝"], &["ㄍㄡ"])),
748    (28, (&["穣"], &["ㄖㄤ"])),
749    (24, (&["秭"], &["ㄗˇ"])),
750    (20, (&["垓"], &["ㄍㄞ"])),
751    (16, (&["京"], &["ㄐㄧㄥ"])),
752    (12, (&["兆"], &["ㄓㄠˋ"])),
753    (8, (&["億"], &["ㄧˋ"])),
754    (4, (&["萬"], &["ㄨㄢˋ"])),
755];
756
757const NEG_ALTS: Alts = (&["負"], &["ㄈㄨˋ"]);
758const POINT_ALTS: Alts = (&["點"], &["ㄉㄧㄢˇ"]);
759const ORD_ALTS: Alts = (&["第"], &["ㄉㄧˋ"]);
760const YEAR_W_ALTS: Alts = (&["年"], &["ㄋㄧㄢˊ"]);
761/// Note the bopomofo 公元 carries an interior space that survives to the
762/// output — `to_year` joins without stripping ("ㄍㄨㄥ ㄩㄢˊㄑㄧㄢˊ…").
763const YEAR_PREFIX_ALTS: Alts = (&["公元", "西元"], &["ㄍㄨㄥ ㄩㄢˊ", "ㄒㄧㄩㄢˊ"]);
764const YEAR_BCE_ALTS: Alts = (&["前"], &["ㄑㄧㄢˊ"]);
765const ROC_ERA_ALTS: Alts = (&["民國"], &["ㄇㄧㄣˊㄍㄨㄛˊ"]);
766/// The ROC first-year word `(("元",), ("ㄩㄢˊ",))` — also the shape of
767/// `CURRENCY_FORMS["XXX"]`, which `to_currency`'s loop selects the same way.
768const YUAN_ALTS: Alts = (&["元"], &["ㄩㄢˊ"]);
769const CHEQUE_ALTS: Alts = (&["正"], &["ㄓㄥˋ"]);
770
771/// `Num2Word_ZH.CAP_map`, inherited unchanged by ZH_TW (traditional
772/// capitals, including the `("正", "整")` row ZH_CN drops).
773const TW_CAP_MAP: [(&str, &str); 14] = [
774    ("千", "仟"),
775    ("百", "佰"),
776    ("十", "拾"),
777    ("九", "玖"),
778    ("八", "捌"),
779    ("七", "柒"),
780    ("六", "陸"),
781    ("五", "伍"),
782    ("四", "肆"),
783    ("三", "叁"),
784    ("二", "貳"),
785    ("一", "壹"),
786    ("元", "圓"),
787    ("正", "整"),
788];
789
790/// `Num2Word_ZH_TW.counters` — the 注音 readings `to_ordinal`/`to_ordinal_num`
791/// swap in when `reading is True`.
792fn tw_counter(counter: &str) -> Option<&'static str> {
793    match counter {
794        "個" => Some("˙ㄍㄜ"),
795        "名" => Some("ㄇㄧㄥˊ"),
796        "位" => Some("ㄨㄟˋ"),
797        _ => None,
798    }
799}
800
801/// The three states `reading` can put `select_text`/`zh_to_cap` in: Python
802/// tests `reading is True` (only the bool `True`) and `reading == "capital"`;
803/// everything else behaves like the default `False`.
804#[derive(Clone, Copy, PartialEq)]
805enum Reading {
806    Plain,
807    True,
808    Capital,
809}
810
811fn parse_reading(kw: &Kwargs) -> Reading {
812    match kw.get("reading") {
813        Some(KwVal::Bool(true)) => Reading::True,
814        Some(KwVal::Str(s)) if s == "capital" => Reading::Capital,
815        _ => Reading::Plain,
816    }
817}
818
819/// `prefer` as the item set `select_text` intersects with. A list keeps its
820/// members, a str iterates characters; a non-iterable raises TypeError inside
821/// Python's `set()` — NotImplemented lets the dispatcher fall back to the
822/// original, which owns that raise.
823fn parse_prefer(kw: &Kwargs) -> Result<Option<Vec<String>>> {
824    match kw.get("prefer") {
825        None | Some(KwVal::None) => Ok(None),
826        Some(KwVal::List(l)) => Ok(Some(l.clone())),
827        Some(KwVal::Str(s)) => Ok(Some(s.chars().map(|c| c.to_string()).collect())),
828        Some(_) => Err(N2WError::Fallback("kwargs".into())),
829    }
830}
831
832/// `stuff_zero`, defaulting to 2. Bools are Python ints; any other type
833/// compares unequal to 1/2/3 and takes `zh_merge`'s fall-through arm (0).
834fn parse_stuff_zero(kw: &Kwargs) -> i64 {
835    match kw.get("stuff_zero") {
836        None => 2,
837        Some(KwVal::Int(i)) => *i,
838        Some(KwVal::Bool(b)) => *b as i64,
839        Some(_) => 0,
840    }
841}
842
843/// `counter`: only a `str` short-circuits `select_text`; anything else hits
844/// `len(text)` there and raises TypeError — delegated back to Python.
845fn parse_counter(kw: &Kwargs) -> Result<&str> {
846    match kw.get("counter") {
847        None => Ok(""),
848        Some(KwVal::Str(s)) => Ok(s),
849        Some(_) => Err(N2WError::Fallback("kwargs".into())),
850    }
851}
852
853/// Python truthiness for the `era` kwarg (`if not era:`).
854fn kw_truthy(v: Option<&KwVal>) -> bool {
855    match v {
856        None | Some(KwVal::None) => false,
857        Some(KwVal::Bool(b)) => *b,
858        Some(KwVal::Int(i)) => *i != 0,
859        Some(KwVal::Str(s)) => !s.is_empty(),
860        Some(KwVal::List(l)) => !l.is_empty(),
861    }
862}
863
864/// The prefer half of `select_text`: the single member of
865/// `set(alts) & set(prefer)` when exactly one matches, else the first.
866fn select_alt<'a>(alts: &[&'a str], prefer: Option<&[String]>) -> &'a str {
867    if let Some(p) = prefer {
868        let hits: Vec<&str> = alts
869            .iter()
870            .copied()
871            .filter(|a| p.iter().any(|x| x == a))
872            .collect();
873        if hits.len() == 1 {
874            return hits[0];
875        }
876    }
877    alts[0]
878}
879
880/// Full `select_text` over a ZH_TW nested-tuple entry: side 1 (注音) when
881/// `reading is True`, side 0 (hanzi + capital alternates) otherwise —
882/// `"capital"` is *not* `True`, so it reads side 0 like the default — then
883/// the prefer intersection.
884fn select2(alts: Alts, reading: Reading, prefer: Option<&[String]>) -> &'static str {
885    let side = if reading == Reading::True { alts.1 } else { alts.0 };
886    select_alt(side, prefer)
887}
888
889/// `zh_to_cap`'s capital arm: sequential `str.replace` over `CAP_map`.
890fn apply_cap_map(value: &str, map: &[(&str, &str)]) -> String {
891    let mut out = value.to_string();
892    for (plain, cap) in map {
893        if out.contains(plain) {
894            out = out.replace(plain, cap);
895        }
896    }
897    out
898}
899
900/// The card table as `select_text` resolves it under a live
901/// (`reading`, `prefer`) pair — what `splitnum` reads on the Python side.
902fn resolved_cards(reading: Reading, prefer: Option<&[String]>) -> Cards {
903    let mut cards = Cards::new();
904    for (exp, alts) in TW_HIGH.iter() {
905        cards.insert(BigInt::from(10u8).pow(*exp), select2(*alts, reading, prefer));
906    }
907    for (v, alts) in TW_MID.iter() {
908        cards.insert(BigInt::from(*v), select2(*alts, reading, prefer));
909    }
910    for (d, alts) in TW_LOW.iter().enumerate() {
911        cards.insert(BigInt::from(d), select2(*alts, reading, prefer));
912    }
913    cards
914}
915
916/// A per-call view of the engine with the `to_cardinal` kwargs live.
917struct TwKwEngine<'a> {
918    base: &'a LangZhTw,
919    cards: Cards,
920    negword: &'static str,
921    stuff_zero: i64,
922    zero_word: &'static str,
923}
924
925impl Lang for TwKwEngine<'_> {
926    fn cards(&self) -> &Cards {
927        &self.cards
928    }
929    fn maxval(&self) -> &BigInt {
930        self.base.maxval()
931    }
932    fn negword(&self) -> &str {
933        self.negword
934    }
935    fn pointword(&self) -> &str {
936        self.base.pointword()
937    }
938    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
939        zh_merge(l, r, self.stuff_zero, self.zero_word)
940    }
941}
942
943/// Python's `str(<float>)` for a finite value — fills the `%s` of the
944/// TypeError format strings (`errmsg_floatord`/`errmsg_negord`/
945/// `errmsg_floatyear`). The corpora record only the type; the message
946/// mirrors CPython's spelling.
947fn py_float_str(value: f64, precision: u32) -> String {
948    let a = value.abs();
949    let mag = if a != 0.0 && (a >= 1e16 || a < 1e-4) {
950        let s = format!("{:e}", a);
951        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
952        let exp: i32 = exp.parse().expect("exponent is a small int");
953        format!(
954            "{}e{}{:02}",
955            mant,
956            if exp < 0 { '-' } else { '+' },
957            exp.abs()
958        )
959    } else {
960        format!("{:.*}", precision as usize, a)
961    };
962    if value.is_sign_negative() {
963        format!("-{}", mag)
964    } else {
965        mag
966    }
967}
968
969/// `str(value)` for either FloatValue arm (Decimal's `%s` is its `str`).
970fn py_value_str(value: &FloatValue) -> String {
971    match value {
972        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
973        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
974    }
975}
976
977impl Lang for LangZhTw {
978    /// This language's own `to_currency(currency=...)` default,
979    /// read from the live Python signature. Only 44 of 156 use EUR.
980    fn default_currency(&self) -> &str {
981        "XXX"
982    }
983
984    /// This language's own `to_currency(separator=...)` default,
985    /// read from the live Python signature. Base's is ",", but only
986    /// 36 of 149 languages actually use it — most default to " " or a
987    /// conjunction, so inheriting Base's comma silently corrupts them.
988    fn default_separator(&self) -> &str {
989        ""
990    }
991
992    fn cards(&self) -> &Cards {
993        &self.cards
994    }
995
996    fn maxval(&self) -> &BigInt {
997        &self.maxval
998    }
999
1000    fn negword(&self) -> &str {
1001        // base's default_to_cardinal does `format!("{} ", negword().trim())`,
1002        // matching Python's `"%s " % select_text(self.negword).strip()`.
1003        NEGWORD
1004    }
1005
1006    fn pointword(&self) -> &str {
1007        POINTWORD
1008    }
1009
1010    // is_title stays False (Num2Word_Base.__init__ sets it; neither ZH nor
1011    // ZH_TW touches it), so `title` is the identity and `exclude_title`
1012    // — `[negword, pointword]` in Python — is never consulted.
1013
1014    /// `Num2Word_ZH.merge`.
1015    ///
1016    /// ```python
1017    /// ltext, rtext = self.select_text(ltext), self.select_text(rtext)
1018    /// if lnum == 1 and rnum < 10:
1019    ///     return (rtext, rnum)
1020    /// with_zero = ("%s%s%s" % (ltext, select_text(low_numwords[-1]), rtext), lnum + rnum)
1021    /// no_zero   = ("%s%s"   % (ltext, rtext),                              lnum + rnum)
1022    /// if len(str(lnum)) - len(str(rnum)) > 1:
1023    ///     if self.stuff_zero == 1: return with_zero
1024    ///     elif self.stuff_zero == 2:
1025    ///         if len(str(lnum)) - len(str(rnum)) > 1 and len(str(rnum)) % 4 != 0:
1026    ///             return with_zero
1027    ///         return no_zero
1028    ///     elif self.stuff_zero == 3: return no_zero
1029    /// elif rnum > lnum:
1030    ///     return ("%s%s" % (ltext, rtext), lnum * rnum)
1031    /// return no_zero
1032    /// ```
1033    ///
1034    /// The `select_text` calls are no-ops here: the cards are pre-resolved
1035    /// strings, and Python's `select_text` short-circuits on `strtype` — which
1036    /// is also what happens on the Python side from the second merge onward,
1037    /// since `merge` returns a plain `str`.
1038    ///
1039    /// The body lives in [`zh_merge`], shared with the kwargs engine, which
1040    /// carries the live `stuff_zero` and the (reading/prefer)-selected zero.
1041    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
1042        zh_merge(l, r, STUFF_ZERO as i64, ZERO_WORD)
1043    }
1044
1045    /// `Num2Word_ZH_TW.to_cardinal`.
1046    ///
1047    /// The `assert int(value) == value` guard and its `to_cardinal_float`
1048    /// fallback are elided: input is always integral here.
1049    ///
1050    /// `default_to_cardinal` covers the shared body exactly — the "負 " prefix
1051    /// (`negword` + a trailing space), the `value >= MAXVAL` OverflowError,
1052    /// `splitnum` → `clean` → `merge`, and the no-op `title`.
1053    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1054        let out = default_to_cardinal(self, value)?;
1055        // zh_to_cap runs BEFORE the space strip — the reason the leading 一 of
1056        // e.g. "負 一十二" survives. See the module docs.
1057        let out = self.zh_to_cap(&out);
1058        Ok(out.replace(' ', ""))
1059    }
1060
1061    /// `Num2Word_ZH_TW.to_ordinal` → `Num2Word_ZH.to_ordinal`.
1062    ///
1063    /// The `counter` keyword defaults to `""`; `select_text("")` returns `""`
1064    /// (it short-circuits on `strtype`), so the suffix is empty and the
1065    /// `counters` lookup — plus its `NotImplementedError` — is unreachable
1066    /// without `reading=True`.
1067    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1068        self.verify_ordinal(value)?;
1069        let base = self.to_cardinal(value)?;
1070        Ok(format!("{}{}", ORD_PREFIX, base))
1071    }
1072
1073    /// `Num2Word_ZH_TW.to_ordinal_num` → `Num2Word_ZH.to_ordinal_num`.
1074    ///
1075    /// `"%s%s%s" % (select_text(ord_prefix), value, select_text(counter))`.
1076    /// No `verify_ordinal`, so negatives render literally: -1 → "第-1".
1077    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1078        Ok(format!("{}{}", ORD_PREFIX, value))
1079    }
1080
1081    /// `Num2Word_ZH_TW.to_year` with `era=False` → `Num2Word_ZH.to_year`.
1082    ///
1083    /// ```python
1084    /// out = []
1085    /// if value < 0:                     out += [self.year_prefix, self.year_bce]
1086    /// elif reading == "capital":        out += [self.year_prefix]
1087    /// out += [self.cards[int(s)] for s in str(abs(int(value)))]
1088    /// out += [self.year]
1089    /// return "".join(self.select_text(s) for s in out)
1090    /// ```
1091    ///
1092    /// Digit-by-digit, not cardinal: 2025 → 二零二五年, and 1010 → 一零一零年
1093    /// (`cards[0]` is 零, so no 〇 and no merge-driven 零-elision).
1094    /// Negative → 公元前…年. The `reading == "capital"` arm cannot fire.
1095    ///
1096    /// `errmsg_floatyear`'s TypeError is unreachable (integral input), and
1097    /// `cards[0..=9]` are all populated by `low_numwords`, so the digit lookup
1098    /// cannot KeyError.
1099    fn to_year(&self, value: &BigInt) -> Result<String> {
1100        let mut out = String::new();
1101        if value.is_negative() {
1102            out.push_str(YEAR_PREFIX);
1103            out.push_str(YEAR_BCE);
1104        }
1105        // str(abs(int(value))) — for 0 this is "0", giving "零年".
1106        for ch in value.abs().to_string().chars() {
1107            let digit = BigInt::from(ch.to_digit(10).expect("BigInt::to_string emits digits"));
1108            out.push_str(self.cards.get(&digit).unwrap_or(""));
1109        }
1110        out.push_str(YEAR);
1111        Ok(out)
1112    }
1113
1114    /// `Num2Word_ZH_TW.to_cardinal_float`.
1115    ///
1116    /// ```python
1117    /// def to_cardinal_float(self, value, reading=False, prefer=None):
1118    ///     ...                                   # errmsg_nonnum guard, unreachable here
1119    ///     pre, post = self.float2tuple(float(value))
1120    ///     post = str(post)
1121    ///     post = "0" * (self.precision - len(post)) + post
1122    ///     out = [self.to_cardinal(pre, reading=reading, prefer=prefer)]
1123    ///     if value < 0 and pre == 0:
1124    ///         out = [self.select_text(self.negword).strip()] + out
1125    ///     if self.precision:
1126    ///         out.append(self.select_text(self.title(self.pointword)))
1127    ///     for i in range(self.precision):
1128    ///         out.append(to_s(self.to_cardinal(int(post[i]), reading, prefer)))
1129    ///     out = self.zh_to_cap(" ".join(out), self.capital)
1130    ///     return out.replace(" ", "")
1131    /// ```
1132    ///
1133    /// Two departures from `Num2Word_Base.to_cardinal_float`, both load-bearing:
1134    ///
1135    /// 1. **It always casts `float(value)`** — even a `Decimal`. Base preserves
1136    ///    Decimal precision via `isinstance(value, Decimal)`; ZH_TW does not, so
1137    ///    both `FloatValue` arms funnel through the *float* branch of
1138    ///    `float2tuple`, and precision is recomputed from the f64's repr rather
1139    ///    than kept from the Decimal. Hence `Decimal("1.10")` → "一點一" (the
1140    ///    trailing zero is dropped, precision 2 → 1) and
1141    ///    `Decimal("98746251323029.99")` → "…三千零二十九點九八": the float cast
1142    ///    rounds `.99` → `.98` at trillion scale (issue #603's trap), reproduced
1143    ///    not repaired. Both corpus-confirmed.
1144    /// 2. **It re-runs `zh_to_cap` on the space-joined string and then strips
1145    ///    every space** (Base joins with " " and stops). The inner
1146    ///    `self.to_cardinal(pre)` has already stripped any leading 一 of "一十…",
1147    ///    and the negative prefix is "負", so the outer `zh_to_cap` never fires
1148    ///    in practice — but it is reproduced verbatim.
1149    ///
1150    /// `reading`/`prefer`/`capital` are fixed at their defaults (`False`,
1151    /// `None`, `False`), so `select_text` collapses to identity and `zh_to_cap`
1152    /// takes its non-capital branch, exactly as in the integer path.
1153    ///
1154    /// `precision_override` (the `precision=` kwarg) is **ignored**: ZH_TW's
1155    /// override drops the parameter Base accepts, so `num2words(x,
1156    /// lang="zh_TW", precision=N)` has no effect — verified live (`precision=4`
1157    /// still renders all five digits of 1.23456).
1158    fn to_cardinal_float(
1159        &self,
1160        value: &FloatValue,
1161        _precision_override: Option<u32>,
1162    ) -> Result<String> {
1163        // pre, post = self.float2tuple(float(value)) — float(value) collapses
1164        // both arms to an f64 before float2tuple ever branches.
1165        let f: f64 = match value {
1166            FloatValue::Float { value, .. } => *value,
1167            FloatValue::Decimal { value, .. } => value
1168                .to_f64()
1169                .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?,
1170        };
1171
1172        // float2tuple's float branch sets self.precision from the f64's repr;
1173        // reuse it (rather than the FloatValue's stored precision, which for a
1174        // Decimal is its own scale and would keep the trailing zero).
1175        let precision = float_repr_precision(f);
1176        let (pre, post) = float2tuple(&FloatValue::Float {
1177            value: f,
1178            precision,
1179        });
1180
1181        // post = str(post); post = "0"*(precision - len(post)) + post
1182        let post_str = post.to_string();
1183        let post_str = format!(
1184            "{}{}",
1185            "0".repeat((precision as usize).saturating_sub(post_str.len())),
1186            post_str
1187        );
1188
1189        // out = [self.to_cardinal(pre)] — the full ZH_TW cardinal, itself
1190        // zh_to_cap'd and space-stripped (so "十二", never "一十二", for +12).
1191        let mut out: Vec<String> = vec![self.to_cardinal(&pre)?];
1192
1193        // if value < 0 and pre == 0: prepend the bare negword "負". The sign
1194        // test reads the ORIGINAL value, not the toward-zero-truncated pre —
1195        // for -0.5 that is the only place the minus can come from.
1196        let is_negative = match value {
1197            FloatValue::Float { value, .. } => *value < 0.0,
1198            FloatValue::Decimal { value, .. } => value.is_negative(),
1199        };
1200        if is_negative && pre.is_zero() {
1201            out.insert(0, NEGWORD.to_string());
1202        }
1203
1204        // if self.precision: out.append(select_text(title(pointword)))
1205        if precision > 0 {
1206            out.push(POINTWORD.to_string());
1207        }
1208
1209        // for i in range(precision): out.append(to_cardinal(int(post[i])))
1210        for ch in post_str.chars().take(precision as usize) {
1211            let d = ch
1212                .to_digit(10)
1213                .ok_or_else(|| N2WError::Value(format!("non-digit {:?} in fractional part", ch)))?;
1214            out.push(self.to_cardinal(&BigInt::from(d))?);
1215        }
1216
1217        // out = zh_to_cap(" ".join(out), capital=False); return out.replace(" ","")
1218        let joined = out.join(" ");
1219        Ok(self.zh_to_cap(&joined).replace(' ', ""))
1220    }
1221
1222    /// `to_ordinal(float/Decimal)` — `Num2Word_ZH.to_ordinal` runs
1223    /// `verify_ordinal(value)` first:
1224    ///
1225    /// ```python
1226    /// if not value == int(value):
1227    ///     raise TypeError(self.errmsg_floatord % value)   # any fractional value
1228    /// if not abs(value) == value:
1229    ///     raise TypeError(self.errmsg_negord % value)     # any negative value
1230    /// ```
1231    ///
1232    /// So `0.5`/`-1.5` are TypeError, `-1.0` is TypeError, but `-0.0` passes
1233    /// **both** checks (`-0.0 == 0` and `abs(-0.0) == -0.0`) and renders
1234    /// "第零". A whole positive value takes the integer cardinal —
1235    /// `to_ordinal(5.0)` == "第五", `to_ordinal(1e16)` == "第一京" — because
1236    /// `to_cardinal`'s `assert int(value) == value` routes it to the int path.
1237    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1238        let Some(i) = value.as_whole_int() else {
1239            return Err(N2WError::Type(format!(
1240                "Cannot treat float {} as ordinal.",
1241                py_value_str(value)
1242            )));
1243        };
1244        if i.is_negative() {
1245            return Err(N2WError::Type(format!(
1246                "Cannot treat negative num {} as ordinal.",
1247                py_value_str(value)
1248            )));
1249        }
1250        Ok(format!("{}{}", ORD_PREFIX, self.to_cardinal(&i)?))
1251    }
1252
1253    /// `to_ordinal_num(float/Decimal)`: `"%s%s%s" % (第, value, "")` — no
1254    /// `verify_ordinal`, so every value renders literally: "第-1000000.0",
1255    /// "第0.5", "第1e+16", "第5.00".
1256    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
1257        Ok(format!("{}{}", ORD_PREFIX, repr_str))
1258    }
1259
1260    /// `to_year(float/Decimal)` — `Num2Word_ZH.to_year`:
1261    ///
1262    /// ```python
1263    /// if not value == int(value):
1264    ///     raise TypeError(self.errmsg_floatyear % value)
1265    /// ```
1266    ///
1267    /// then digit-by-digit over `str(abs(int(value)))` — so whole floats
1268    /// reduce to the integer path exactly (`-21.0` → "公元前二一年",
1269    /// `1e20` → 一垓's twenty digits as 一 + nineteen 零 + 年), and `-0.0`
1270    /// truncates to 0, losing its sign: "零年".
1271    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1272        let Some(i) = value.as_whole_int() else {
1273            return Err(N2WError::Type(format!(
1274                "Cannot treat float {} as year.",
1275                py_value_str(value)
1276            )));
1277        };
1278        self.to_year(&i)
1279    }
1280
1281    /// `Num2Word_Base.to_fraction`, which ZH_TW inherits — with the Python
1282    /// crash it exhibits reproduced verbatim. The sign line is
1283    ///
1284    /// ```python
1285    /// sign = "%s " % self.negword.strip() if is_negative else ""
1286    /// ```
1287    ///
1288    /// and ZH_TW's `negword` is the nested tuple `(("負",), ("ㄈㄨˋ",))`, so a
1289    /// **negative** fraction dies with
1290    /// `AttributeError: 'tuple' object has no attribute 'strip'` before any
1291    /// word is rendered. Positive fractions never touch `negword` and render
1292    /// through the base recipe: `to_fraction(3, 4)` == "三 第四s" (the bare
1293    /// "s" plural is Python's own).
1294    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
1295        if denominator.is_zero() {
1296            return Err(N2WError::ZeroDivision(
1297                "denominator must not be zero".into(),
1298            ));
1299        }
1300        if denominator == &BigInt::one() || numerator.is_zero() {
1301            return self.to_cardinal(numerator);
1302        }
1303        let is_negative = numerator.is_negative() ^ denominator.is_negative();
1304        if is_negative {
1305            // self.negword.strip() — negword is a tuple here, not a str.
1306            return Err(N2WError::Attribute(
1307                "'tuple' object has no attribute 'strip'".into(),
1308            ));
1309        }
1310        let num_word = self.to_cardinal(&numerator.abs())?;
1311        let mut den_word = self.to_ordinal(&denominator.abs())?;
1312        if numerator.abs() != BigInt::one() {
1313            den_word.push('s'); // Python's bare "s" plural.
1314        }
1315        Ok(format!("{} {}", num_word, den_word))
1316    }
1317
1318    // ---- currency -------------------------------------------------------
1319    //
1320    // `Num2Word_ZH.to_currency` is a wholesale override that shares nothing
1321    // with `Num2Word_Base.to_currency`, and its own body comments record what
1322    // it drops. Consequently `pluralize`, `_cents_verbose`, `_cents_terse`,
1323    // `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are all unreachable from
1324    // it, and their trait defaults are left alone:
1325    //
1326    //   * `CURRENCY_PRECISION` is `{}` (Base's, never touched), so
1327    //     `.get(code, 100)` is the default 100 for every code.
1328    //   * `CURRENCY_ADJECTIVES` is `{}` too, and `to_currency` ignores the
1329    //     `adjective` kwarg regardless ("# CURRENCY_ADJECTIVES are not
1330    //     implemented").
1331    //   * `pluralize` stays abstract, exactly as in Python — Chinese has no
1332    //     plural form to select, and nothing calls it.
1333    //
1334    // `_money_verbose` is inherited unchanged (`self.to_cardinal(number)`), so
1335    // the trait default already matches and `to_cheque` below uses it.
1336
1337    fn lang_name(&self) -> &str {
1338        "Num2Word_ZH_TW"
1339    }
1340
1341    /// `Num2Word_ZH.to_currency`.
1342    ///
1343    /// ```python
1344    /// self.set_str_selection(reading, prefer)
1345    /// left, right, is_negative = parse_currency_parts(val, is_int_with_cents=False)
1346    /// try:
1347    ///     cr = self.CURRENCY_FORMS[currency]
1348    /// except KeyError:
1349    ///     raise NotImplementedError('Currency code "%s" not implemented for "%s"' % ...)
1350    /// minus_str = self.negword if is_negative else ""
1351    /// money_str = self.to_cardinal(left, reading=reading, prefer=prefer)
1352    /// if currency == "XXX":
1353    ///     cr_pre, cr_post = ("", cr)
1354    /// else:
1355    ///     cr_pre, cr_post = (cr, self.CURRENCY_FORMS["XXX"])
1356    /// cents_str = self.to_currency_float(right, reading=reading, prefer=prefer)
1357    /// cheque = self.cheque_suffix if len(cents_str) == 0 and reading == "capital" else ""
1358    /// for c in [minus_str, money_str, cr_post, *cents_str, cheque]:
1359    ///     cr_pre += self.zh_to_cap(self.select_text(c), reading == "capital")
1360    /// return cr_pre
1361    /// ```
1362    ///
1363    /// Departures from `Num2Word_Base.to_currency`, all deliberate on Python's
1364    /// side and all preserved:
1365    ///
1366    /// 1. **`cents`, `separator` and `adjective` are accepted and ignored.**
1367    ///    Nothing in the body reads them, so the separator never appears and no
1368    ///    cent word is ever pluralized.
1369    /// 2. **`has_decimal` is ignored** ("# has_decimal is not implemented"), so
1370    ///    the int/float split that drives Base collapses: `1` and `1.0` both
1371    ///    give "一元", because `to_currency_float(0)` returns `[]` either way.
1372    /// 3. **`CURRENCY_PRECISION` is never consulted** — `parse_currency_parts`
1373    ///    runs at its default `divisor=100` for every code. JPY therefore grows
1374    ///    the 角/分 subunits it does not have: `12.34 JPY` → "日元十二元三角四分"
1375    ///    (corpus-confirmed), where Base would round to a whole yen.
1376    /// 4. **The currency name precedes the sign**, since `cr_pre` seeds the
1377    ///    accumulator ahead of `minus_str`: −12.34 EUR → "歐元負十二元三角四分".
1378    /// 5. **`minus_str` is `self.negword` verbatim** — no `"%s " % ...strip()`,
1379    ///    so no space is introduced.
1380    /// 6. `money_str` is built from the already-absolute `left`, so `zh_to_cap`
1381    ///    *does* strip the leading 一 of "一十二" here. The float cardinal path
1382    ///    keeps it ("負一十二點三四") because its "負 " prefix blocks the
1383    ///    `startswith` test — see the module docs. Same number, two spellings.
1384    ///
1385    /// `cr_pre` is also the one component never passed through `zh_to_cap`.
1386    /// Immaterial at `reading=False` (no currency name starts with "一十"), but
1387    /// it is why 元 inside a name would survive capital mode uncapitalized.
1388    fn to_currency(
1389        &self,
1390        val: &CurrencyValue,
1391        currency: &str,
1392        _cents: bool,
1393        _separator: Option<&str>,
1394        _adjective: bool,
1395    ) -> Result<String> {
1396        // parse_currency_parts(val, is_int_with_cents=False); the rest stays at
1397        // the Python defaults (keep_precision=False, divisor=100). Python
1398        // parses before the CURRENCY_FORMS lookup — order kept, though this
1399        // call cannot fail once the shim has parsed the value.
1400        let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
1401
1402        let form = *self.currency_forms.get(currency).ok_or_else(|| {
1403            N2WError::NotImplemented(format!(
1404                "Currency code \"{}\" not implemented for \"{}\"",
1405                currency,
1406                self.lang_name()
1407            ))
1408        })?;
1409
1410        let minus_str = if is_negative { NEGWORD } else { "" };
1411        let money_str = self.to_cardinal(&left)?;
1412
1413        // `cr_post` is `CURRENCY_FORMS["XXX"]` in both arms — as `cr` itself
1414        // when currency == "XXX", by re-lookup otherwise — so it always
1415        // resolves to 元 and only `cr_pre` actually branches.
1416        let cr_pre: &str = if currency == "XXX" {
1417            ""
1418        } else {
1419            match form {
1420                Form::Name(name) => name,
1421                // Unreachable: "XXX" is the table's only nested entry, and it
1422                // took the other arm. Python would reach `tuple += str` here.
1423                Form::Nested => {
1424                    return Err(N2WError::Type(
1425                        "can only concatenate tuple (not \"str\") to tuple".into(),
1426                    ))
1427                }
1428            }
1429        };
1430
1431        // `right` carries scale 0 — parse_currency_parts truncates it with
1432        // `int(fraction * divisor)` — so this is exact.
1433        let cents_str = self.to_currency_float(&right.as_bigint_and_exponent().0);
1434
1435        // `cheque = self.cheque_suffix if len(cents_str) == 0 and reading ==
1436        // "capital" else ""`. `reading` is fixed at False, so 正 never appears
1437        // and the trailing component is always "".
1438
1439        // for c in [minus_str, money_str, cr_post, *cents_str, cheque]
1440        let mut out = String::from(cr_pre);
1441        out.push_str(&self.zh_to_cap(minus_str));
1442        out.push_str(&self.zh_to_cap(&money_str));
1443        out.push_str(&self.zh_to_cap(XXX_UNIT));
1444        for c in &cents_str {
1445            out.push_str(&self.zh_to_cap(c));
1446        }
1447        Ok(out)
1448    }
1449
1450    /// `Num2Word_Base.to_cheque`, which `Num2Word_ZH`/`_ZH_TW` inherit
1451    /// unchanged — but which cannot delegate to `currency::default_to_cheque`,
1452    /// because Base's `cr1, _cr2 = self.CURRENCY_FORMS[currency]` is here
1453    /// destructuring ZH's *name strings* rather than a (unit, subunit) pair.
1454    ///
1455    /// ```python
1456    /// try:
1457    ///     cr1, _cr2 = self.CURRENCY_FORMS[currency]
1458    /// except KeyError:
1459    ///     raise NotImplementedError('Currency code "%s" not implemented for "%s"' % ...)
1460    /// ...
1461    /// unit = cr1[-1] if isinstance(cr1, tuple) else cr1
1462    /// ```
1463    ///
1464    /// Unpacking a `str` iterates its characters, so the outcome hinges on the
1465    /// name's *length* — a Python bug, reproduced verbatim:
1466    ///
1467    /// | entry | `cr1` | result |
1468    /// |---|---|---|
1469    /// | "歐元" (2 chars) | "歐" | "… AND 56/100 歐" — half the currency name |
1470    /// | "人民幣" (3 chars) | — | **ValueError**; `except KeyError` misses it |
1471    /// | `(("元",), ("ㄩㄢˊ",))` | `("元",)` | tuple → `cr1[-1]` → "元" |
1472    ///
1473    /// So `to_cheque` succeeds only for "XXX" and the nine two-character names
1474    /// (AUD CAD EUR GBP HKD JPY KRW THB USD), and raises ValueError for the
1475    /// seven longer ones (CHF CNY MOP MYR NTD NZD SGD). Corpus-confirmed on
1476    /// both sides: `cheque:EUR` → "一千二百三十四 AND 56/100 歐", `cheque:CNY`
1477    /// → ValueError, `cheque:KWD` → NotImplementedError.
1478    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
1479        let form = *self.currency_forms.get(currency).ok_or_else(|| {
1480            N2WError::NotImplemented(format!(
1481                "Currency code \"{}\" not implemented for \"{}\"",
1482                currency,
1483                self.lang_name()
1484            ))
1485        })?;
1486
1487        // cr1, _cr2 = <form>
1488        // unit = cr1[-1] if isinstance(cr1, tuple) else cr1
1489        let unit: String = match form {
1490            Form::Nested => XXX_UNIT.to_string(),
1491            Form::Name(name) => match name.chars().count() {
1492                // cr1 is the first character, a `str` — so `unit = cr1`.
1493                2 => name
1494                    .chars()
1495                    .next()
1496                    .expect("a 2-char name has a first char")
1497                    .to_string(),
1498                n if n > 2 => {
1499                    return Err(N2WError::Value(
1500                        "too many values to unpack (expected 2)".into(),
1501                    ))
1502                }
1503                // Unreachable for ZH_TW's table, but live for the parent
1504                // `Num2Word_ZH`, whose "XXX" stays the 1-char str "元".
1505                n => {
1506                    return Err(N2WError::Value(format!(
1507                        "not enough values to unpack (expected 2, got {})",
1508                        n
1509                    )))
1510                }
1511            },
1512        };
1513
1514        // CURRENCY_PRECISION is `{}`, so this is always 100 and `fraction_str`
1515        // is always the "NN/100" form — the `divisor > 1` else-arm below is
1516        // dead. Kept so the shape still matches Base.
1517        let divisor = self.currency_precision(currency);
1518        let is_negative = val.is_negative();
1519        let abs_val = val.abs();
1520        // int(abs_val) — truncation, which on a non-negative value is floor.
1521        let whole = abs_val.with_scale(0).as_bigint_and_exponent().0;
1522
1523        let fraction_str = if divisor > 1 {
1524            let sub = (&abs_val - BigDecimal::from(whole.clone())) * BigDecimal::from(divisor);
1525            let sub = sub.with_scale(0).as_bigint_and_exponent().0;
1526            let digits = divisor.to_string().len() - 1;
1527            format!("{:0>width$}/{}", sub.to_string(), divisor, width = digits)
1528        } else {
1529            String::new()
1530        };
1531
1532        let words = self.money_verbose(&whole, currency)?;
1533        let sign = if is_negative { "MINUS " } else { "" };
1534        let body = if fraction_str.is_empty() {
1535            format!("{} {}", words, unit)
1536        } else {
1537            format!("{} AND {} {}", words, fraction_str, unit)
1538        };
1539        // Python's `.upper()`. A no-op on CJK, and "AND"/"MINUS" are already
1540        // uppercase — but it is what Base does, so it is what runs.
1541        Ok(format!("{}{}", sign, body).to_uppercase())
1542    }
1543
1544    // ---- grammatical kwargs ----------------------------------------------
1545    //
1546    // The live signatures, from lang_ZH.py / lang_ZH_TW.py:
1547    //
1548    //   to_cardinal(value, stuff_zero=2, reading=False, prefer=None)
1549    //   to_ordinal(value, counter="", reading=False, prefer=None)
1550    //   to_ordinal_num(value, counter="", reading=False, prefer=None)
1551    //   to_year(value, era=False, reading=False, prefer=None)
1552    //   to_currency(val, currency, cents, separator, adjective,
1553    //               reading=False, prefer=None)
1554    //
1555    // Each hook guards with `kw.only(...)` so an unknown kwarg falls back to
1556    // Python, which raises the original TypeError.
1557
1558    /// `Num2Word_ZH_TW.to_cardinal` with `stuff_zero`/`reading`/`prefer` live.
1559    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1560        if kw.is_empty() {
1561            return self.to_cardinal(value);
1562        }
1563        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
1564            return Err(N2WError::Fallback("kwargs".into()));
1565        }
1566        let reading = parse_reading(kw);
1567        let prefer = parse_prefer(kw)?;
1568        self.kw_cardinal(value, parse_stuff_zero(kw), reading, prefer.as_deref())
1569    }
1570
1571    /// `Num2Word_ZH_TW.to_ordinal` with `counter`/`reading`/`prefer` live.
1572    ///
1573    /// ```python
1574    /// if reading is True:
1575    ///     if counter not in self.counters and counter:
1576    ///         raise NotImplementedError(f"Reading not implemented for {counter}")
1577    ///     counter = self.counters.get(counter, "")
1578    /// return super().to_ordinal(value, counter=counter, ...)
1579    /// ```
1580    ///
1581    /// With `reading` anything but the bool `True`, the counter passes
1582    /// through verbatim — "第一个" for `counter="个"` — and `verify_ordinal`
1583    /// still rejects negatives with TypeError.
1584    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1585        if kw.is_empty() {
1586            return self.to_ordinal(value);
1587        }
1588        if !kw.only(&["counter", "reading", "prefer"]) {
1589            return Err(N2WError::Fallback("kwargs".into()));
1590        }
1591        let reading = parse_reading(kw);
1592        let prefer = parse_prefer(kw)?;
1593        let mut counter = parse_counter(kw)?;
1594        if reading == Reading::True {
1595            match tw_counter(counter) {
1596                Some(c) => counter = c,
1597                None if !counter.is_empty() => {
1598                    // Python raises NotImplementedError here with this exact
1599                    // message; surfacing NotImplemented sends the call back to
1600                    // Python, which raises the real thing.
1601                    return Err(N2WError::NotImplemented(format!(
1602                        "Reading not implemented for {}",
1603                        counter
1604                    )));
1605                }
1606                None => counter = "",
1607            }
1608        }
1609        self.verify_ordinal(value)?;
1610        let base = self.kw_cardinal(value, STUFF_ZERO as i64, reading, prefer.as_deref())?;
1611        Ok(format!(
1612            "{}{}{}",
1613            select2(ORD_ALTS, reading, prefer.as_deref()),
1614            base,
1615            counter
1616        ))
1617    }
1618
1619    /// `Num2Word_ZH_TW.to_ordinal_num` with the kwargs live — same counter
1620    /// handling as `to_ordinal`, but no `verify_ordinal`, so "第-5个".
1621    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1622        if kw.is_empty() {
1623            return self.to_ordinal_num(value);
1624        }
1625        if !kw.only(&["counter", "reading", "prefer"]) {
1626            return Err(N2WError::Fallback("kwargs".into()));
1627        }
1628        let reading = parse_reading(kw);
1629        let prefer = parse_prefer(kw)?;
1630        let mut counter = parse_counter(kw)?;
1631        if reading == Reading::True {
1632            match tw_counter(counter) {
1633                Some(c) => counter = c,
1634                None if !counter.is_empty() => {
1635                    return Err(N2WError::NotImplemented(format!(
1636                        "Reading not implemented for {}",
1637                        counter
1638                    )));
1639                }
1640                None => counter = "",
1641            }
1642        }
1643        Ok(format!(
1644            "{}{}{}",
1645            select2(ORD_ALTS, reading, prefer.as_deref()),
1646            value,
1647            counter
1648        ))
1649    }
1650
1651    /// `Num2Word_ZH_TW.to_year` with `era`/`reading`/`prefer` live.
1652    ///
1653    /// `era` falsy → `Num2Word_ZH.to_year` (digit-by-digit, 公元前 for
1654    /// negatives, 公元 prefix for positive years under `reading="capital"`).
1655    ///
1656    /// `era` truthy → the ROC branch:
1657    ///
1658    /// ```python
1659    /// min_year = 1912
1660    /// if value < min_year:
1661    ///     raise ValueError("Can't convert years less than %s to ROC era" % min_year)
1662    /// era_year = abs(int(value - min_year + 1))
1663    /// if reading == "arabic":  era_year_words = era_year
1664    /// elif era_year == 1:      era_year_words = select_text((("元",), ("ㄩㄢˊ",)))
1665    /// elif era_year < 101:     era_year_words = self.to_cardinal(era_year)
1666    /// else:                    era_year_words = "".join(select_text(cards[int(s)]) ...)
1667    /// return "%s%s%s" % (select_text(ROC_era), era_year_words, select_text(year))
1668    /// ```
1669    ///
1670    /// Note the `< 101` arm calls `to_cardinal` at its **defaults**, whose
1671    /// `set_str_selection(False, None)` resets the live selection — so the
1672    /// surrounding 民國/年 come out in hanzi even under `reading=True`.
1673    /// Reproduced by dropping to `Reading::Plain` for the tail in that arm.
1674    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1675        if kw.is_empty() {
1676            return self.to_year(value);
1677        }
1678        if !kw.only(&["era", "reading", "prefer"]) {
1679            return Err(N2WError::Fallback("kwargs".into()));
1680        }
1681        let reading = parse_reading(kw);
1682        let prefer = parse_prefer(kw)?;
1683        if !kw_truthy(kw.get("era")) {
1684            return self.kw_year(value, reading, prefer.as_deref());
1685        }
1686
1687        // `if not value == int(value)` cannot fire: input is integral here.
1688        let min_year = BigInt::from(1912);
1689        if value < &min_year {
1690            return Err(N2WError::Value(
1691                "Can't convert years less than 1912 to ROC era".into(),
1692            ));
1693        }
1694        // abs(int(value - 1912 + 1)) — non-negative once value >= 1912.
1695        let era_year = value - BigInt::from(1911);
1696
1697        // The selection state the trailing 民國/年 renders under; the
1698        // to_cardinal arm resets it, exactly as Python's side effect does.
1699        let mut tail_reading = reading;
1700        let mut tail_prefer = prefer.clone();
1701
1702        let era_year_words: String = if kw.str("reading") == Some("arabic") {
1703            era_year.to_string()
1704        } else if era_year.is_one() {
1705            select2(YUAN_ALTS, reading, prefer.as_deref()).to_string()
1706        } else if era_year < BigInt::from(101) {
1707            // self.to_cardinal(era_year) — defaults, which also reset
1708            // self.reading/self.prefer for the format below.
1709            tail_reading = Reading::Plain;
1710            tail_prefer = None;
1711            self.to_cardinal(&era_year)?
1712        } else {
1713            era_year
1714                .to_string()
1715                .chars()
1716                .map(|c| {
1717                    select2(
1718                        TW_LOW[c.to_digit(10).expect("BigInt digits") as usize],
1719                        reading,
1720                        prefer.as_deref(),
1721                    )
1722                })
1723                .collect()
1724        };
1725
1726        Ok(format!(
1727            "{}{}{}",
1728            select2(ROC_ERA_ALTS, tail_reading, tail_prefer.as_deref()),
1729            era_year_words,
1730            select2(YEAR_W_ALTS, tail_reading, tail_prefer.as_deref())
1731        ))
1732    }
1733
1734    /// `Num2Word_ZH.to_currency` with `reading`/`prefer` live — the capital
1735    /// mode is where the 大寫 forms and the 整 cheque suffix appear:
1736    /// `to_currency(1234, reading="capital")` == "壹仟貳佰叁拾肆圓整".
1737    fn to_currency_kw(
1738        &self,
1739        val: &CurrencyValue,
1740        currency: &str,
1741        _cents: bool,
1742        _separator: Option<&str>,
1743        _adjective: bool,
1744        kw: &Kwargs,
1745    ) -> Result<String> {
1746        if kw.is_empty() {
1747            return self.to_currency(val, currency, _cents, _separator, _adjective);
1748        }
1749        if !kw.only(&["reading", "prefer"]) {
1750            return Err(N2WError::Fallback("kwargs".into()));
1751        }
1752        let reading = parse_reading(kw);
1753        let prefer = parse_prefer(kw)?;
1754        let prefer = prefer.as_deref();
1755
1756        let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
1757
1758        let form = *self.currency_forms.get(currency).ok_or_else(|| {
1759            N2WError::NotImplemented(format!(
1760                "Currency code \"{}\" not implemented for \"{}\"",
1761                currency,
1762                self.lang_name()
1763            ))
1764        })?;
1765
1766        // minus_str = self.negword — resolved by select_text in the loop.
1767        let minus_str = if is_negative {
1768            select2(NEG_ALTS, reading, prefer)
1769        } else {
1770            ""
1771        };
1772        let money_str = self.kw_cardinal(&left, STUFF_ZERO as i64, reading, prefer)?;
1773
1774        let cr_pre: &str = if currency == "XXX" {
1775            ""
1776        } else {
1777            match form {
1778                Form::Name(name) => name,
1779                Form::Nested => {
1780                    return Err(N2WError::Type(
1781                        "can only concatenate tuple (not \"str\") to tuple".into(),
1782                    ))
1783                }
1784            }
1785        };
1786        // cr_post — CURRENCY_FORMS["XXX"], the nested tuple, select_text'd.
1787        let cr_post = select2(YUAN_ALTS, reading, prefer);
1788
1789        let cents_str = self.kw_currency_float(&right.as_bigint_and_exponent().0, reading, prefer);
1790
1791        // cheque = cheque_suffix if len(cents_str) == 0 and reading == "capital"
1792        let cheque = if cents_str.is_empty() && reading == Reading::Capital {
1793            select2(CHEQUE_ALTS, reading, prefer)
1794        } else {
1795            ""
1796        };
1797
1798        // for c in [minus_str, money_str, cr_post, *cents_str, cheque]:
1799        //     cr_pre += self.zh_to_cap(self.select_text(c), reading == "capital")
1800        let mut out = String::from(cr_pre);
1801        out.push_str(&self.kw_zh_to_cap(minus_str, reading, prefer));
1802        out.push_str(&self.kw_zh_to_cap(&money_str, reading, prefer));
1803        out.push_str(&self.kw_zh_to_cap(cr_post, reading, prefer));
1804        for c in &cents_str {
1805            out.push_str(&self.kw_zh_to_cap(c, reading, prefer));
1806        }
1807        out.push_str(&self.kw_zh_to_cap(cheque, reading, prefer));
1808        Ok(out)
1809    }
1810
1811    /// `Num2Word_ZH_TW.to_cardinal(float, stuff_zero, reading, prefer)`: the
1812    /// `assert int(value) == value` routes whole values to the (kwargs-live)
1813    /// integer path — where `stuff_zero` applies — and fractional values to
1814    /// `to_cardinal_float(value, reading, prefer)`, whose inner `to_cardinal`
1815    /// calls reset `stuff_zero` to its default 2 (Python re-assigns
1816    /// `self.stuff_zero` on every call).
1817    fn to_cardinal_float_kw(
1818        &self,
1819        value: &FloatValue,
1820        precision_override: Option<u32>,
1821        kw: &Kwargs,
1822    ) -> Result<String> {
1823        if kw.is_empty() {
1824            return self.cardinal_float_entry(value, precision_override);
1825        }
1826        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
1827            return Err(N2WError::Fallback("kwargs".into()));
1828        }
1829        let reading = parse_reading(kw);
1830        let prefer = parse_prefer(kw)?;
1831        if let Some(i) = value.as_whole_int() {
1832            return self.kw_cardinal(&i, parse_stuff_zero(kw), reading, prefer.as_deref());
1833        }
1834        self.kw_cardinal_float(value, reading, prefer.as_deref())
1835    }
1836}