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