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