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}