Skip to main content

num2words2_core/
lang_zh_hk.rs

1//! Port of `lang_ZH_HK.py` (Chinese, Hong Kong / Traditional).
2//!
3//! Registry check: `__init__.py` maps `"zh_HK"` → `lang_ZH_HK.Num2Word_ZH_HK()`,
4//! so this file ports that class. `Num2Word_ZH_HK` defines no methods at all:
5//! its `__init__` only rebuilds `CURRENCY_FLOATS` (毫/仙), merges five extra
6//! `CURRENCY_FORMS` codes, and appends two `CAP_map` rows (毫→角, 仙→分) —
7//! every behaviour is inherited from `Num2Word_ZH`. So `to_cardinal` /
8//! `to_ordinal` / `to_ordinal_num` / `to_year` are byte-identical to `zh_TW`
9//! (and differ from `zh_CN` only by the simplified/traditional numword tables
10//! that `Num2Word_ZH_CN` substitutes), while all three of the child's data
11//! tweaks land on the currency surface.
12//!
13//! The one `CAP_map` consumer is `zh_to_cap`'s `capital` branch, reachable
14//! only through `reading="capital"` — i.e. through the `*_kw` hooks. The
15//! runtime table (parent rows + the child's 毫→角/仙→分) is [`HK_CAP_MAP`].
16//!
17//! # Currency: ZH replaces the machinery, it does not configure it
18//!
19//! Most languages inherit `Num2Word_Base.to_currency` and supply data. ZH
20//! overrides `to_currency` wholesale with a different algorithm, so almost
21//! none of the Base contract applies here:
22//!
23//! * `CURRENCY_FORMS` values are **bare strings** ("歐羅"), not the
24//!   `(singular, plural)` tuples every other language carries. There are no
25//!   plural forms to choose between, so `pluralize` is never called and stays
26//!   at the trait default that raises — exactly as `Num2Word_Base.pluralize`
27//!   does.
28//! * The subunit names are **not** per currency: they come from
29//!   `CURRENCY_FLOATS`, a flat 2-element list (毫 = 1/10, 仙 = 1/100), so
30//!   there is no subunit half of a `CURRENCY_FORMS` entry to store.
31//! * `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both empty
32//!   (`# CURRENCY_ADJECTIVES are not implemented` in the source), so
33//!   `currency_adjective` and `currency_precision` keep their trait defaults
34//!   (`None` / 100).
35//! * `cents=`, `separator=` and `adjective=` are accepted by ZH's signature
36//!   and then never read — the output is a bare concatenation with no
37//!   separator anywhere.
38//! * `_money_verbose` / `_cents_verbose` / `_cents_terse` are never reached:
39//!   the integer part goes through `to_cardinal` directly and the subunits
40//!   through ZH's own `to_currency_float`.
41//!
42//! `to_cheque`, by contrast, is **not** overridden by ZH, so Base's runs — and
43//! Base's assumes the tuple shape that ZH does not have. That mismatch is
44//! bug #7 below, and it is the whole story of this file's `to_cheque`.
45//!
46//! # The `lang_EUR.py` mutation trap does not apply here
47//!
48//! `Num2Word_EN.__init__` mutates `Num2Word_EUR.CURRENCY_FORMS` in place, so
49//! 16 classes read an EN-rewritten table at runtime. `Num2Word_ZH` is not one
50//! of them: it subclasses `Num2Word_Base` directly and shadows the inherited
51//! `{}` with its own class-body dict, which is a different object from the one
52//! EN mutates. Verified against the live interpreter — `Num2Word_ZH_HK()`
53//! reports `EUR → 歐羅`, not `("euro", "euros")`.
54//!
55//! `Num2Word_ZH_HK.__init__` likewise leaks nothing back: it does
56//! `self.CURRENCY_FORMS = self.CURRENCY_FORMS.copy()` (and the same for
57//! `CAP_map`) *before* mutating, so the copies are instance attributes and
58//! `Num2Word_ZH.CURRENCY_FORMS["EUR"]` stays 歐元 for `zh`/`zh_TW`. The tables
59//! below are therefore per-instance state modelled as per-struct state, which
60//! is what they already are in Python.
61//!
62//! Shape: **engine**. `Num2Word_ZH` defines `high_numwords`/`mid_numwords`/
63//! `low_numwords` + `set_high_numwords` + `merge`, so `Num2Word_Base` builds
64//! `self.cards`/`MAXVAL` and drives `splitnum`/`clean`. `to_cardinal` is only
65//! *wrapped*, not replaced: it delegates to `super().to_cardinal` and then
66//! post-processes. So `cards`/`maxval`/`merge` are all live here and
67//! `base::default_to_cardinal` does the real work.
68//!
69//! # The `reading` / `prefer` axis
70//!
71//! `Num2Word_ZH` threads a `reading` and a `prefer` argument through every
72//! public method, and `select_text` uses them to choose between alternative
73//! glyphs. Exactly one card is polymorphic: `low_numwords[-1]` is the tuple
74//! `("零", "〇")`, so `cards[0]` is a *tuple*, not a string.
75//!
76//! The plain (no-kwargs) entry points are always entered at the defaults
77//! (`reading=False`, `prefer=None`), and `select_text` under those defaults
78//! reduces to "take `text[0]`":
79//!
80//!   * `all(isinstance(item, tuple) for item in ("零","〇"))` is **False**
81//!     (the items are `str`), so the `reading is True` branch is dead;
82//!   * `set(text) & set(self.prefer or set())` is empty, so `len(common) == 1`
83//!     is False and it falls through to `text = text[0]`.
84//!
85//! The same holds for `year_prefix = ("公元", "西元")` → "公元". So the plain
86//! paths store the already-selected first element ("零", "公元") and skip
87//! `select_text`. Note `reading=True` never changes ZH_HK output at all —
88//! no table entry is a tuple-of-tuples, so the `text[1]` branch is dead for
89//! *every* reading value except `"capital"`.
90//!
91//! The `*_kw` hooks at the bottom of the `Lang` impl carry the live kwargs:
92//! `reading="capital"` activates `zh_to_cap`'s [`HK_CAP_MAP`] arm (and the
93//! 公元 prefix on positive years, and the currency path's 整 cheque suffix),
94//! `prefer` re-selects the 零/〇 card and the 公元/西元 prefix, `stuff_zero`
95//! parameterizes `merge`, and `counter` suffixes the ordinal forms.
96//!
97//! # Faithfully reproduced Python bugs / oddities
98//!
99//! 1. **`zh_to_cap`'s leading-一 strip is prefix-anchored, so it misses
100//!    negatives.** With `capital` false, `zh_to_cap` does
101//!    `elif out.startswith(one + ten): out = out[len(one):]` — i.e. it only
102//!    strips the redundant 一 when the string *starts* with "一十". A negative
103//!    teen starts with the negword 負, so the strip never fires:
104//!    `to_cardinal(12)` == "十二" but `to_cardinal(-12)` == "負一十二", not the
105//!    expected "負十二". Confirmed by the frozen corpus via the float path
106//!    (`-12.34` → "負一十二點三四" vs `12.34` → "十二點三四"). Reproduced in
107//!    [`LangZhHk::zh_to_cap_plain`] — do not "fix" it.
108//! 2. **The strip is unconditional on position, not on grammar.** Any result
109//!    that merely begins "一十…" loses its 一, which is what makes
110//!    `to_cardinal(100000)` == "十萬" (from "一十萬") and
111//!    `to_cardinal(1000000000)` == "十億" (from "一十億"). Interior "一十"
112//!    survives: `to_cardinal(1010)` == "一千零一十".
113//! 3. **`to_ordinal_num` skips `verify_ordinal`.** `to_ordinal` calls it (so
114//!    negatives raise `TypeError`), but `to_ordinal_num` just interpolates
115//!    the raw value: `to_ordinal_num(-1)` == "第-1". Corpus confirms.
116//! 4. **`to_year` never bounds-checks.** It bypasses `to_cardinal` entirely
117//!    and spells digits one by one, so it has no `MAXVAL` guard and cannot
118//!    overflow — `to_year(10**100)` is fine while `to_cardinal(10**100)`
119//!    raises `OverflowError`. No overflow check is added here.
120//! 5. **`merge`'s dead `stuff_zero` fallthrough.** The `if len(str(lnum)) -
121//!    len(str(rnum)) > 1:` block has arms for `stuff_zero` 1/2/3 and no
122//!    `else`, so an out-of-range `stuff_zero` would fall past the whole
123//!    `if/elif` chain to the trailing `return no_zero`. Unreachable at the
124//!    fixed default of 2; noted rather than modelled.
125//! 6. **`merge`'s `stuff_zero == 2` arm re-tests its own guard.** The inner
126//!    `if len(str(lnum)) - len(str(rnum)) > 1 and ...` repeats a condition the
127//!    outer `if` already proved. Harmless; kept as a comment, not as code.
128//! 7. **`to_cheque` unpacks a string as if it were a tuple, and is broken for
129//!    15 of ZH_HK's 17 currency codes.** See [`LangZhHk::to_cheque`] — the
130//!    single largest quirk in this file.
131//! 8. **`to_currency` ignores `has_decimal`, so `1.999` prints no cents.**
132//!    Base gates the cents segment on `isinstance(val, float) or "." in
133//!    str(val)`; ZH's source says `# has_decimal is not implemented` and gates
134//!    on nothing at all, emitting subunits iff the rounded subunit count is
135//!    non-zero. So `1.0` → "一元" (agreeing with Base by accident, since Base
136//!    would print "one euro, zero cents"-style output) and `1.999` → "二元",
137//!    a float input that renders as a bare integer. Corpus pins the `1.0` row.
138//! 9. **`to_currency`'s `zh_to_cap` pass over each component is dead code.**
139//!    The loop applies `zh_to_cap` to every fragment, but `money_str` has
140//!    already been through it inside `to_cardinal` (and its leading-一 strip is
141//!    idempotent — the result can never start "一十" again), and no other
142//!    fragment is longer than one character. Ported literally anyway, since
143//!    the cost is nil and the equivalence is an argument rather than a fact.
144//!
145//! # Cross-call mutable state (see `concerns`)
146//!
147//! `Num2Word_ZH` is stateful: `to_cardinal` assigns `self.stuff_zero`, and
148//! `set_str_selection` assigns `self.reading` / `self.prefer` / `self.capital`.
149//! `merge` then reads `self.stuff_zero`, and `zh_to_cap` / `to_cardinal_float`
150//! read `self.capital`. For the four in-scope modes this is *self-consistent*
151//! — every entry point assigns before anything reads, and none of them leaves
152//! state that changes a later call's result — so the stateless Rust port is
153//! faithful. The handshake is real but benign here.
154
155use crate::base::{
156    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, KwVal, Lang, N2WError,
157    Result,
158};
159use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
160use crate::floatpath::{default_to_cardinal_float, FloatValue};
161use bigdecimal::BigDecimal;
162use num_bigint::BigInt;
163use num_traits::{One, Signed, Zero};
164use std::collections::HashMap;
165
166/// `to_cardinal`'s `stuff_zero` default.
167///
168/// Python signature: `to_cardinal(self, value, stuff_zero=2, reading=False,
169/// prefer=None)`. Nothing in the four in-scope modes passes `stuff_zero`
170/// (`to_ordinal` forwards only `reading`/`prefer`), so `merge` always sees 2:
171/// "零 between discontinuous *high* numbers only". 1 = always insert 零,
172/// 3 = never. Only the value-2 arm is modelled below.
173const STUFF_ZERO: u8 = 2;
174
175/// `low_numwords[-1]` after `select_text` at `reading=False`/`prefer=None`.
176/// Python holds the tuple `("零", "〇")` here; the default selection is `[0]`.
177const ZERO_WORD: &str = "零";
178
179/// `cards[1]` — the 一 that `zh_to_cap` strips off a leading "一十".
180const ONE_WORD: &str = "一";
181/// `cards[10]`.
182const TEN_WORD: &str = "十";
183
184/// `Num2Word_ZH_HK.CURRENCY_FLOATS_CHILD` — the subunit names, indexed by
185/// `to_currency_float` as `[0]` (the 1/10 place) and `[1]` (the 1/100 place).
186///
187/// `__init__` does `self.CURRENCY_FLOATS = self.CURRENCY_FLOATS_CHILD.copy()`,
188/// an assignment rather than a merge, so this **replaces** `Num2Word_ZH`'s
189/// mainland 角/分 outright. 毫 and 仙 are the Hong Kong names (仙 transliterates
190/// English "cent"), which is why 12.34 HKD reads 十二元三毫四仙 here but
191/// 十二元三角四分 in `zh`.
192const CURRENCY_FLOATS: [&str; 2] = ["毫", "仙"];
193
194/// `CURRENCY_FORMS[code]` for a language whose values are bare strings.
195///
196/// [`CurrencyForms`] models Python's usual `(unit_forms, subunit_forms)` pair,
197/// which ZH simply does not have (see the module docs). The whole Python string
198/// goes in as the single `unit` entry and `subunit` is left empty — the shape
199/// is inert here because both consumers of the table, `to_currency` and
200/// `to_cheque`, are overridden below and read the string through
201/// [`LangZhHk::currency_string`] instead. Nothing reaches
202/// `currency::default_to_currency`, whose `pluralize`/`cr2` assumptions this
203/// entry would not satisfy.
204fn bare(s: &str) -> CurrencyForms {
205    CurrencyForms::new(&[s], &[])
206}
207
208/// `CURRENCY_FORMS` as `Num2Word_ZH_HK` actually sees it at runtime.
209///
210/// Built in two steps on purpose, mirroring `__init__`: `Num2Word_ZH`'s
211/// class-body dict first, then the `CURRENCY_FORMS_CHILD` codes written on
212/// top. Pre-merging them would hide which entries are HK's own, and that
213/// five-row diff is exactly what a reviewer checks against `lang_ZH_HK.py`.
214/// Both halves are verified against a live `Num2Word_ZH_HK()`.
215fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
216    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
217
218    // ---- Num2Word_ZH.CURRENCY_FORMS (class body), inherited ----
219    m.insert("XXX", bare("元")); // Generic dollar
220    m.insert("CNY", bare("人民幣"));
221    m.insert("NTD", bare("新台幣"));
222    m.insert("HKD", bare("港幣"));
223    m.insert("MOP", bare("澳門幣"));
224    m.insert("SGD", bare("新加坡元"));
225    m.insert("MYR", bare("馬來西亞令吉"));
226    m.insert("USD", bare("美元"));
227    m.insert("EUR", bare("歐元"));
228    m.insert("GBP", bare("英鎊"));
229    m.insert("JPY", bare("日元"));
230    m.insert("CHF", bare("瑞士法郎"));
231    m.insert("CAD", bare("加元"));
232    m.insert("AUD", bare("澳幣"));
233    m.insert("NZD", bare("紐西蘭元"));
234    m.insert("THB", bare("泰銖"));
235    m.insert("KRW", bare("韓元"));
236
237    // ---- Num2Word_ZH_HK.CURRENCY_FORMS_CHILD, merged on top ----
238    //     for k, v in self.CURRENCY_FORMS_CHILD.items():
239    //         self.CURRENCY_FORMS[k] = v
240    // Hong Kong / Taiwan names for five codes. Note CAD restates the parent's
241    // 加元 unchanged — a no-op write that the source performs anyway, kept here
242    // so the five-row child table matches `lang_ZH_HK.py` line for line.
243    m.insert("EUR", bare("歐羅")); // was 歐元
244    m.insert("JPY", bare("日圓")); // was 日元
245    m.insert("CAD", bare("加元")); // was 加元 — unchanged
246    m.insert("AUD", bare("澳元")); // was 澳幣
247    m.insert("KRW", bare("韓圜")); // was 韓元
248
249    m
250}
251
252pub struct LangZhHk {
253    cards: Cards,
254    maxval: BigInt,
255    currency_forms: HashMap<&'static str, CurrencyForms>,
256}
257
258impl Default for LangZhHk {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl LangZhHk {
265    pub fn new() -> Self {
266        // Num2Word_ZH.setup(): declared low → high, then reversed in place.
267        let mut high: Vec<&str> = vec![
268            "萬",       // 10 ** 4
269            "億",       // 10 ** 8
270            "兆",       // 10 ** 12
271            "京",       // 10 ** 16
272            "垓",       // 10 ** 20
273            "秭",       // 10 ** 24
274            "穣",       // 10 ** 28
275            "溝",       // 10 ** 32
276            "澗",       // 10 ** 36
277            "正",       // 10 ** 40
278            "載",       // 10 ** 44
279            "極",       // 10 ** 48
280            "恆河沙",   // 10 ** 52
281            "阿僧祇",   // 10 ** 56
282            "那由他",   // 10 ** 60
283            "不可思議", // 10 ** 64
284            "無量",     // 10 ** 68
285            "不可說",   // 10 ** 72
286        ];
287        high.reverse();
288
289        let mut cards = Cards::new();
290
291        // Num2Word_ZH.set_high_numwords:
292        //     max = 4 * len(high)
293        //     for word, n in zip(high, range(max, 0, -4)):
294        //         self.cards[10 ** n] = word
295        // zip() stops at the shorter sequence; here both are exactly len(high)
296        // long (max = 4*18 = 72, so range(72, 0, -4) yields 72..=4 → 18 items),
297        // so every word is consumed. 不可說 → 10^72 … 萬 → 10^4.
298        let max = 4 * high.len() as u32;
299        let mut n = max;
300        for word in high.iter() {
301            if n == 0 {
302                break;
303            }
304            cards.insert(BigInt::from(10u8).pow(n), *word);
305            n -= 4;
306        }
307
308        set_mid_numwords(&mut cards, &[(1000, "千"), (100, "百"), (10, "十")]);
309        // Python's last entry is the tuple ("零", "〇"); stored pre-selected.
310        set_low_numwords(
311            &mut cards,
312            &[
313                "九", "八", "七", "六", "五", "四", "三", "二", "一", ZERO_WORD,
314            ],
315        );
316
317        // Num2Word_Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
318        // The OrderedDict's first *inserted* key is the highest card (10^72),
319        // which is also `Cards::highest()`. → 10^75.
320        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
321
322        LangZhHk {
323            cards,
324            maxval,
325            // Built once here, never per call. `to_currency` only ever reads
326            // this table, and rebuilding a 17-entry HashMap per conversion is
327            // how an earlier revision of this port ended up slower than the
328            // Python it replaces.
329            currency_forms: build_currency_forms(),
330        }
331    }
332
333    /// `Num2Word_Base.verify_ordinal`, minus the float arm (integer input only).
334    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
335        // `if not abs(value) == value: raise TypeError(self.errmsg_negord % value)`
336        if value.is_negative() {
337            return Err(N2WError::Type(format!(
338                "Cannot treat negative num {} as ordinal.",
339                value
340            )));
341        }
342        Ok(())
343    }
344
345    /// `Num2Word_ZH.zh_to_cap(value, capital=False)`.
346    ///
347    /// The `capital` arm (CAP_map substitution) is unreachable from the four
348    /// in-scope modes — `capital` is `reading == "capital"`, and `reading` is
349    /// always `False` here — so only the `elif` arm is ported:
350    ///
351    /// ```python
352    /// elif out.startswith(one + ten):
353    ///     out = out[len(one):]
354    /// return out
355    /// ```
356    ///
357    /// `len(one)` is 1 in *characters* (Python slices strings by character),
358    /// so this drops exactly one char — hence `chars().skip(1)` rather than a
359    /// byte slice, since 一 is 3 bytes in UTF-8.
360    ///
361    /// Bug #1/#2 in the module docs live here: the test is anchored at the
362    /// start of the string, so "負一十二" keeps its 一 while "一十萬" loses it.
363    fn zh_to_cap_plain(&self, value: &str) -> String {
364        let one_ten = format!("{}{}", ONE_WORD, TEN_WORD);
365        if value.starts_with(&one_ten) {
366            value.chars().skip(1).collect()
367        } else {
368            value.to_string()
369        }
370    }
371
372    /// `Num2Word_ZH.to_cardinal(value, stuff_zero, reading, prefer)` with the
373    /// kwargs live:
374    ///
375    /// ```python
376    /// self.stuff_zero = stuff_zero
377    /// self.set_str_selection(reading, prefer)
378    /// out = super().to_cardinal(value).replace(" ", "")
379    /// return self.zh_to_cap(out, reading == "capital")
380    /// ```
381    ///
382    /// `prefer` can only re-select the zero card (the sole tuple entry);
383    /// `stuff_zero` rides through `merge`; `reading == "capital"` takes
384    /// `zh_to_cap`'s CAP_map arm, which performs **no** leading-一 strip
385    /// (11 → "壹拾壹").
386    fn kw_cardinal(
387        &self,
388        value: &BigInt,
389        stuff_zero: i64,
390        reading: Reading,
391        prefer: Option<&[String]>,
392    ) -> Result<String> {
393        let zero = select_alt(&ZERO_ALTS, prefer);
394        let mut cards = self.cards.clone();
395        if zero != ZERO_WORD {
396            cards.insert(BigInt::zero(), zero);
397        }
398        let eng = HkKwEngine {
399            base: self,
400            cards,
401            stuff_zero,
402            zero_word: zero,
403        };
404        let out = default_to_cardinal(&eng, value)?.replace(' ', "");
405        Ok(match reading {
406            Reading::Capital => apply_cap_map(&out, &HK_CAP_MAP),
407            _ => self.zh_to_cap_plain(&out),
408        })
409    }
410
411    /// `self.CURRENCY_FORMS[currency]`, with the `KeyError` → `NotImplementedError`
412    /// translation both `to_currency` and Base's `to_cheque` spell out
413    /// identically:
414    ///
415    /// ```python
416    /// except KeyError:
417    ///     raise NotImplementedError(
418    ///         'Currency code "%s" not implemented for "%s"'
419    ///         % (currency, self.__class__.__name__))
420    /// ```
421    ///
422    /// Returns the *bare string* ("歐羅"), which is what ZH's table holds — the
423    /// [`CurrencyForms`] wrapper is only a container, see [`bare`].
424    fn currency_string(&self, code: &str) -> Result<&str> {
425        self.currency_forms
426            .get(code)
427            // Indexing is safe: `bare()` always writes exactly one unit form.
428            .map(|f| f.unit[0].as_str())
429            .ok_or_else(|| {
430                N2WError::NotImplemented(format!(
431                    "Currency code \"{}\" not implemented for \"{}\"",
432                    code,
433                    self.lang_name()
434                ))
435            })
436    }
437
438    /// `self.cards[d]` for one digit. A miss is Python's `KeyError`;
439    /// unreachable, since `set_low_numwords` fills 0..=9.
440    fn card(&self, d: u32) -> Result<&str> {
441        self.cards
442            .get(&BigInt::from(d))
443            .ok_or_else(|| N2WError::Key(format!("{}", d)))
444    }
445
446    /// `Num2Word_ZH.to_currency_float(value, reading=False, prefer=None)`.
447    ///
448    /// ```python
449    /// cents = "%02d" % value
450    /// out = []
451    /// if int(cents) > 0:
452    ///     if not (int(cents[0]) == 0 and reading == "capital"):
453    ///         out += [self.cards[int(cents[0])]]
454    ///     if int(cents[0]) > 0:
455    ///         out += [self.CURRENCY_FLOATS[0]]
456    ///     if int(cents[1]) > 0:
457    ///         out += [self.cards[int(cents[1])], self.CURRENCY_FLOATS[1]]
458    /// return out
459    /// ```
460    ///
461    /// `value` is a Python `int`: `to_currency` runs `parse_currency_parts`
462    /// with `keep_precision=False`, whose float branch ends
463    /// `cents = int(fraction * divisor)`. It is also always in `0..=99`
464    /// (`fraction < 1` and `divisor` is 100) and never negative — the parse
465    /// `abs()`es first — so `"%02d"` yields exactly two characters and
466    /// `int(cents) > 0` reduces to `value > 0`.
467    ///
468    /// Two asymmetries worth not "tidying":
469    ///
470    /// * the 毫 unit is emitted only when the 1/10 digit is non-zero, but the
471    ///   *digit word* is emitted regardless (the `reading == "capital"` guard
472    ///   on it is dead here) — that is what puts the bare 零 in 0.01's
473    ///   "零元**零**一仙" while 0.50 gets "五毫" and no 仙 at all;
474    /// * the 仙 unit, by contrast, is bound to its own digit, so a trailing
475    ///   zero subunit vanishes rather than reading "…零仙".
476    fn to_currency_float(&self, value: &BigInt) -> Result<Vec<&str>> {
477        let mut out: Vec<&str> = Vec::new();
478        if !value.is_positive() {
479            return Ok(out);
480        }
481
482        // `"%02d" % value`. Zero-padded through the string, as
483        // `currency::default_cents_terse` does — `BigInt`'s `Display` routes
484        // through `pad_integral` and would not honour a bare `{:02}`.
485        let cents = format!("{:0>2}", value.to_string());
486        let digits: Vec<char> = cents.chars().collect();
487        // `cents[0]` / `cents[1]` — Python indexes str by character. Padding
488        // guarantees at least two; a hypothetical 3-digit value would take the
489        // leading two, exactly as Python's subscripts do.
490        let d0 = py_int_digit(digits[0])?;
491        let d1 = py_int_digit(digits[1])?;
492
493        out.push(self.card(d0)?);
494        if d0 > 0 {
495            out.push(CURRENCY_FLOATS[0]);
496        }
497        if d1 > 0 {
498            out.push(self.card(d1)?);
499            out.push(CURRENCY_FLOATS[1]);
500        }
501        Ok(out)
502    }
503}
504
505/// `int(ch)` for a single character. Python raises `ValueError` on a
506/// non-digit; unreachable here, since the caller's string comes from
507/// `"%02d" % <non-negative int>`.
508fn py_int_digit(ch: char) -> Result<u32> {
509    ch.to_digit(10).ok_or_else(|| {
510        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
511    })
512}
513
514/// `len(str(n))` — Python's decimal digit count (a leading "-" would count,
515/// exactly as `BigInt`'s `Display` renders it; in `merge` these are always
516/// non-negative anyway). Returned as `i64` because `merge` subtracts two of
517/// these and the difference is routinely negative.
518fn py_len_str(n: &BigInt) -> i64 {
519    n.to_string().len() as i64
520}
521
522/// `Num2Word_ZH.merge`, verbatim, with the two per-call inputs live:
523/// `self.stuff_zero` (the `to_cardinal` kwarg) and
524/// `select_text(self.low_numwords[-1])` (the 零/〇 the `prefer` kwarg picks).
525fn zh_merge(
526    l: (&str, &BigInt),
527    r: (&str, &BigInt),
528    stuff_zero: i64,
529    zero_word: &str,
530) -> (String, BigInt) {
531    let (ltext, lnum) = l;
532    let (rtext, rnum) = r;
533
534    // ignore lpair if lnum is 1 and rnum is less than 10
535    if lnum.is_one() && rnum < &BigInt::from(10) {
536        return (rtext.to_string(), rnum.clone());
537    }
538
539    // stuff_zero logic between discontinous numbers
540    // http://www.hkame.org.hk/uploaded_files/magazine/15/271.pdf
541    let with_zero = || (format!("{}{}{}", ltext, zero_word, rtext), lnum + rnum);
542    let no_zero = || (format!("{}{}", ltext, rtext), lnum + rnum);
543
544    let ldigits = py_len_str(lnum);
545    let rdigits = py_len_str(rnum);
546
547    if ldigits - rdigits > 1 {
548        match stuff_zero {
549            // 凡「零」必讀 — every discontinuity is spoken.
550            1 => return with_zero(),
551            // Discontinous high numbers. The Python inner test re-checks
552            // `len(str(lnum)) - len(str(rnum)) > 1`, which the outer `if`
553            // already established; only the `% 4` half carries information.
554            2 => {
555                if rdigits % 4 != 0 {
556                    return with_zero();
557                }
558                return no_zero();
559            }
560            // 凡「零」不讀 — never.
561            3 => return no_zero(),
562            // Python's if/elif chain has no else: any other stuff_zero
563            // (None, 4, "2", ...) falls through to the trailing
564            // `return no_zero` — `None == 1` etc. are all simply False.
565            _ => {}
566        }
567    } else if rnum > lnum {
568        return (format!("{}{}", ltext, rtext), lnum * rnum);
569    }
570    no_zero()
571}
572
573// ---- grammatical kwargs (reading / prefer / stuff_zero / counter) ----------
574
575/// The three states `reading` can put `select_text`/`zh_to_cap` in: Python
576/// tests `reading is True` (only the bool `True`) and `reading == "capital"`;
577/// everything else behaves like the default `False`.
578#[derive(Clone, Copy, PartialEq)]
579enum Reading {
580    Plain,
581    True,
582    Capital,
583}
584
585fn parse_reading(kw: &Kwargs) -> Reading {
586    match kw.get("reading") {
587        Some(KwVal::Bool(true)) => Reading::True,
588        Some(KwVal::Str(s)) if s == "capital" => Reading::Capital,
589        _ => Reading::Plain,
590    }
591}
592
593/// `prefer` as the item set `select_text` intersects with. A list keeps its
594/// members, a str iterates characters (`set("〇") == {"〇"}`); a non-iterable
595/// raises TypeError inside Python's `set()` — NotImplemented lets the
596/// dispatcher fall back to the original, which owns both that raise and the
597/// silently-succeeding cases where no tuple card is ever selected.
598fn parse_prefer(kw: &Kwargs) -> Result<Option<Vec<String>>> {
599    match kw.get("prefer") {
600        None | Some(KwVal::None) => Ok(None),
601        Some(KwVal::List(l)) => Ok(Some(l.clone())),
602        Some(KwVal::Str(s)) => Ok(Some(s.chars().map(|c| c.to_string()).collect())),
603        Some(_) => Err(N2WError::Fallback("kwargs".into())),
604    }
605}
606
607/// `stuff_zero`, defaulting to 2. Bools are Python ints; any other type
608/// compares unequal to 1/2/3 and takes `zh_merge`'s fall-through arm (0).
609fn parse_stuff_zero(kw: &Kwargs) -> i64 {
610    match kw.get("stuff_zero") {
611        None => 2,
612        Some(KwVal::Int(i)) => *i,
613        Some(KwVal::Bool(b)) => *b as i64,
614        Some(_) => 0,
615    }
616}
617
618/// `counter`: only a `str` short-circuits `select_text`; anything else hits
619/// `len(text)` there and raises TypeError — delegated back to Python.
620fn parse_counter(kw: &Kwargs) -> Result<&str> {
621    match kw.get("counter") {
622        None => Ok(""),
623        Some(KwVal::Str(s)) => Ok(s),
624        Some(_) => Err(N2WError::Fallback("kwargs".into())),
625    }
626}
627
628/// `select_text` over a tuple of plain-`str` alternatives (the only tuple
629/// shape ZH's tables hold): the single member of `set(alts) & set(prefer)`
630/// when exactly one matches, else the first alternative.
631fn select_alt<'a>(alts: &[&'a str], prefer: Option<&[String]>) -> &'a str {
632    if let Some(p) = prefer {
633        let hits: Vec<&str> = alts
634            .iter()
635            .copied()
636            .filter(|a| p.iter().any(|x| x == a))
637            .collect();
638        if hits.len() == 1 {
639            return hits[0];
640        }
641    }
642    alts[0]
643}
644
645/// `zh_to_cap`'s capital arm: sequential `str.replace` over `CAP_map`.
646fn apply_cap_map(value: &str, map: &[(&str, &str)]) -> String {
647    let mut out = value.to_string();
648    for (plain, cap) in map {
649        if out.contains(plain) {
650            out = out.replace(plain, cap);
651        }
652    }
653    out
654}
655
656/// `Num2Word_ZH_HK`'s runtime `CAP_map`: the parent `Num2Word_ZH` table
657/// (traditional capitals, *with* the `("正", "整")` row ZH_CN drops) plus the
658/// two `CAP_map_CHILD` rows `__init__` appends (毫 → 角, 仙 → 分).
659const HK_CAP_MAP: [(&str, &str); 16] = [
660    ("千", "仟"),
661    ("百", "佰"),
662    ("十", "拾"),
663    ("九", "玖"),
664    ("八", "捌"),
665    ("七", "柒"),
666    ("六", "陸"),
667    ("五", "伍"),
668    ("四", "肆"),
669    ("三", "叁"),
670    ("二", "貳"),
671    ("一", "壹"),
672    ("元", "圓"),
673    ("正", "整"),
674    ("毫", "角"),
675    ("仙", "分"),
676];
677
678/// The `("零", "〇")` card — the only `prefer`-selectable table entry.
679const ZERO_ALTS: [&str; 2] = ["零", "〇"];
680/// `year_prefix = ("公元", "西元")` — the other `prefer`-selectable tuple.
681const YEAR_PREFIX_ALTS: [&str; 2] = ["公元", "西元"];
682
683/// Digits 0-9 as `select_text(self.cards[d])` yields them at the defaults
684/// (identical glyphs in both scripts; 0 is the tuple's first form).
685const HK_DIGITS: [&str; 10] = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
686
687/// A per-call view of the engine with the `to_cardinal` kwargs live.
688struct HkKwEngine<'a> {
689    base: &'a LangZhHk,
690    cards: Cards,
691    stuff_zero: i64,
692    zero_word: &'static str,
693}
694
695impl Lang for HkKwEngine<'_> {
696    fn cards(&self) -> &Cards {
697        &self.cards
698    }
699    fn maxval(&self) -> &BigInt {
700        self.base.maxval()
701    }
702    fn negword(&self) -> &str {
703        self.base.negword()
704    }
705    fn pointword(&self) -> &str {
706        self.base.pointword()
707    }
708    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
709        zh_merge(l, r, self.stuff_zero, self.zero_word)
710    }
711}
712
713/// Python's `str(<float>)` for a finite value — fills the `%s` of the
714/// TypeError format strings (`errmsg_floatord`/`errmsg_negord`/
715/// `errmsg_floatyear`). The corpora record only the type; the message
716/// mirrors CPython's spelling.
717fn py_float_str(value: f64, precision: u32) -> String {
718    let a = value.abs();
719    let mag = if a != 0.0 && (a >= 1e16 || a < 1e-4) {
720        let s = format!("{:e}", a);
721        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
722        let exp: i32 = exp.parse().expect("exponent is a small int");
723        format!(
724            "{}e{}{:02}",
725            mant,
726            if exp < 0 { '-' } else { '+' },
727            exp.abs()
728        )
729    } else {
730        format!("{:.*}", precision as usize, a)
731    };
732    if value.is_sign_negative() {
733        format!("-{}", mag)
734    } else {
735        mag
736    }
737}
738
739/// `str(value)` for either FloatValue arm (Decimal's `%s` is its `str`).
740fn py_value_str(value: &FloatValue) -> String {
741    match value {
742        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
743        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
744    }
745}
746
747impl Lang for LangZhHk {
748    /// This language's own `to_currency(currency=...)` default,
749    /// read from the live Python signature. Only 44 of 156 use EUR.
750    fn default_currency(&self) -> &str {
751        "XXX"
752    }
753
754    /// This language's own `to_currency(separator=...)` default,
755    /// read from the live Python signature. Base's is ",", but only
756    /// 36 of 149 languages actually use it — most default to " " or a
757    /// conjunction, so inheriting Base's comma silently corrupts them.
758    fn default_separator(&self) -> &str {
759        ""
760    }
761
762    fn cards(&self) -> &Cards {
763        &self.cards
764    }
765
766    fn maxval(&self) -> &BigInt {
767        &self.maxval
768    }
769
770    fn negword(&self) -> &str {
771        "負"
772    }
773
774    fn pointword(&self) -> &str {
775        "點"
776    }
777
778    /// `Num2Word_ZH.merge`.
779    ///
780    /// `select_text(ltext)`/`select_text(rtext)` are identities at the default
781    /// reading (see module docs), so they are elided; likewise
782    /// `select_text(self.low_numwords[-1])` is [`ZERO_WORD`] at the defaults.
783    /// The body lives in [`zh_merge`], shared with the kwargs engine, which
784    /// carries the live `stuff_zero` and the `prefer`-selected zero.
785    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
786        zh_merge(l, r, STUFF_ZERO as i64, ZERO_WORD)
787    }
788
789    /// `Num2Word_ZH.to_cardinal(value, stuff_zero=2, reading=False, prefer=None)`:
790    ///
791    /// ```python
792    /// out = super().to_cardinal(value).replace(" ", "")
793    /// return self.zh_to_cap(out, reading == "capital")
794    /// ```
795    ///
796    /// The `replace(" ", "")` is what welds the negword on: the base emits
797    /// `"負 " + words` (from `"%s " % self.negword.strip()`), and the space is
798    /// then squeezed out → "負一". It also strips the separators `merge` never
799    /// inserts, making it a no-op for positives.
800    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
801        let out = default_to_cardinal(self, value)?.replace(' ', "");
802        Ok(self.zh_to_cap_plain(&out))
803    }
804
805    /// `Num2Word_ZH.to_ordinal(value, counter="", reading=False, prefer=None)`:
806    ///
807    /// ```python
808    /// self.verify_ordinal(value)
809    /// base = self.to_cardinal(value, reading=reading, prefer=prefer)
810    /// return "%s%s%s" % (select_text(self.ord_prefix), base, select_text(counter))
811    /// ```
812    ///
813    /// `ord_prefix` is the plain string "第"; `counter` defaults to `""` and
814    /// `select_text("")` short-circuits on `isinstance(text, strtype)` → "".
815    /// Note the 第 is prepended *after* `to_cardinal` has already run
816    /// `zh_to_cap`, so the strip still sees the bare numeral: 100000 →
817    /// "一十萬" → "十萬" → "第十萬".
818    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
819        self.verify_ordinal(value)?;
820        let base = self.to_cardinal(value)?;
821        Ok(format!("第{}", base))
822    }
823
824    /// `Num2Word_ZH.to_ordinal_num(value, counter="", ...)`:
825    ///
826    /// ```python
827    /// return "%s%s%s" % (select_text(self.ord_prefix), value, select_text(counter))
828    /// ```
829    ///
830    /// No `verify_ordinal`, no `to_cardinal`, no bounds check — the value is
831    /// interpolated with `%s`, i.e. `str(value)`. Bug #3: negatives sail
832    /// through, so `to_ordinal_num(-1)` == "第-1".
833    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
834        Ok(format!("第{}", value))
835    }
836
837    /// `Num2Word_ZH.to_year(value, reading=False, prefer=None)`.
838    ///
839    /// ```python
840    /// out = []
841    /// if value < 0:
842    ///     out += [self.year_prefix, self.year_bce]
843    /// elif reading == "capital":
844    ///     out += [self.year_prefix]
845    /// out += [self.cards[int(s)] for s in str(abs(int(value)))]
846    /// out += [self.year]
847    /// return "".join(self.select_text(s) for s in out)
848    /// ```
849    ///
850    /// Digit-by-digit, *not* via `to_cardinal` — so 10 → "一零年", not "十年",
851    /// and there is no `MAXVAL` guard (bug #4). `year_prefix` is the tuple
852    /// `("公元", "西元")` → "公元" at the default reading; `year_bce` = "前";
853    /// `year` = "年". The `reading == "capital"` arm is unreachable here, so a
854    /// positive year gets no prefix at all: 1 → "一年".
855    ///
856    /// The float guard (`if not value == int(value): raise TypeError`) is
857    /// vacuous for `BigInt` input and is omitted.
858    fn to_year(&self, value: &BigInt) -> Result<String> {
859        let mut out = String::new();
860        if value.is_negative() {
861            out.push_str("公元"); // select_text(("公元", "西元")) at default reading
862            out.push_str("前"); // year_bce
863        }
864
865        // str(abs(int(value))) — abs() first, so no sign char reaches the
866        // cards lookup.
867        for ch in value.abs().to_string().chars() {
868            let d = ch.to_digit(10).ok_or_else(|| {
869                // Unreachable: abs() of a BigInt renders as digits only.
870                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
871            })?;
872            let word = self
873                .cards
874                .get(&BigInt::from(d))
875                .ok_or_else(|| N2WError::Key(format!("{}", d)))?;
876            out.push_str(word);
877        }
878
879        out.push_str("年"); // self.year
880        Ok(out)
881    }
882
883    /// `Num2Word_ZH.to_cardinal_float(value)`:
884    ///
885    /// ```python
886    /// def to_cardinal_float(self, value):
887    ///     out = super().to_cardinal_float(value).replace(" ", "")
888    ///     return self.zh_to_cap(out, self.capital)
889    /// ```
890    ///
891    /// `super().to_cardinal_float` is `Num2Word_Base.to_cardinal_float` — the
892    /// [`default_to_cardinal_float`] port — which space-joins the integer part,
893    /// 點, and each fractional digit (`"零 點 五"`). ZH squeezes the spaces out
894    /// with the same `replace(" ", "")` its integer `to_cardinal` uses to weld
895    /// 負 on, then runs `zh_to_cap` over the whole string. The default trait
896    /// impl would return the *space-joined* form, so this override is required.
897    ///
898    /// The integer part is produced by `self.to_cardinal(pre)` inside the base
899    /// method, i.e. ZH's own wrapped `to_cardinal`, so its leading-一 strip has
900    /// already fired: `12.34` → `to_cardinal(12)` = "十二" → "十二點三四", and
901    /// `-12.34` → `to_cardinal(-12)` = "負一十二" (bug #1, the strip is
902    /// prefix-anchored and a leading 負 blocks it) → "負一十二點三四".
903    ///
904    /// Two Python facts pinned down here:
905    ///
906    /// * **ZH's override drops the `precision=` parameter.** Its signature is
907    ///   `to_cardinal_float(self, value)` — no `precision` — so it always calls
908    ///   `super().to_cardinal_float(value)` with `precision=None`, and base then
909    ///   lets `float2tuple` recompute `self.precision` from the value's own
910    ///   `repr`/`as_tuple().exponent`. `precision=` is therefore a no-op for
911    ///   this language (verified live: `num2words(0.5, lang="zh_HK",
912    ///   precision=5)` is still "零點五", not "零點五零零零零"). So
913    ///   `precision_override` is deliberately ignored and `None` threaded
914    ///   through — honouring it would pad/truncate the fraction and diverge.
915    ///   See `concerns`.
916    /// * **The outer `zh_to_cap` is a no-op.** The string always begins with
917    ///   the already-stripped integer part (or 負 when value<0 and pre==0),
918    ///   never a fresh "一十", so the leading-一 strip can never fire again
919    ///   (bug #9's idempotence). Applied anyway, exactly as Python does; the
920    ///   `capital` flag is `False` on every path that can reach Rust.
921    fn to_cardinal_float(
922        &self,
923        value: &FloatValue,
924        _precision_override: Option<u32>,
925    ) -> Result<String> {
926        let out = default_to_cardinal_float(self, value, None)?.replace(' ', "");
927        Ok(self.zh_to_cap_plain(&out))
928    }
929
930    // ---- currency ---------------------------------------------------------
931    //
932    // `Num2Word_ZH` overrides `to_currency` outright, so only `lang_name`,
933    // `currency_forms` and the two entry points below are language-specific.
934    // Everything else keeps its trait default *because Python does too*:
935    // `pluralize` raises (ZH has no plural forms and never calls it);
936    // `currency_adjective` is None and `currency_precision` is 100 (both dicts
937    // are empty); `money_verbose` is `to_cardinal` (Base's, used by
938    // `to_cheque`); `cents_verbose`/`cents_terse` are unreachable.
939
940    fn lang_name(&self) -> &str {
941        "Num2Word_ZH_HK"
942    }
943
944    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
945        self.currency_forms.get(code)
946    }
947
948    /// `Num2Word_ZH.to_currency`:
949    ///
950    /// ```python
951    /// self.set_str_selection(reading, prefer)
952    /// left, right, is_negative = parse_currency_parts(val, is_int_with_cents=False)
953    /// try:
954    ///     cr = self.CURRENCY_FORMS[currency]
955    /// except KeyError:
956    ///     raise NotImplementedError(...)
957    /// minus_str = self.negword if is_negative else ""
958    /// money_str = self.to_cardinal(left, reading=reading, prefer=prefer)
959    /// if currency == "XXX":
960    ///     cr_pre, cr_post = ("", cr)
961    /// else:
962    ///     cr_pre, cr_post = (cr, self.CURRENCY_FORMS["XXX"])
963    /// cents_str = self.to_currency_float(right, reading=reading, prefer=prefer)
964    /// cheque = self.cheque_suffix if len(cents_str) == 0 and reading == "capital" else ""
965    /// for c in [minus_str, money_str, cr_post, *cents_str, cheque]:
966    ///     cr_pre += self.zh_to_cap(self.select_text(c), reading == "capital")
967    /// return cr_pre
968    /// ```
969    ///
970    /// `cents`, `separator` and `adjective` are in ZH's signature and are never
971    /// read by its body — there is no `_cents_verbose` call to switch, nothing
972    /// to interpolate a separator between, and a `# CURRENCY_ADJECTIVES are not
973    /// implemented` comment where the prefix would go. Hence the three `_`
974    /// parameters: ignoring them is the port, not an omission. (`separator`
975    /// still arrives pre-resolved through `default_separator`, which is `""`
976    /// for this language — see the override above.)
977    fn to_currency(
978        &self,
979        val: &CurrencyValue,
980        currency: &str,
981        _cents: bool,
982        _separator: Option<&str>,
983        _adjective: bool,
984    ) -> Result<String> {
985        // `parse_currency_parts(val, is_int_with_cents=False)` — ZH passes that
986        // one kwarg and nothing else, so `keep_precision=False` and
987        // `divisor=100` stay at *currency.py's* defaults. The 100 is hardcoded
988        // rather than read from `currency_precision()` on purpose: ZH never
989        // consults CURRENCY_PRECISION, so a hypothetical KWD entry would not
990        // make this path use 1000. The two happen to agree only because ZH's
991        // CURRENCY_PRECISION is empty.
992        //
993        // Consequences worth naming: for a true `int` the float branch is
994        // skipped entirely and `right` is 0, so ints never print subunits; for
995        // a float, `keep_precision=False` means the value is first quantized to
996        // 2dp ROUND_HALF_UP (2.675 → 2.68 → 二元六毫八仙) and fractional cents
997        // can never survive — which is why `cardinal_from_decimal` stays at its
998        // default and this language needs no fractional-cents branch.
999        let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
1000        // `cents = int(fraction * divisor)` already truncated to scale 0.
1001        let right = right.as_bigint_and_exponent().0;
1002
1003        let cr = self.currency_string(currency)?;
1004
1005        // `self.negword`, *not* Base's `"%s " % self.negword.strip()`: ZH adds
1006        // no trailing space, because nothing in the output is space-separated.
1007        let minus_str = if is_negative { self.negword() } else { "" };
1008
1009        // `self.to_cardinal(left, ...)` — the *absolute* integer part, since
1010        // `parse_currency_parts` split the sign off into `is_negative`. So the
1011        // leading-一 strip fires normally and -12.34 reads 負 + 十二; contrast
1012        // module-doc bug #1, where `to_cardinal(-12)` on its own yields the
1013        // unstripped 負一十二.
1014        let money_str = self.to_cardinal(&left)?;
1015
1016        // A non-XXX code prefixes its own name and *still* closes the integer
1017        // part with the generic 元: "歐羅一百元", not "歐羅一百歐羅".
1018        let (mut cr_pre, cr_post) = if currency == "XXX" {
1019            (String::new(), cr)
1020        } else {
1021            // `self.CURRENCY_FORMS["XXX"]` is a subscript, so a miss here would
1022            // be KeyError rather than the NotImplementedError above — but "XXX"
1023            // is a class-body literal that no override removes.
1024            let xxx = self
1025                .currency_forms
1026                .get("XXX")
1027                .expect("XXX is a class-body literal of Num2Word_ZH.CURRENCY_FORMS");
1028            (cr.to_string(), xxx.unit[0].as_str())
1029        };
1030
1031        let cents_str = self.to_currency_float(&right)?;
1032
1033        // `cheque = self.cheque_suffix if len(cents_str) == 0 and reading ==
1034        // "capital" else ""`. 正 is only ever appended in a capital reading,
1035        // which cannot reach this path, so `cheque` is always "" and its
1036        // append is a no-op. Omitted rather than modelled as `push_str("")`.
1037        //
1038        // `select_text(c)` is the identity on every fragment here: they are all
1039        // plain `str`, and the one tuple-valued card (`cards[0]`) is stored
1040        // pre-selected as 零. `zh_to_cap(c, False)` is likewise a no-op on all
1041        // of them (bug #9) but is applied anyway, as Python does.
1042        for c in [minus_str, money_str.as_str(), cr_post]
1043            .into_iter()
1044            .chain(cents_str)
1045        {
1046            cr_pre.push_str(&self.zh_to_cap_plain(c));
1047        }
1048        Ok(cr_pre)
1049    }
1050
1051    /// `Num2Word_Base.to_cheque`, inherited unchanged — `Num2Word_ZH` overrides
1052    /// `to_currency` but leaves this alone. Reimplemented here instead of
1053    /// delegating to [`crate::currency::default_to_cheque`] for exactly one
1054    /// reason: Base unpacks the currency entry as a 2-tuple, and ZH's entries
1055    /// are strings.
1056    ///
1057    /// ```python
1058    /// try:
1059    ///     cr1, _cr2 = self.CURRENCY_FORMS[currency]
1060    /// except KeyError:
1061    ///     raise NotImplementedError(...)
1062    /// ...
1063    /// unit = cr1[-1] if isinstance(cr1, tuple) else cr1
1064    /// ```
1065    ///
1066    /// **Bug #7.** Unpacking a `str` iterates its *characters*, so `cr1, _cr2 =
1067    /// "歐羅"` binds `cr1 = "歐"`, `_cr2 = "羅"` — and the `except` clause
1068    /// catches only `KeyError`, so the unpack's `ValueError` escapes uncaught.
1069    /// Every outcome below is pinned by the frozen corpus:
1070    ///
1071    /// | code | value | chars | `to_cheque(1234.56)` |
1072    /// |---|---|---|---|
1073    /// | EUR | 歐羅 | 2 | `"一千二百三十四 AND 56/100 歐"` — **half the name** |
1074    /// | CNY | 人民幣 | 3 | `ValueError: too many values to unpack (expected 2)` |
1075    /// | CHF | 瑞士法郎 | 4 | `ValueError: too many values to unpack (expected 2)` |
1076    /// | XXX | 元 | 1 | `ValueError: not enough values to unpack (expected 2, got 1)` |
1077    /// | KWD | — | — | `KeyError` → `NotImplementedError` |
1078    ///
1079    /// So `to_cheque` "succeeds" for only 9 of the 17 codes (AUD CAD EUR GBP
1080    /// HKD JPY KRW THB USD — the two-character ones), and even then prints just
1081    /// the first half of the currency name. The other 8 raise `ValueError` on a
1082    /// perfectly valid code. `N2WError::Value` is the right variant precisely
1083    /// *because* Python's failure is a `ValueError` and not a currency error —
1084    /// mapping it to `NotImplemented` would erase the distinction the corpus
1085    /// tests. Do not "fix" any of this.
1086    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
1087        let cr = self.currency_string(currency)?;
1088
1089        // `cr1, _cr2 = <str>` — an iterable unpack over characters.
1090        let chars: Vec<char> = cr.chars().collect();
1091        if chars.len() != 2 {
1092            return Err(N2WError::Value(if chars.len() < 2 {
1093                format!(
1094                    "not enough values to unpack (expected 2, got {})",
1095                    chars.len()
1096                )
1097            } else {
1098                "too many values to unpack (expected 2)".to_string()
1099            }));
1100        }
1101        // `unit = cr1[-1] if isinstance(cr1, tuple) else cr1` — cr1 is a
1102        // one-character `str`, never a tuple, so `unit` is cr1 itself.
1103        let unit = chars[0];
1104
1105        // `self.CURRENCY_PRECISION.get(currency, 100)` — empty dict, so 100 for
1106        // every code. Read through the hook, as Base reads the dict.
1107        let divisor = self.currency_precision(currency);
1108        let is_negative = val.is_negative();
1109        let abs_val = val.abs();
1110        // `whole = int(abs_val)` — truncation, which on a non-negative equals
1111        // the floor `with_scale(0)` performs.
1112        let whole = abs_val.with_scale(0).as_bigint_and_exponent().0;
1113
1114        let fraction_str = if divisor > 1 {
1115            let sub = (&abs_val - BigDecimal::from(whole.clone())) * BigDecimal::from(divisor);
1116            let sub = sub.with_scale(0).as_bigint_and_exponent().0;
1117            let digits = divisor.to_string().len() - 1;
1118            format!("{:0>width$}/{}", sub.to_string(), divisor, width = digits)
1119        } else {
1120            String::new()
1121        };
1122
1123        // Base's `_money_verbose`, i.e. `self.to_cardinal(whole)` — ZH's
1124        // wrapped one, so 1234 → 一千二百三十四.
1125        let words = self.money_verbose(&whole, currency)?;
1126        let sign = if is_negative { "MINUS " } else { "" };
1127        let body = if fraction_str.is_empty() {
1128            format!("{} {}", words, unit)
1129        } else {
1130            format!("{} AND {} {}", words, fraction_str, unit)
1131        };
1132        // `.upper()` — a no-op on CJK; the literals are already uppercase.
1133        Ok(format!("{}{}", sign, body).to_uppercase())
1134    }
1135
1136    // ---- float/Decimal entry routing --------------------------------------
1137
1138    /// `to_ordinal(float/Decimal)`: `Num2Word_Base.verify_ordinal` runs with
1139    /// both arms live. Non-whole → TypeError(`errmsg_floatord`) *first*;
1140    /// whole-but-negative → TypeError(`errmsg_negord`); `-0.0` passes both
1141    /// (`int(-0.0) == -0.0`, `abs(-0.0) == -0.0`) → "第零". A whole value
1142    /// then takes `to_cardinal`'s integer path, prefixed with 第.
1143    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1144        match value.as_whole_int() {
1145            None => Err(N2WError::Type(format!(
1146                "Cannot treat float {} as ordinal.",
1147                py_value_str(value)
1148            ))),
1149            Some(i) => {
1150                if i.is_negative() {
1151                    return Err(N2WError::Type(format!(
1152                        "Cannot treat negative num {} as ordinal.",
1153                        py_value_str(value)
1154                    )));
1155                }
1156                Ok(format!("第{}", self.to_cardinal(&i)?))
1157            }
1158        }
1159    }
1160
1161    /// `to_ordinal_num(float/Decimal)`: no verification — `str(value)`
1162    /// between 第 and the (default-empty) counter: "第-0.0", "第1e+16".
1163    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
1164        Ok(format!("第{}", repr_str))
1165    }
1166
1167    /// `to_year(float/Decimal)`: `if not value == int(value)` raises
1168    /// TypeError(`errmsg_floatyear`); whole values render digit-by-digit
1169    /// through the integer `to_year` (`int(-0.0)` is 0 → no 公元前 prefix).
1170    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1171        match value.as_whole_int() {
1172            None => Err(N2WError::Type(format!(
1173                "Cannot treat float {} as year.",
1174                py_value_str(value)
1175            ))),
1176            Some(i) => self.to_year(&i),
1177        }
1178    }
1179
1180    // ---- grammatical kwargs ------------------------------------------------
1181
1182    /// `to_cardinal(value, stuff_zero=2, reading=False, prefer=None)`.
1183    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1184        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
1185            return Err(N2WError::Fallback("kwargs".into()));
1186        }
1187        let prefer = parse_prefer(kw)?;
1188        self.kw_cardinal(
1189            value,
1190            parse_stuff_zero(kw),
1191            parse_reading(kw),
1192            prefer.as_deref(),
1193        )
1194    }
1195
1196    /// `to_ordinal(value, counter="", reading=False, prefer=None)`:
1197    /// `verify_ordinal` (negatives raise) then the kwargs cardinal at the
1198    /// default `stuff_zero=2` — Python forwards only `reading`/`prefer`.
1199    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1200        if !kw.only(&["counter", "reading", "prefer"]) {
1201            return Err(N2WError::Fallback("kwargs".into()));
1202        }
1203        let counter = parse_counter(kw)?;
1204        let prefer = parse_prefer(kw)?;
1205        let reading = parse_reading(kw);
1206        self.verify_ordinal(value)?;
1207        let base = self.kw_cardinal(value, 2, reading, prefer.as_deref())?;
1208        Ok(format!("第{}{}", base, counter))
1209    }
1210
1211    /// `to_ordinal_num(value, counter="", reading=False, prefer=None)`:
1212    /// "第" + `str(value)` + counter; `reading`/`prefer` are inert (only
1213    /// plain strings ever reach `select_text` here).
1214    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1215        if !kw.only(&["counter", "reading", "prefer"]) {
1216            return Err(N2WError::Fallback("kwargs".into()));
1217        }
1218        let counter = parse_counter(kw)?;
1219        Ok(format!("第{}{}", value, counter))
1220    }
1221
1222    /// `to_year(value, reading=False, prefer=None)`: the `elif reading ==
1223    /// "capital"` arm prefixes a positive year with 公元 — the digits stay
1224    /// un-capitalized (`to_year` never calls `zh_to_cap`). `prefer` can
1225    /// re-select the 零/〇 digit and the 公元/西元 prefix.
1226    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1227        if !kw.only(&["reading", "prefer"]) {
1228            return Err(N2WError::Fallback("kwargs".into()));
1229        }
1230        let prefer = parse_prefer(kw)?;
1231        let reading = parse_reading(kw);
1232        let p = prefer.as_deref();
1233
1234        let mut out = String::new();
1235        if value.is_negative() {
1236            out.push_str(select_alt(&YEAR_PREFIX_ALTS, p));
1237            out.push_str("前");
1238        } else if reading == Reading::Capital {
1239            out.push_str(select_alt(&YEAR_PREFIX_ALTS, p));
1240        }
1241        for ch in value.abs().to_string().chars() {
1242            let d = ch.to_digit(10).expect("abs(BigInt) is all digits") as usize;
1243            if d == 0 {
1244                out.push_str(select_alt(&ZERO_ALTS, p));
1245            } else {
1246                out.push_str(HK_DIGITS[d]);
1247            }
1248        }
1249        out.push_str("年");
1250        Ok(out)
1251    }
1252
1253    /// `to_currency(val, ..., reading, prefer)` with the kwargs live. Every
1254    /// component runs through `zh_to_cap(select_text(c), capital)`; under
1255    /// capital that CAP-maps (元 → 圓, and HK's table *does* carry
1256    /// ("正", "整"), so the cheque suffix reads 整 — "零圓整") and the leading
1257    /// zero cents digit is skipped (`not (int(cents[0]) == 0 and reading ==
1258    /// "capital")`).
1259    fn to_currency_kw(
1260        &self,
1261        val: &CurrencyValue,
1262        currency: &str,
1263        cents: bool,
1264        separator: Option<&str>,
1265        adjective: bool,
1266        kw: &Kwargs,
1267    ) -> Result<String> {
1268        if !kw.only(&["reading", "prefer"]) {
1269            return Err(N2WError::Fallback("kwargs".into()));
1270        }
1271        if kw.is_empty() {
1272            return self.to_currency(val, currency, cents, separator, adjective);
1273        }
1274        let prefer = parse_prefer(kw)?;
1275        let reading = parse_reading(kw);
1276        let p = prefer.as_deref();
1277        let capital = reading == Reading::Capital;
1278
1279        let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
1280        let right = right.as_bigint_and_exponent().0;
1281
1282        let cr = self.currency_string(currency)?;
1283        let minus_str = if is_negative {
1284            self.negword().to_string()
1285        } else {
1286            String::new()
1287        };
1288        let money_str = self.kw_cardinal(&left, 2, reading, p)?;
1289        let (mut out, cr_post) = if currency == "XXX" {
1290            (String::new(), cr.to_string())
1291        } else {
1292            let xxx = self
1293                .currency_forms
1294                .get("XXX")
1295                .expect("XXX is a class-body literal of Num2Word_ZH.CURRENCY_FORMS");
1296            (cr.to_string(), xxx.unit[0].clone())
1297        };
1298
1299        // to_currency_float(right, reading, prefer) — the capital guard on
1300        // the leading zero digit is live here.
1301        let digit = |d: u32| -> String {
1302            if d == 0 {
1303                select_alt(&ZERO_ALTS, p).to_string()
1304            } else {
1305                HK_DIGITS[d as usize].to_string()
1306            }
1307        };
1308        let cents_num: u32 = right.to_string().parse().unwrap_or(0);
1309        let mut cents_parts: Vec<String> = Vec::new();
1310        if cents_num > 0 {
1311            let d0 = cents_num / 10;
1312            let d1 = cents_num % 10;
1313            if !(d0 == 0 && capital) {
1314                cents_parts.push(digit(d0));
1315            }
1316            if d0 > 0 {
1317                cents_parts.push(CURRENCY_FLOATS[0].to_string());
1318            }
1319            if d1 > 0 {
1320                cents_parts.push(digit(d1));
1321                cents_parts.push(CURRENCY_FLOATS[1].to_string());
1322            }
1323        }
1324        // cheque_suffix "正" — only in capital readings with no cents; the
1325        // loop's CAP-map pass turns it into 整.
1326        let cheque = if cents_parts.is_empty() && capital {
1327            "正".to_string()
1328        } else {
1329            String::new()
1330        };
1331
1332        for c in std::iter::once(minus_str)
1333            .chain(std::iter::once(money_str))
1334            .chain(std::iter::once(cr_post))
1335            .chain(cents_parts)
1336            .chain(std::iter::once(cheque))
1337        {
1338            let piece = if capital {
1339                apply_cap_map(&c, &HK_CAP_MAP)
1340            } else {
1341                self.zh_to_cap_plain(&c)
1342            };
1343            out.push_str(&piece);
1344        }
1345        Ok(out)
1346    }
1347
1348    /// `to_cardinal(float/Decimal, stuff_zero, reading, prefer)`. A whole
1349    /// value runs the integer engine with the kwargs live; a fractional one
1350    /// goes through `to_cardinal_float`, whose inner `self.to_cardinal(part)`
1351    /// calls reset every kwarg to its default — only the caller's final
1352    /// `zh_to_cap(out, reading == "capital")` survives.
1353    fn to_cardinal_float_kw(
1354        &self,
1355        value: &FloatValue,
1356        precision_override: Option<u32>,
1357        kw: &Kwargs,
1358    ) -> Result<String> {
1359        if !kw.only(&["stuff_zero", "reading", "prefer"]) {
1360            return Err(N2WError::Fallback("kwargs".into()));
1361        }
1362        if kw.is_empty() {
1363            return self.cardinal_float_entry(value, precision_override);
1364        }
1365        let prefer = parse_prefer(kw)?;
1366        let reading = parse_reading(kw);
1367        if let Some(i) = value.as_whole_int() {
1368            return self.kw_cardinal(&i, parse_stuff_zero(kw), reading, prefer.as_deref());
1369        }
1370        let out = self.to_cardinal_float(value, None)?;
1371        Ok(match reading {
1372            Reading::Capital => apply_cap_map(&out, &HK_CAP_MAP),
1373            _ => self.zh_to_cap_plain(&out),
1374        })
1375    }
1376}
1377
1378#[cfg(test)]
1379mod float_tests {
1380    use super::*;
1381    use std::str::FromStr;
1382
1383    /// Drive the float arm the way the binding does.
1384    ///
1385    /// `precision` is *not* derived here: Python computes it as
1386    /// `abs(Decimal(repr(value)).as_tuple().exponent)`, which depends on
1387    /// `repr(float)` and is deliberately not reimplemented in the core. Every
1388    /// value below carries the precision the live interpreter reported for it.
1389    fn f(value: f64, precision: u32) -> String {
1390        LangZhHk::new()
1391            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1392            .unwrap()
1393    }
1394
1395    /// Drive the Decimal arm — exact arbitrary precision, never an f64 cast.
1396    fn d(s: &str, precision: u32) -> String {
1397        LangZhHk::new()
1398            .to_cardinal_float(
1399                &FloatValue::Decimal {
1400                    value: BigDecimal::from_str(s).unwrap(),
1401                    precision,
1402                },
1403                None,
1404            )
1405            .unwrap()
1406    }
1407
1408    /// Every `"to": "cardinal"` corpus row for `zh_HK` whose `arg` has a dot
1409    /// and is non-integral, i.e. every row that actually reaches the float
1410    /// path. ("0.0" and "1.0" are corpus rows too, but they satisfy
1411    /// `int(v) == v` in `Num2Word_Base.to_cardinal` and take the integer
1412    /// branch, so they never arrive here.)
1413    #[test]
1414    fn corpus_cardinal_float() {
1415        let rows: &[(f64, u32, &str)] = &[
1416            (0.5, 1, "零點五"),
1417            (1.5, 1, "一點五"),
1418            (2.25, 2, "二點二五"),
1419            (3.14, 2, "三點一四"),
1420            (0.01, 2, "零點零一"),
1421            (0.1, 1, "零點一"),
1422            (0.99, 2, "零點九九"),
1423            (1.01, 2, "一點零一"),
1424            // pre == 12 → "十二": ZH's leading-一 strip has already fired
1425            // inside to_cardinal before 點 is appended.
1426            (12.34, 2, "十二點三四"),
1427            (99.99, 2, "九十九點九九"),
1428            (100.5, 1, "一百點五"),
1429            (1234.56, 2, "一千二百三十四點五六"),
1430            // pre == 0 → the 負 comes from to_cardinal_float itself
1431            // (`value < 0 and pre == 0`), then the space-squeeze welds it on.
1432            (-0.5, 1, "負零點五"),
1433            (-1.5, 1, "負一點五"),
1434            // Bug #1: to_cardinal(-12) is "負一十二" — the leading-一 strip is
1435            // prefix-anchored and the 負 blocks it — so the float form keeps
1436            // the 一 that positive 12.34 loses. Corpus-pinned; do not "fix".
1437            (-12.34, 2, "負一十二點三四"),
1438            // The f64-artefact pair. 1.005 → post 4.999999999999893…,
1439            // 2.675 → post 674.9999999999998; the `< 0.01` heuristic in
1440            // float2tuple rounds both back up rather than flooring them, and
1441            // 1.005's "5" is then zero-padded to "005".
1442            (1.005, 3, "一點零零五"),
1443            (2.675, 3, "二點六七五"),
1444        ];
1445        for (value, precision, want) in rows {
1446            assert_eq!(&f(*value, *precision), want, "value {}", value);
1447        }
1448    }
1449
1450    /// Every `"to": "cardinal_dec"` corpus row for `zh_HK`. The Decimal arm is
1451    /// exact: "1.10" keeps its trailing zero (post = 10, precision 2 → 一零),
1452    /// and 98_746_251_323_029.99 keeps its cents — the very case an f64 cast
1453    /// would corrupt at trillion scale (issue #603).
1454    #[test]
1455    fn corpus_cardinal_dec() {
1456        let rows: &[(&str, u32, &str)] = &[
1457            ("0.01", 2, "零點零一"),
1458            ("1.10", 2, "一點一零"),
1459            ("12.345", 3, "十二點三四五"),
1460            (
1461                "98746251323029.99",
1462                2,
1463                "九十八兆七千四百六十二億五千一百三十二萬三千零二十九點九九",
1464            ),
1465            ("0.001", 3, "零點零零一"),
1466        ];
1467        for (value, precision, want) in rows {
1468            assert_eq!(&d(value, precision.to_owned()), want, "value {}", value);
1469        }
1470    }
1471
1472    /// `Num2Word_ZH.to_cardinal_float(self, value)` has no `precision`
1473    /// parameter, so the `precision=` kwarg is dropped before Base ever sees
1474    /// it — verified live: `num2words(0.5, lang="zh_HK", precision=5)` is
1475    /// still "零點五", not "零點五零零零零". The override must therefore
1476    /// ignore `precision_override`.
1477    #[test]
1478    fn precision_override_is_dropped() {
1479        let out = LangZhHk::new()
1480            .to_cardinal_float(
1481                &FloatValue::Float {
1482                    value: 0.5,
1483                    precision: 1,
1484                },
1485                Some(5),
1486            )
1487            .unwrap();
1488        assert_eq!(out, "零點五");
1489    }
1490
1491    /// The strip *does* fire through the float path when nothing blocks it:
1492    /// pre == 11 → "一十一" → "十一", then 點五. (Live: 11.5 → "十一點五",
1493    /// 10.5 → "十點五" — 10 is a bare card and never had the 一.)
1494    #[test]
1495    fn leading_one_ten_strip_through_float_path() {
1496        assert_eq!(f(11.5, 1), "十一點五");
1497        assert_eq!(f(10.5, 1), "十點五");
1498    }
1499}