num2words2_core/lang_ca.rs
1//! Port of `lang_CA.py` (Catalan), registered by `CONVERTER_CLASSES["ca"]`
2//! as `Num2Word_CA`.
3//!
4//! Shape: **engine**. `Num2Word_CA` subclasses `Num2Word_EUR` →
5//! `Num2Word_Base`, defines `high_numwords`/`mid_numwords`/`low_numwords` plus
6//! `merge`, and lets the base `to_cardinal` drive `splitnum`/`clean`. It then
7//! overrides `to_ordinal` / `to_ordinal_num` with hand-rolled algorithms.
8//!
9//! Inheritance chain actually walked:
10//! * `Num2Word_EUR.set_high_numwords` — pairs each high word with
11//! `range(3 + 6*len(high), 3, -6)`. CA sets `GIGA_SUFFIX = None` and
12//! `MEGA_SUFFIX = "ilió"`, so **only** the MEGA half fires and the cards
13//! land at 10^24/10^18/10^12/10^6. There is deliberately **no 10^9 card**
14//! — a milliard is spelled compositionally as "mil milions".
15//! * `Num2Word_EUR.gen_high_numwords([], [], lows)` — with empty units and
16//! tens the comprehension yields `[]`, so the Latin elision table is dead
17//! code here and the result is just `lows == ["quadr","tr","b","m"]`.
18//! * `Num2Word_Base.to_year(value)` → `self.to_cardinal(value)`; CA does not
19//! override it, so years are plain cardinals ("menys cinc-cents" for -500).
20//! * `Num2Word_Base.verify_ordinal` — negatives raise `TypeError`.
21//!
22//! `MAXVAL = 1000 * 10^24 = 10^27`; `to_cardinal(10^27)` raises OverflowError.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! Verified against the interpreter; all of these are wrong-looking but real.
27//!
28//! 1. **`ords_3` has no key 0**, so every exact multiple of ten from 30 to 90
29//! raises `KeyError`: `to_ordinal(30)`, `(40)`, … `(90)` all blow up, while
30//! `to_ordinal(31)` == "trenta-unè" works. 30 dies in the `value <= 30` arm
31//! (`frac = 30 % 10 == 0`), 40–90 in the `value < 100` arm
32//! (`value - dec == 0`). See [`LangCa::ords_3`].
33//! 2. **Missing space in the `< 1e18` arm.** The format is
34//! `"%s%s%s %s" % (cardinal, ords[dec], gender_stem, ...)` with no
35//! separator between `cardinal` and `ords[dec]`, so
36//! `to_ordinal(9999999)` == "nou**milionè** nou-cents…" and
37//! `to_ordinal(123456789)` == "cent vint-i-tres**milionè** quatre-cents…".
38//! The sibling `< 1e6` arm *does* include the space ("dotze mil …").
39//! 3. **`value >= 1e18` chops a character and glues on "onè".**
40//! `part1[:-1] + "onè"` turns "un trilió" into "un trilionè" (fine) but
41//! "mil trilions" into "mil trilion**onè**" and
42//! `to_cardinal(10^27 - 1)` into a string ending "…noranta-no**onè**".
43//! 4. **`int(math.log(v, 1000))` overshoots on two float-rounding windows.**
44//! `ln` is computed in `f64`, and for v just below 10^15 or 10^18 the
45//! result rounds up to exactly `5.0` / `6.0`:
46//! * v ∈ [999999999999996, 999999999999999] → `dec = 10^15 > v`, so
47//! `divmod` gives `high_part = 0`, `low_part = v`, and
48//! `to_ordinal(low_part)` recurses on the *same value* forever →
49//! Python raises **RecursionError**. See [`RECURSION_LIMIT`].
50//! * v ∈ [999999999999995072, 10^18) → `dec = 10^18`, which is absent from
51//! `ords` → **KeyError**. This is why `to_ordinal(10^18 - 1)` raises.
52//! [`log1000_trunc`] reproduces the float computation rather than taking an
53//! exact integer logarithm, precisely so these windows survive.
54//! 5. **`to_ordinal(0)` returns the empty string** (not "zero"-ish), because
55//! the first arm sets `text = ""` and the function ends with `.strip()`.
56//! `to_ordinal(200)` == "dos-cents" (no ordinal suffix at all) for the same
57//! reason: the trailing `to_ordinal(0)` contributes nothing and `.strip()`
58//! eats the space.
59//! 6. `to_ordinal(1234567890)` raises `KeyError` — it reaches
60//! `to_ordinal(90)`, which is bug 1.
61//! 7. **Negative integer currency amounts get a double space**, and
62//! `adjective=True` is ignored for ints but honoured for floats — CA's
63//! `to_currency` handles ints itself and delegates floats to the base
64//! class, and the two arms disagree. See [`LangCa::to_currency`].
65//! 8. **`CURRENCY_FORMS` typos are load-bearing output**: `SLL` is
66//! `("leonE", "leones")` and `STD` is `("dobra", "dobrAs")` — mid-word
67//! capitals in the *source dict*, so `to_currency(1, "SLL")` really is
68//! "un leonE". `ALL`'s singular is misspelt `"qqindarka"`. Copied verbatim.
69//! 9. **`GBP`/`INR`/… take masculine "un" against a feminine noun**
70//! ("un lliura", "un rupia"): the int arm renders the number with plain
71//! `to_cardinal`, which has no gender agreement. In the corpus as-is.
72//! 10. **CA's `to_cardinal_float` un-fixes issue #603.** It calls
73//! `self.float2tuple(float(value))`, float-casting Decimal input that
74//! base.py deliberately keeps exact, so `Decimal("98746251323029.99")`
75//! renders "…coma noranta-vuit" (98) rather than 99, and
76//! `Decimal("1.10")` gives "un coma un" rather than "un coma un zero".
77//! Both are frozen in the corpus. See [`LangCa::to_cardinal_float`].
78//! 11. **A fraction of exactly zero can leave a dangling "coma".** The
79//! pointword is appended unconditionally (base guards it with
80//! `if self.precision:`), while the fraction's own word is guarded by
81//! `int(post_str) > 0 or leading_zeros == 0`. At precision 0 — reachable
82//! for 17-significant-digit doubles like `1.2345678901234568e16` — both
83//! combine into "…cinc-cents seixanta-vuit coma" with nothing after it.
84//!
85//! # Error variants
86//!
87//! `KeyError` → [`N2WError::Key`], `TypeError` → [`N2WError::Type`],
88//! `OverflowError` → [`N2WError::Overflow`].
89//!
90//! **RecursionError has no matching `N2WError` variant.** The four values in
91//! bug 4's first window are mapped to [`N2WError::Value`] by the depth guard in
92//! [`LangCa::to_ordinal_inner`]. That is knowingly the wrong exception *type*;
93//! it is chosen because the faithful alternative — unbounded recursion — would
94//! abort the process on stack overflow rather than return. Flagged in the port
95//! report.
96
97use crate::base::{clean, splitnum, Cards, Lang, N2WError, Node, Result};
98use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
99use crate::floatpath::{float2tuple, FloatValue};
100use bigdecimal::BigDecimal;
101use num_bigint::BigInt;
102use num_integer::Integer;
103use num_traits::{One, Signed, ToPrimitive, Zero};
104use std::collections::HashMap;
105use std::str::FromStr;
106
107/// `self.gender_stem`. CA hardcodes "è"; `to_ordinal_num`'s
108/// `"è" if self.gender_stem == "è" else "a"` therefore always picks "è".
109const GENDER_STEM: &str = "è";
110
111/// `self.negword`, kept with its trailing space exactly as Python sets it.
112/// `to_cardinal` uses `negword.strip()` + `" "`, so the space is cosmetic.
113const NEGWORD: &str = "menys ";
114
115/// `Num2Word_CA.to_currency`'s own default `separator=" amb"`.
116///
117/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
118const SEPARATOR_DEFAULT: &str = " amb";
119
120/// The separator the pyo3 binding passes when the Python caller omitted one.
121///
122/// `Num2Word_CA.to_currency` declares `separator=" amb"`, but the `Lang` trait
123/// has no per-language defaults: `__init__.py`'s fast path (and `diff_test.py`)
124/// substitute `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default —
125/// before the value ever reaches Rust. By then "caller omitted separator" and
126/// "caller explicitly passed a comma" are the same string, and the information
127/// needed to tell them apart no longer exists on this side of the boundary.
128///
129/// So `,` is read back as the unset sentinel and CA's own default restored.
130/// This is the only reading that matches the oracle: all 63 float rows of the
131/// `ca` currency corpus were generated by `num2words(v, lang="ca",
132/// to="currency", currency=c)` with no `separator=`, and every one of them
133/// expects " amb".
134///
135/// The cost is narrow and known: a caller who *explicitly* passes
136/// `separator=","` gets " amb" here where Python would give ",". Fixing that
137/// properly needs `Option<&str>` in the trait signature, which lives in
138/// `base.rs` — outside this port's remit. Flagged in the port report.
139const SEPARATOR_UNSET: &str = ",";
140
141/// Depth at which we stand in for CPython's RecursionError.
142///
143/// Python's default limit is 1000 frames and the ordinal recursion is only
144/// ever unbounded for the four values in module-doc bug 4. The exact frame
145/// count is not observable through the corpus (only the exception type is), so
146/// any limit comfortably above the deepest legitimate nesting (~4) works.
147const RECURSION_LIMIT: u32 = 900;
148
149/// Python's `int(math.log(int(value), 1000))`.
150///
151/// CPython's `loghelper` converts the int with `PyLong_AsDouble` (round to
152/// nearest) and calls C `log`; the two-argument form then divides by
153/// `log(1000.0)`. Both call sites are guarded by `value < 1e18`, so `to_u64`
154/// is lossless and `as f64` performs exactly `PyLong_AsDouble`'s rounding.
155///
156/// This deliberately does **not** compute an exact integer logarithm: the f64
157/// round-off is load-bearing (module-doc bug 4).
158fn log1000_trunc(value: &BigInt) -> u32 {
159 // math.log(1000) == 0x1.ba18a998fffa0p+2; this literal round-trips to the
160 // same bits under Rust's correctly-rounded float parsing.
161 const LN_1000: f64 = 6.907755278982137_f64;
162 let x = value
163 .to_u64()
164 .expect("log1000_trunc call sites are guarded by value < 1e18")
165 as f64;
166 // Python's int() truncates toward zero; the ratio is >= 1 here, so
167 // truncation and floor agree.
168 (x.ln() / LN_1000).trunc() as u32
169}
170
171fn pow10(e: u32) -> BigInt {
172 BigInt::from(10u8).pow(e)
173}
174
175/// Python's `abs(Decimal(str(value)).as_tuple().exponent)` for an `f64` — the
176/// precision `Num2Word_Base.float2tuple` derives from `repr` and assigns to
177/// `self.precision` on every call.
178///
179/// CA needs its own copy because it float-casts **Decimal** input too (see
180/// [`LangCa::to_cardinal_float`]), so the precision carried on
181/// `FloatValue::Decimal` — `abs(exponent)` of the *exact* decimal — is not the
182/// one Python ends up reading. `floatpath` has an equivalent helper but it is
183/// private, and it counts the digits of `format!("{}", f)`, which disagrees
184/// with `repr` at both ends of the range:
185///
186/// * `repr(5.0)` is `"5.0"` → precision **1**, but `format!("{}", 5.0)` is
187/// `"5"` → 0. Not academic: `to_cardinal_float(5.0)` is "cinc coma zero",
188/// and at precision 0 this port would emit a dangling "cinc coma".
189/// * `repr(1e16)` is `"1e+16"` → `Decimal("1e+16").as_tuple().exponent == 16`
190/// → precision **16**, but `format!("{}", 1e16)` is plain digits → 0. Python
191/// really does say "deu mil bilions coma" followed by fifteen "zero".
192///
193/// CPython's `float_repr_style='short'` formats with David Gay's shortest
194/// `dtoa` (a digit string plus `decpt`, the decimal point's position), then
195/// picks fixed notation iff `-4 < decpt <= 16`, and in fixed notation appends
196/// ".0" when no fractional digits were left. Rust's `{:e}` is the same
197/// shortest-round-trip digit string, so `decpt == exp + 1` recovers Gay's.
198/// `Decimal(repr(f)).as_tuple().exponent` is then `-1` for that ".0" case and
199/// `decpt - ndigits` in every other, scientific notation included.
200///
201/// Note precision 0 *is* reachable, via the scientific arm: `1.2345678901234568e16`
202/// has 17 digits at `decpt == 17`. Python then prints a bare trailing "coma";
203/// see [`LangCa::to_cardinal_float`].
204///
205/// Verified against the interpreter over 4035 values — every case in this
206/// module's tests plus 4000 random ones spanning 1e-30..1e30, both signs — with
207/// no mismatch.
208fn float_repr_precision(f: f64) -> u32 {
209 // `{:e}` always emits the exponent, except for inf/nan; callers guard those
210 // (Python raises out of `int(value)` first), so the fallback is unreachable.
211 let s = format!("{:e}", f);
212 let (mantissa, exp) = match s.split_once('e') {
213 Some(parts) => parts,
214 None => return 0,
215 };
216 let exp: i32 = match exp.parse() {
217 Ok(e) => e,
218 Err(_) => return 0,
219 };
220 // The sign and the point are formatting; only the digits count.
221 let ndigits = mantissa.chars().filter(|c| c.is_ascii_digit()).count() as i32;
222 let decpt = exp + 1;
223 if decpt > -4 && decpt <= 16 && decpt >= ndigits {
224 // Fixed notation with nothing after the point: repr appends ".0", so
225 // the Decimal exponent is -1 rather than 0.
226 1
227 } else {
228 (decpt - ndigits).unsigned_abs()
229 }
230}
231
232/// Python's `float(value)` on a `Decimal`.
233///
234/// Goes through the decimal string rather than `BigDecimal::to_f64`: that
235/// method trims the significand to ~25 digits before dividing, so it is only
236/// approximately rounded, whereas `Decimal.__float__` (libmpdec) and Rust's
237/// `f64::from_str` are both *correctly* rounded to the nearest double. They
238/// agree on every corpus value either way, but only the string route is
239/// guaranteed to.
240fn decimal_to_f64(value: &BigDecimal) -> f64 {
241 // BigDecimal's Display emits plain or `E`-notation decimal, both of which
242 // Rust's float parser accepts; a value too large for f64 parses to
243 // infinity, exactly as Python's float(Decimal) overflows to inf.
244 f64::from_str(&value.to_string()).unwrap_or(f64::NAN)
245}
246
247pub struct LangCa {
248 cards: Cards,
249 maxval: BigInt,
250 /// `self.ords`. Python mixes int keys (1..20, 30..90, 100..900) with float
251 /// keys (1e3, 1e6, 1e9, 1e12, 1e15); dict lookup matches an int `dec`
252 /// against a float key by numeric equality, and every such key is exactly
253 /// representable, so a single integer-keyed map is equivalent.
254 ords_tbl: HashMap<u128, &'static str>,
255 exclude_title: Vec<String>,
256 /// `Num2Word_CA.CURRENCY_FORMS`. CA *replaces* `Num2Word_EUR`'s dict
257 /// wholesale — plain class-attribute shadowing, no merge anywhere in the
258 /// chain — so EUR's entries are entirely absent. That is observable:
259 /// EUR spells `PLN` with three forms `("zloty","zlotys","zlotu")` and
260 /// `EUR` as `("euro","euro")`, whereas CA has two-form `("zloty","zlotys")`
261 /// and `("euro","euros")` — hence "zero euros", not "zero euro".
262 /// All 166 CA entries are 2+2, so `pluralize` never indexes past [1].
263 ///
264 /// Built once here rather than per call, as the porting contract requires.
265 currency_forms: HashMap<&'static str, CurrencyForms>,
266 /// `Num2Word_CA.CURRENCY_ADJECTIVES` — *not* defined by CA, so this is
267 /// `Num2Word_EUR`'s dict inherited verbatim (English adjectives on a
268 /// Catalan converter: `to_currency(100.0, "USD", adjective=True)` gives
269 /// "cent US dòlars amb zero centaus"). Reproduced, not corrected.
270 currency_adjectives: HashMap<&'static str, &'static str>,
271}
272
273impl Default for LangCa {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl LangCa {
280 pub fn new() -> Self {
281 let mut cards = Cards::new();
282
283 // Num2Word_EUR.set_high_numwords(["quadr", "tr", "b", "m"]).
284 // cap = 3 + 6*4 = 27; zip(high, range(27, 3, -6)) -> 27, 21, 15, 9.
285 // GIGA_SUFFIX is None so the 10**n insert is skipped entirely;
286 // MEGA_SUFFIX = "ilió" gives cards[10**(n-3)].
287 let high = ["quadr", "tr", "b", "m"];
288 let cap: i64 = 3 + 6 * high.len() as i64;
289 let mut n = cap;
290 for word in high.iter() {
291 if n <= 3 {
292 break;
293 }
294 cards.insert(pow10((n - 3) as u32), format!("{}ilió", word));
295 n -= 6;
296 }
297
298 for (k, v) in [
299 (1000i64, "mil"),
300 (100, "cent"),
301 (90, "noranta"),
302 (80, "vuitanta"),
303 (70, "setanta"),
304 (60, "seixanta"),
305 (50, "cinquanta"),
306 (40, "quaranta"),
307 (30, "trenta"),
308 ] {
309 cards.insert(BigInt::from(k), v);
310 }
311
312 // set_low_numwords: words map to descending values ending at 0.
313 let low = [
314 "vint-i-nou",
315 "vint-i-vuit",
316 "vint-i-set",
317 "vint-i-sis",
318 "vint-i-cinc",
319 "vint-i-quatre",
320 "vint-i-tres",
321 "vint-i-dos",
322 "vint-i-un",
323 "vint",
324 "dinou",
325 "divuit",
326 "disset",
327 "setze",
328 "quinze",
329 "catorze",
330 "tretze",
331 "dotze",
332 "onze",
333 "deu",
334 "nou",
335 "vuit",
336 "set",
337 "sis",
338 "cinc",
339 "quatre",
340 "tres",
341 "dos",
342 "un",
343 "zero",
344 ];
345 let len = low.len();
346 for (i, word) in low.iter().enumerate() {
347 cards.insert(BigInt::from(len - 1 - i), *word);
348 }
349
350 // MAXVAL = 1000 * list(cards)[0]; insertion order puts 10**24 first.
351 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
352
353 let ords_tbl: HashMap<u128, &'static str> = [
354 (1u128, "primer"),
355 (2, "segon"),
356 (3, "tercer"),
357 (4, "quart"),
358 (5, "cinqu"),
359 (6, "sis"),
360 (7, "set"),
361 (8, "vuit"),
362 (9, "nov"),
363 (10, "des"),
364 (11, "onz"),
365 (12, "dotz"),
366 (13, "tretz"),
367 (14, "catorz"),
368 (15, "quinz"),
369 (16, "setz"),
370 (17, "disset"),
371 (18, "divuit"),
372 (19, "dinov"),
373 (20, "vint"),
374 (30, "trent"),
375 (40, "quarant"),
376 (50, "cinquant"),
377 (60, "seixant"),
378 (70, "setant"),
379 (80, "vuitant"),
380 (90, "norant"),
381 (100, "cent"),
382 (200, "dos-cent"),
383 (300, "tres-cent"),
384 (400, "quatre-cent"),
385 (500, "cinc-cent"),
386 (600, "sis-cent"),
387 (700, "set-cent"),
388 (800, "vuit-cent"),
389 (900, "nou-cent"),
390 (1_000, "mil"),
391 (1_000_000, "milion"),
392 (1_000_000_000, "mil milion"),
393 (1_000_000_000_000, "bilion"),
394 (1_000_000_000_000_000, "mil bilion"),
395 ]
396 .into_iter()
397 .collect();
398
399 // CURRENCY_FORMS / CURRENCY_ADJECTIVES are built once, here, and
400 // stored on the struct: rebuilding a 166-entry table on every
401 // to_currency call is what made an earlier revision of this port an
402 // order of magnitude slower than the Python it replaces.
403 let currency_forms: HashMap<&'static str, CurrencyForms> = [
404 ("EUR", &["euro", "euros"][..], &["cèntim", "cèntims"][..]),
405 ("ESP", &["pesseta", "pessetes"][..], &["cèntim", "cèntims"][..]),
406 ("USD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
407 ("PEN", &["sol", "sols"][..], &["cèntim", "cèntims"][..]),
408 ("CRC", &["colón", "colons"][..], &["centau", "centaus"][..]),
409 ("AUD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
410 ("CAD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
411 ("GBP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
412 ("RUB", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
413 ("SEK", &["corona", "corones"][..], &["öre", "öre"][..]),
414 ("NOK", &["corona", "corones"][..], &["øre", "øre"][..]),
415 ("PLN", &["zloty", "zlotys"][..], &["grosz", "groszy"][..]),
416 ("MXN", &["peso", "pesos"][..], &["centau", "centaus"][..]),
417 ("RON", &["leu", "lei"][..], &["ban", "bani"][..]),
418 ("INR", &["rupia", "rupies"][..], &["paisa", "paise"][..]),
419 ("HUF", &["fòrint", "fòrints"][..], &["fillér", "fillérs"][..]),
420 ("FRF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
421 ("CNY", &["iuan", "iuans"][..], &["fen", "jiao"][..]),
422 ("CZK", &["corona", "corones"][..], &["haléř", "haléřů"][..]),
423 ("NIO", &["córdoba", "córdobas"][..], &["centau", "centaus"][..]),
424 ("VES", &["bolívar", "bolívars"][..], &["cèntim", "cèntims"][..]),
425 ("BRL", &["real", "reals"][..], &["centau", "centaus"][..]),
426 ("CHF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
427 ("JPY", &["ien", "iens"][..], &["sen", "sen"][..]),
428 ("KRW", &["won", "wons"][..], &["jeon", "jeon"][..]),
429 ("KPW", &["won", "wons"][..], &["chŏn", "chŏn"][..]),
430 ("TRY", &["lira", "lires"][..], &["kuruş", "kuruş"][..]),
431 ("ZAR", &["rand", "rands"][..], &["cèntim", "cèntims"][..]),
432 ("KZT", &["tenge", "tenge"][..], &["tin", "tin"][..]),
433 ("UAH", &["hrívnia", "hrívnies"][..], &["kopiika", "kopíok"][..]),
434 ("THB", &["baht", "bahts"][..], &["satang", "satang"][..]),
435 ("AED", &["dirham", "dirhams"][..], &["fils", "fulūs"][..]),
436 ("AFN", &["afgani", "afganis"][..], &["puli", "puls"][..]),
437 ("ALL", &["lek", "lekë"][..], &["qqindarka", "qindarkë"][..]),
438 ("AMD", &["dram", "drams"][..], &["luma", "lumas"][..]),
439 ("ANG", &["florí", "florins"][..], &["cèntim", "cèntims"][..]),
440 ("AOA", &["kwanza", "kwanzes"][..], &["cèntim", "cèntims"][..]),
441 ("ARS", &["peso", "pesos"][..], &["centau", "centaus"][..]),
442 ("AWG", &["florí", "florins"][..], &["centau", "centaus"][..]),
443 ("AZN", &["manat", "manats"][..], &["qəpik", "qəpik"][..]),
444 ("BBD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
445 ("BDT", &["taka", "taka"][..], &["poisha", "poisha"][..]),
446 ("BGN", &["lev", "leva"][..], &["stotinka", "stotinki"][..]),
447 ("BHD", &["dinar", "dinars"][..], &["fils", "fulūs"][..]),
448 ("BIF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
449 ("BMD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
450 ("BND", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
451 ("BOB", &["boliviano", "bolivianos"][..], &["centau", "centaus"][..]),
452 ("BSD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
453 ("BTN", &["ngultrum", "ngultrums"][..], &["chetrum", "chetrums"][..]),
454 ("BWP", &["pula", "pula"][..], &["thebe", "thebe"][..]),
455 ("BYN", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
456 ("BYR", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
457 ("BZD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
458 ("CDF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
459 ("CLP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
460 ("COP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
461 ("CUP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
462 ("CVE", &["escut", "escuts"][..], &["centau", "centaus"][..]),
463 ("CYP", &["lliura", "lliures"][..], &["cèntim", "cèntims"][..]),
464 ("DJF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
465 ("DKK", &["corona", "corones"][..], &["øre", "øre"][..]),
466 ("DOP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
467 ("DZD", &["dinar", "dinars"][..], &["cèntim", "cèntims"][..]),
468 ("ECS", &["sucre", "sucres"][..], &["centau", "centaus"][..]),
469 ("EGP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
470 ("ERN", &["nakfa", "nakfes"][..], &["cèntim", "cèntims"][..]),
471 ("ETB", &["birr", "birr"][..], &["cèntim", "cèntims"][..]),
472 ("FJD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
473 ("FKP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
474 ("GEL", &["lari", "laris"][..], &["tetri", "tetri"][..]),
475 ("GHS", &["cedi", "cedis"][..], &["pesewa", "pesewas"][..]),
476 ("GIP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
477 ("GMD", &["dalasi", "dalasis"][..], &["butut", "bututs"][..]),
478 ("GNF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
479 ("GTQ", &["quetzal", "quetzals"][..], &["centau", "centaus"][..]),
480 ("GYD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
481 ("HKD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
482 ("HNL", &["lempira", "lempires"][..], &["centau", "centaus"][..]),
483 ("HRK", &["kuna", "kuna"][..], &["lipa", "lipa"][..]),
484 ("HTG", &["gourde", "gourdes"][..], &["cèntim", "cèntims"][..]),
485 ("IDR", &["rúpia", "rúpies"][..], &["cèntim", "cèntims"][..]),
486 ("ILS", &["xéquel", "xéquels"][..], &["agorà", "agorot"][..]),
487 ("IQD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
488 ("IRR", &["rial", "rials"][..], &["dinar", "dinars"][..]),
489 ("ISK", &["corona", "corones"][..], &["eyrir", "aurar"][..]),
490 ("ITL", &["lira", "lires"][..], &["cèntim", "cèntims"][..]),
491 ("JMD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
492 ("JOD", &["dinar", "dinars"][..], &["piastra", "piastres"][..]),
493 ("KES", &["xiling", "xílings"][..], &["cèntim", "cèntims"][..]),
494 ("KGS", &["som", "som"][..], &["tyiyn", "tyiyn"][..]),
495 ("KHR", &["riel", "riels"][..], &["cèntim", "cèntims"][..]),
496 ("KMF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
497 ("KWD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
498 ("KYD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
499 ("LAK", &["kip", "kips"][..], &["at", "at"][..]),
500 ("LBP", &["lliura", "lliures"][..], &["piastra", "piastres"][..]),
501 ("LKR", &["rúpia", "rúpies"][..], &["cèntim", "cèntims"][..]),
502 ("LRD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
503 ("LSL", &["loti", "maloti"][..], &["sente", "lisente"][..]),
504 ("LTL", &["lita", "litai"][..], &["cèntim", "cèntims"][..]),
505 ("LYD", &["dinar", "dinars"][..], &["dírham", "dírhams"][..]),
506 ("MAD", &["dírham", "dirhams"][..], &["cèntim", "cèntims"][..]),
507 ("MDL", &["leu", "lei"][..], &["ban", "bani"][..]),
508 ("MGA", &["ariary", "ariary"][..], &["iraimbilanja", "iraimbilanja"][..]),
509 ("MKD", &["denar", "denari"][..], &["deni", "deni"][..]),
510 ("MMK", &["kyat", "kyats"][..], &["pya", "pyas"][..]),
511 ("MNT", &["tögrög", "tögrög"][..], &["möngö", "möngö"][..]),
512 ("MOP", &["pataca", "pataques"][..], &["avo", "avos"][..]),
513 ("MRO", &["ouguiya", "ouguiya"][..], &["khoums", "khoums"][..]),
514 ("MRU", &["ouguiya", "ouguiya"][..], &["khoums", "khoums"][..]),
515 ("MUR", &["rupia", "rúpies"][..], &["cèntim", "cèntims"][..]),
516 ("MVR", &["rufiyaa", "rufiyaa"][..], &["laari", "laari"][..]),
517 ("MWK", &["kwacha", "kwacha"][..], &["tambala", "tambala"][..]),
518 ("MYR", &["ringgit", "ringgits"][..], &["sen", "sens"][..]),
519 ("MZN", &["metical", "meticals"][..], &["centau", "centaus"][..]),
520 ("NAD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
521 ("NGN", &["naira", "naires"][..], &["kobo", "kobos"][..]),
522 ("NPR", &["rupia", "rupies"][..], &["paisa", "paises"][..]),
523 ("NZD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
524 ("OMR", &["rial", "rials"][..], &["baisa", "baisa"][..]),
525 ("PAB", &["dòlar", "dòlars"][..], &["centésimo", "centésimos"][..]),
526 ("PGK", &["kina", "kina"][..], &["toea", "toea"][..]),
527 ("PHP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
528 ("PKR", &["rupia", "rupies"][..], &["paisa", "paise"][..]),
529 ("PLZ", &["zloty", "zlotys"][..], &["grosz", "groszy"][..]),
530 ("PYG", &["guaraní", "guaranís"][..], &["cèntim", "cèntims"][..]),
531 ("QAR", &["rial", "rials"][..], &["dírham", "dírhams"][..]),
532 ("QTQ", &["quetzal", "quetzals"][..], &["centau", "centaus"][..]),
533 ("RSD", &["dinar", "dinars"][..], &["para", "para"][..]),
534 ("RUR", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
535 ("RWF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
536 ("SAR", &["riyal", "riyals"][..], &["hàl·lala", "hàl·lalat"][..]),
537 ("SBD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
538 ("SCR", &["rupia", "rupies"][..], &["cèntim", "cèntims"][..]),
539 ("SDG", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
540 ("SGD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
541 ("SHP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
542 ("SLL", &["leonE", "leones"][..], &["cèntim", "cèntims"][..]),
543 ("SRD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
544 ("SSP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
545 ("STD", &["dobra", "dobrAs"][..], &["cèntim", "cèntims"][..]),
546 ("SVC", &["colón", "colons"][..], &["centau", "centaus"][..]),
547 ("SYP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
548 ("SZL", &["lilangeni", "emalangeni"][..], &["cèntim", "cèntims"][..]),
549 ("TJS", &["somoni", "somoni"][..], &["diram", "diram"][..]),
550 ("TMT", &["manat", "manats"][..], &["teňňesi", "teňňesi"][..]),
551 ("TND", &["dinar", "dinars"][..], &["mil·lim", "mil·limat"][..]),
552 ("TOP", &["paanga", "paangas"][..], &["seniti", "seniti"][..]),
553 ("TTD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
554 ("TWD", &["nou dòlar", "nous dòlars"][..], &["fen", "fen"][..]),
555 ("TZS", &["xíling", "xílings"][..], &["cèntim", "cèntims"][..]),
556 ("UGX", &["xíling", "xílings"][..], &["cèntim", "cèntims"][..]),
557 ("UYU", &["peso", "pesos"][..], &["centèsim", "centèsims"][..]),
558 ("UZS", &["som", "som"][..], &["tiyin", "tiyin"][..]),
559 ("VND", &["dong", "dongs"][..], &["xu", "xu"][..]),
560 ("VUV", &["vatu", "vatus"][..], &["cèntim", "cèntims"][..]),
561 ("WST", &["tala", "tala"][..], &["sene", "sene"][..]),
562 ("XAF", &["franc CFA", "francs CFA"][..], &["cèntim", "cèntims"][..]),
563 ("XCD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
564 ("XOF", &["franc CFA", "francs CFA"][..], &["cèntim", "cèntims"][..]),
565 ("XPF", &["franc CFP", "francs CFP"][..], &["cèntim", "cèntims"][..]),
566 ("YER", &["rial", "rials"][..], &["fils", "fils"][..]),
567 ("YUM", &["dinar", "dinars"][..], &["para", "para"][..]),
568 ("ZMW", &["kwacha", "kwacha"][..], &["ngwee", "ngwee"][..]),
569 ("ZWL", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
570 ]
571 .into_iter()
572 .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
573 .collect();
574
575 let currency_adjectives: HashMap<&'static str, &'static str> = [
576 ("AUD", "Australian"),
577 ("BYN", "Belarusian"),
578 ("CAD", "Canadian"),
579 ("EEK", "Estonian"),
580 ("USD", "US"),
581 ("RUB", "Russian"),
582 ("NOK", "Norwegian"),
583 ("MXN", "Mexican"),
584 ("RON", "Romanian"),
585 ("INR", "Indian"),
586 ("HUF", "Hungarian"),
587 ("ISK", "íslenskar"),
588 ("UZS", "Uzbekistan"),
589 ("SAR", "Saudi"),
590 ("JPY", "Japanese"),
591 ("KRW", "Korean"),
592 ]
593 .into_iter()
594 .collect();
595
596 LangCa {
597 cards,
598 maxval,
599 ords_tbl,
600 // self.exclude_title = ["i", "menys", "coma"]
601 exclude_title: vec!["i".into(), "menys".into(), "coma".into()],
602 currency_forms,
603 currency_adjectives,
604 }
605 }
606
607 /// `self.ords[key]` — missing keys raise `KeyError`, which several inputs
608 /// genuinely hit (`dec == 10**18`; see module-doc bugs 4 and 6).
609 fn ords(&self, key: &BigInt) -> Result<&'static str> {
610 key.to_u128()
611 .and_then(|k| self.ords_tbl.get(&k).copied())
612 .ok_or_else(|| N2WError::Key(format!("{}", key)))
613 }
614
615 /// `self.ords_3[key]` — keys are 1..=9 only. **There is no key 0**, so
616 /// every round ten 30..90 raises `KeyError` (module-doc bug 1).
617 fn ords_3(&self, key: &BigInt) -> Result<&'static str> {
618 let word = match key.to_u32() {
619 Some(1) => "unè",
620 Some(2) => "dosè",
621 Some(3) => "tresè",
622 Some(4) => "quatrè",
623 Some(5) => "cinquè",
624 Some(6) => "sisè",
625 Some(7) => "setè",
626 Some(8) => "vuitè",
627 Some(9) => "novè",
628 _ => return Err(N2WError::Key(format!("{}", key))),
629 };
630 Ok(word)
631 }
632
633 /// `Num2Word_Base.verify_ordinal`. The float check (`value == int(value)`)
634 /// is vacuous for integer input; only the negative check can fire.
635 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
636 if value.is_negative() {
637 return Err(N2WError::Type(format!(
638 "El número negatiu {} no pot ser tractat com un ordinal.",
639 value
640 )));
641 }
642 Ok(())
643 }
644
645 /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
646 /// truncating `int(value)` the two checks perform first: NaN raises
647 /// ValueError and ±inf OverflowError inside the first comparison;
648 /// fractional values get CA's own `errmsg_floatord`, negative whole ones
649 /// its `errmsg_negord` (both TypeError). `-0.0` passes both checks and
650 /// ordinalises as zero → "" (bug 5). Returns the whole value as the
651 /// integer the ordinal path then renders.
652 fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
653 if let FloatValue::Float { value: f, .. } = value {
654 if f.is_nan() {
655 return Err(N2WError::Value(
656 "cannot convert float NaN to integer".into(),
657 ));
658 }
659 if f.is_infinite() {
660 return Err(N2WError::Overflow(
661 "cannot convert float infinity to integer".into(),
662 ));
663 }
664 }
665 match value.as_whole_int() {
666 None => Err(N2WError::Type(
667 "El float no pot ser tractat com un ordinal.".into(),
668 )),
669 Some(i) if i.is_negative() => Err(N2WError::Type(format!(
670 "El número negatiu {} no pot ser tractat com un ordinal.",
671 i
672 ))),
673 Some(i) => Ok(i),
674 }
675 }
676
677 fn to_ordinal_inner(&self, value: &BigInt, depth: u32) -> Result<String> {
678 self.verify_ordinal(value)?;
679 if depth > RECURSION_LIMIT {
680 // Stands in for CPython's RecursionError; see module docs.
681 return Err(N2WError::Value(format!(
682 "RecursionError: maximum recursion depth exceeded in to_ordinal({})",
683 value
684 )));
685 }
686
687 let five = BigInt::from(5);
688 let ten = BigInt::from(10);
689 let twenty = BigInt::from(20);
690 let thirty = BigInt::from(30);
691 let hundred = BigInt::from(100);
692 let two_hundred = BigInt::from(200);
693 let thousand = pow10(3);
694
695 let text: String = if value.is_zero() {
696 // to_ordinal(0) == "" (module-doc bug 5).
697 String::new()
698 } else if value < &five {
699 self.ords(value)?.to_string()
700 } else if value <= &twenty {
701 format!("{}{}", self.ords(value)?, GENDER_STEM)
702 } else if value <= &thirty {
703 let frac = value % &ten;
704 // PR savoirfairelinux/num2words#670: an exact ten ("trentè") has
705 // no units part; the old `ords_3[0]` lookup raised KeyError.
706 if frac.is_zero() {
707 format!("{}{}", self.ords(value)?, GENDER_STEM)
708 } else {
709 format!(
710 "{}{}{}",
711 self.ords(&twenty)?,
712 "-i-",
713 self.ords_3(&frac)?
714 )
715 }
716 } else if value < &hundred {
717 let dec = (value / &ten) * &ten;
718 let frac = value - &dec;
719 // PR savoirfairelinux/num2words#670: an exact ten ("quarantè")
720 // has no units part; the old `ords_3[0]` lookup raised KeyError.
721 if frac.is_zero() {
722 format!("{}{}", self.ords(&dec)?, GENDER_STEM)
723 } else {
724 format!(
725 "{}{}{}{}",
726 self.ords(&dec)?,
727 "a",
728 "-",
729 self.ords_3(&frac)?
730 )
731 }
732 } else if value == &hundred {
733 format!("{}{}", self.ords(value)?, GENDER_STEM)
734 } else if value < &two_hundred {
735 let cen = (value / &hundred) * &hundred;
736 format!(
737 "{} {}",
738 self.ords(&cen)?,
739 self.to_ordinal_inner(&(value - &cen), depth + 1)?
740 )
741 } else if value < &thousand {
742 let cen = (value / &hundred) * &hundred;
743 format!(
744 "{}{} {}",
745 self.ords(&cen)?,
746 "s",
747 self.to_ordinal_inner(&(value - &cen), depth + 1)?
748 )
749 } else if value == &thousand {
750 format!("{}{}", self.ords(value)?, GENDER_STEM)
751 } else if value < &pow10(6) {
752 // dec is always 1000 here: the ratio lies in (1, 2) with no float
753 // window wide enough to reach 2.0.
754 let dec = BigInt::from(1000u16).pow(log1000_trunc(value));
755 let (high_part, low_part) = value.div_mod_floor(&dec);
756 let cardinal = if high_part.is_one() {
757 String::new()
758 } else {
759 self.to_cardinal(&high_part)?
760 };
761 // Note the spaces — unlike the < 1e18 arm below.
762 format!(
763 "{} {} {}",
764 cardinal,
765 self.ords(&dec)?,
766 self.to_ordinal_inner(&low_part, depth + 1)?
767 )
768 } else if value < &pow10(18) {
769 let dec = BigInt::from(1000u16).pow(log1000_trunc(value));
770 let (high_part, low_part) = value.div_mod_floor(&dec);
771 let cardinal = if high_part.is_one() {
772 String::new()
773 } else {
774 self.to_cardinal(&high_part)?
775 };
776 // Python builds the %-tuple left to right, so `self.ords[dec]`
777 // raises KeyError *before* the recursive `to_ordinal(low_part)`
778 // is evaluated. Binding it first preserves that ordering — it is
779 // what makes to_ordinal(10**18 - 1) a KeyError rather than a
780 // RecursionError (module-doc bug 4).
781 let o = self.ords(&dec)?;
782 // No separator between `cardinal` and `o` (module-doc bug 2).
783 format!(
784 "{}{}{} {}",
785 cardinal,
786 o,
787 GENDER_STEM,
788 self.to_ordinal_inner(&low_part, depth + 1)?
789 )
790 } else {
791 let part1 = self.to_cardinal(value)?;
792 // part1[:-1] is CHARACTER slicing: "un trilió" -> "un trili"
793 // ("ó" is two bytes, so a byte slice would split it).
794 let chars: Vec<char> = part1.chars().collect();
795 let keep = chars.len().saturating_sub(1);
796 format!(
797 "{}{}",
798 chars[..keep].iter().collect::<String>(),
799 "onè"
800 )
801 };
802
803 Ok(text.trim().to_string())
804 }
805}
806
807impl Lang for LangCa {
808 /// This language's own `to_currency(currency=...)` default,
809 /// read from the live Python signature. Only 44 of 156 use EUR.
810 fn default_currency(&self) -> &str {
811 "EUR"
812 }
813
814 /// This language's own `to_currency(separator=...)` default,
815 /// read from the live Python signature. Base's is ",", but only
816 /// 36 of 149 languages actually use it — most default to " " or a
817 /// conjunction, so inheriting Base's comma silently corrupts them.
818 fn default_separator(&self) -> &str {
819 " amb"
820 }
821
822 fn cards(&self) -> &Cards {
823 &self.cards
824 }
825 fn maxval(&self) -> &BigInt {
826 &self.maxval
827 }
828 fn negword(&self) -> &str {
829 NEGWORD
830 }
831 fn pointword(&self) -> &str {
832 "coma"
833 }
834 fn exclude_title(&self) -> &[String] {
835 &self.exclude_title
836 }
837
838 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
839 let (ltext, cnum) = l;
840 let (rtext, nnum) = r;
841 let mut ctext = ltext.to_string();
842 let mut ntext = rtext.to_string();
843
844 let hundred = BigInt::from(100);
845 let million = BigInt::from(1_000_000);
846
847 if cnum.is_one() {
848 if nnum < &million {
849 // Python `return next` — the unmodified right tuple.
850 return (ntext, nnum.clone());
851 }
852 ctext = "un".to_string();
853 }
854
855 if nnum < cnum {
856 // Python spells out four arms here (cnum < 100 / nnum == 1 /
857 // cnum == 100 / else) but only the first behaves differently:
858 // it joins with "-", the other three are identical " " joins.
859 if cnum < &hundred {
860 return (format!("{}-{}", ctext, ntext), cnum + nnum);
861 }
862 return (format!("{} {}", ctext, ntext), cnum + nnum);
863 } else if (nnum % &million).is_zero() && cnum > &BigInt::one() {
864 // ntext[:-3] + "lions" — CHARACTER slicing. "milió" is 5 chars but
865 // 6 bytes, so a byte slice would yield "mil" and produce
866 // "millions". Correct result: "mi" + "lions" == "milions".
867 let chars: Vec<char> = ntext.chars().collect();
868 let keep = chars.len().saturating_sub(3);
869 ntext = chars[..keep].iter().collect::<String>() + "lions";
870 }
871
872 if nnum == &hundred {
873 ntext.push('s');
874 ctext.push('-');
875 } else {
876 ntext = format!(" {}", ntext);
877 }
878 (format!("{}{}", ctext, ntext), cnum * nnum)
879 }
880
881 /// `Num2Word_Base.to_cardinal` for integral input.
882 ///
883 /// Reimplemented rather than delegating to `default_to_cardinal` purely so
884 /// the OverflowError carries CA's `errmsg_toobig`
885 /// ("abs(%s) ha de ser inferior a %s.") instead of the base English text.
886 /// The algorithm is identical.
887 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
888 let mut out = String::new();
889 let mut v = value.clone();
890 if v.is_negative() {
891 v = v.abs();
892 out = format!("{} ", self.negword().trim());
893 }
894
895 if &v >= self.maxval() {
896 return Err(N2WError::Overflow(format!(
897 "abs({}) ha de ser inferior a {}.",
898 v,
899 self.maxval()
900 )));
901 }
902
903 let tree = splitnum(self, &v).ok_or_else(|| {
904 N2WError::Overflow(format!(
905 "abs({}) ha de ser inferior a {}.",
906 v,
907 self.maxval()
908 ))
909 })?;
910 let words = match clean(self, tree) {
911 Node::Leaf(t, _) => t,
912 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
913 };
914 Ok(self.title(&format!("{}{}", out, words)))
915 }
916
917 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
918 self.to_ordinal_inner(value, 0)
919 }
920
921 /// `to_ordinal(float/Decimal)`: CA's `to_ordinal` opens with
922 /// `verify_ordinal(value)`, so a fractional or negative value raises
923 /// TypeError; a whole non-negative one then runs the same arithmetic the
924 /// integer path does (dict lookups hash `5.0` like `5`, `//`/`%` agree on
925 /// whole floats), so the entry delegates to the integer ordinal —
926 /// `5.0` → "cinquè", `0.0` → "" (bug 5), `1e20` → "cent triliononè".
927 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
928 let i = self.verify_ordinal_float(value)?;
929 self.to_ordinal(&i)
930 }
931
932 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then the
933 /// `ords_2` membership test — numeric, so `1.0`..`4.0` hit "1r".."4t" —
934 /// and otherwise `"%s%s" % (value, "è")` with the repr verbatim:
935 /// `5.0` → "5.0è", `Decimal("5.00")` → "5.00è", `-0.0` → "-0.0è";
936 /// `0.5`/`-1.0` raise TypeError from the verify.
937 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
938 let i = self.verify_ordinal_float(value)?;
939 match i.to_u32() {
940 Some(1) => Ok("1r".to_string()),
941 Some(2) => Ok("2n".to_string()),
942 Some(3) => Ok("3r".to_string()),
943 Some(4) => Ok("4t".to_string()),
944 _ => Ok(format!("{}{}", repr_str, GENDER_STEM)),
945 }
946 }
947
948 /// `Num2Word_CA.to_cardinal_float` — **not** `Num2Word_Base`'s.
949 ///
950 /// ```python
951 /// def to_cardinal_float(self, value):
952 /// try:
953 /// float(value) == value
954 /// except (ValueError, TypeError):
955 /// raise TypeError(self.errmsg_nonnum % value)
956 /// pre, post = self.float2tuple(float(value))
957 /// post_str = str(post)
958 /// leading_zeros = self.precision - len(post_str)
959 /// post_str = "0" * leading_zeros + post_str
960 /// out = [self.to_cardinal(pre)]
961 /// if value < 0 and pre == 0:
962 /// out = [self.negword.strip()] + out
963 /// out.append(self.title(self.pointword))
964 /// for _ in range(leading_zeros):
965 /// out.append(self.to_cardinal(0))
966 /// if int(post_str) > 0 or leading_zeros == 0:
967 /// out.append(self.to_cardinal(int(post_str)))
968 /// return " ".join(out)
969 /// ```
970 ///
971 /// Catalan reads the fraction as **one integer**, not digit by digit:
972 /// `2.675` is "dos coma sis-cents setanta-cinc" where the shared base path
973 /// would say "dos coma sis set cinc". Leading zeros are spelled out one by
974 /// one *first*, so `0.01` is "zero coma zero un" — one "zero" from the
975 /// padding loop, then `int("01") == 1` as "un".
976 ///
977 /// Four deliberate departures from `Num2Word_Base.to_cardinal_float`, all
978 /// observable:
979 ///
980 /// 1. **`float2tuple(float(value))` — the cast is unconditional**, so
981 /// Decimal input never reaches float2tuple's exact arm and issue #603's
982 /// fix is silently undone. `Decimal("98746251323029.99")` collapses to
983 /// the double `98746251323029.98`, and the corpus row really does end
984 /// "coma noranta-vuit" rather than "…noranta-nou". `Decimal("1.10")`
985 /// likewise loses its trailing zero to `repr(1.1)` and yields "un coma
986 /// un", where base-path languages say "een komma een nul". This is why
987 /// the `FloatValue::Decimal` arm below re-derives precision from the
988 /// f64 instead of trusting the carried `abs(exponent)`.
989 /// 2. **`pointword` is appended unconditionally**, where base guards it
990 /// with `if self.precision:`. Reachable: at precision 0 — e.g.
991 /// `1.2345678901234568e16`, 17 significant digits with `decpt == 17` —
992 /// `leading_zeros` goes to -1 and the final guard is False too, so
993 /// Python emits the integer, then a bare trailing "coma", and nothing
994 /// else. `en` prints no "point" at all for the same input.
995 /// 3. **`precision=` is not a parameter here**, so `precision_override` is
996 /// ignored. That is not this port cutting a corner: `num2words`
997 /// implements the kwarg by assigning `converter.precision`, and
998 /// `float2tuple` — which runs first — unconditionally overwrites that
999 /// attribute with the repr-derived value before `leading_zeros` reads
1000 /// it. `num2words(2.675, lang="ca", precision=1)` is byte-identical to
1001 /// the plain call; so is the `en` equivalent. Verified in the
1002 /// interpreter for overrides 1, 3 and 5.
1003 /// 4. **The final `to_cardinal` is skipped when the fraction is all
1004 /// zeros** (`int(post_str) > 0 or leading_zeros == 0`), which is what
1005 /// makes `1e16` "deu mil bilions coma" + fifteen "zero" with no
1006 /// sixteenth word. When `leading_zeros == 0` the guard passes anyway, so
1007 /// a lone zero still prints: `to_cardinal_float(5.0)` is "cinc coma
1008 /// zero".
1009 ///
1010 /// Python's `except (ValueError, TypeError)` guard around `float(value)`
1011 /// has no analogue — `FloatValue` cannot hold a non-numeric — so
1012 /// `errmsg_nonnum` is unreachable from Rust.
1013 fn to_cardinal_float(
1014 &self,
1015 value: &FloatValue,
1016 _precision_override: Option<u32>,
1017 ) -> Result<String> {
1018 // `float(value)` — for BOTH arms; see departure 1 above.
1019 let f = match value {
1020 FloatValue::Float { value, .. } => *value,
1021 FloatValue::Decimal { value, .. } => decimal_to_f64(value),
1022 };
1023
1024 // float2tuple opens with `pre = int(value)`, so a non-finite double
1025 // raises there, before the precision is ever computed.
1026 if f.is_nan() {
1027 return Err(N2WError::Value("cannot convert float NaN to integer".into()));
1028 }
1029 if f.is_infinite() {
1030 return Err(N2WError::Overflow(
1031 "cannot convert float infinity to integer".into(),
1032 ));
1033 }
1034
1035 let precision = match value {
1036 // Already `abs(Decimal(str(f)).as_tuple().exponent)` — the Python
1037 // shim derived it from repr of this very double, which is exactly
1038 // what float2tuple would recompute.
1039 FloatValue::Float { precision, .. } => *precision,
1040 // Carried precision is the *Decimal's* scale; the float cast has
1041 // its own repr and hence its own precision (2 for the .99 row
1042 // above, 1 for Decimal("1.10")).
1043 FloatValue::Decimal { .. } => float_repr_precision(f),
1044 };
1045
1046 // The f64 artefacts are load-bearing, so this reuses floatpath's
1047 // float2tuple rather than re-deriving from the decimal string: 2.675
1048 // really does give 674.9999999999998, rescued to 675 by the `< 0.01`
1049 // heuristic, and 1.005 gives 4.999999999999893, rescued to 5. Both
1050 // would come out one lower from an exact recomputation.
1051 //
1052 // (float2tuple rounds with `round_ties_even`, matching Python. Worth
1053 // noting the choice is provably inert *here*: the mode can only differ
1054 // on an exact .5 tie, and a tie is 0.5 away from its integer, so the
1055 // `< 0.01` guard rejects it and both modes fall through to the same
1056 // `floor`. The corpus cannot tell them apart either.)
1057 let (pre, post) = float2tuple(&FloatValue::Float { value: f, precision });
1058
1059 let post_str = post.to_string();
1060 // Python lets this go negative; "0" * -1 == "" and range(-1) is empty,
1061 // so a negative count is a no-op rather than an error. It then feeds
1062 // the `leading_zeros == 0` guard below, where the sign matters.
1063 let leading_zeros = precision as i64 - post_str.chars().count() as i64;
1064 let post_str = if leading_zeros > 0 {
1065 format!("{}{}", "0".repeat(leading_zeros as usize), post_str)
1066 } else {
1067 post_str
1068 };
1069
1070 let mut out = vec![self.to_cardinal(&pre)?];
1071 // `value < 0` tests the ORIGINAL, not the cast — but int(-0.5) == 0
1072 // carries no minus either way, so the sign has to be re-attached here.
1073 if value.is_negative() && pre.is_zero() {
1074 out.insert(0, self.negword().trim().to_string());
1075 }
1076
1077 // Unconditional — departure 2.
1078 out.push(self.title(self.pointword()));
1079
1080 for _ in 0..leading_zeros.max(0) {
1081 out.push(self.to_cardinal(&BigInt::zero())?);
1082 }
1083
1084 // `int(post_str)` drops the padding again: "01" -> 1 -> "un".
1085 let post_val = BigInt::from_str(&post_str)
1086 .map_err(|e| N2WError::Value(e.to_string()))?;
1087 if post_val.is_positive() || leading_zeros == 0 {
1088 out.push(self.to_cardinal(&post_val)?);
1089 }
1090
1091 Ok(out.join(" "))
1092 }
1093
1094 /// The fractional-cents entry point: Python's
1095 /// `cents_str = self.to_cardinal(float(right))` inside
1096 /// `Num2Word_Base.to_currency`.
1097 ///
1098 /// Overridden because `base.rs`'s default routes to
1099 /// `floatpath::cardinal_from_bigdecimal`, which calls
1100 /// `default_to_cardinal_float` directly and so would bypass CA's override.
1101 /// Python goes through `self.to_cardinal`, which dispatches back to
1102 /// `Num2Word_CA.to_cardinal_float` for a non-integral float — and the two
1103 /// spell the fraction differently: `to_currency(1.0125, "USD")` is "un
1104 /// dòlar amb un coma **vint-i-cinc** centaus", not the base path's "un coma
1105 /// dos cinc". Verified against the interpreter.
1106 ///
1107 /// The `Float` variant is built explicitly to mirror Python casting with
1108 /// `float(right)` *before* `to_cardinal` sees the value.
1109 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1110 let f = decimal_to_f64(value);
1111 self.to_cardinal_float(
1112 &FloatValue::Float {
1113 value: f,
1114 precision: float_repr_precision(f),
1115 },
1116 None,
1117 )
1118 }
1119
1120 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1121 self.verify_ordinal(value)?;
1122 // ords_2 = {1: "1r", 2: "2n", 3: "3r", 4: "4t"}; everything else gets
1123 // the digits plus "è" (gender_stem is always "è", so the conditional
1124 // `"è" if self.gender_stem == "è" else "a"` never picks "a").
1125 match value.to_u32() {
1126 Some(1) => Ok("1r".to_string()),
1127 Some(2) => Ok("2n".to_string()),
1128 Some(3) => Ok("3r".to_string()),
1129 Some(4) => Ok("4t".to_string()),
1130 _ => Ok(format!("{}{}", value, GENDER_STEM)),
1131 }
1132 }
1133
1134 /// `Num2Word_Base.to_year` — CA does not override it, so a year is just a
1135 /// cardinal (`to_year(-500)` == "menys cinc-cents").
1136 fn to_year(&self, value: &BigInt) -> Result<String> {
1137 self.to_cardinal(value)
1138 }
1139
1140 // ---- currency -----------------------------------------------------
1141 //
1142 // Not overridden, because CA inherits them unchanged and the base.rs
1143 // defaults already reproduce `Num2Word_Base`:
1144 //
1145 // * `currency_precision` — `CURRENCY_PRECISION` is `{}` all the way up
1146 // the chain (CA and EUR never define it), so
1147 // `CURRENCY_PRECISION.get(code, 100)` is **always 100**. That is not a
1148 // typo: it makes JPY and KWD ordinary 2-decimal currencies here, so
1149 // `to_currency(12.34, "JPY")` == "dotze iens amb trenta-quatre sen"
1150 // and `to_cheque(1234.56, "KWD")` ends "56/100 DINARS", not "560/1000".
1151 // The divisor-1 shortcut in `default_to_currency` is therefore dead
1152 // code for CA, and 0-decimal JPY never happens.
1153 // * `money_verbose` / `cents_verbose` — both are `to_cardinal`.
1154 // * `cents_terse` — `"%0*d" % (2, n)`; `default_cents_terse` at
1155 // divisor 100 gives the same ("dotze euros amb 34 cèntims").
1156 // * `to_cheque` — CA does not override `Num2Word_Base.to_cheque`, and
1157 // `default_to_cheque` is a faithful port of it.
1158
1159 fn lang_name(&self) -> &str {
1160 "Num2Word_CA"
1161 }
1162
1163 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1164 self.currency_forms.get(code)
1165 }
1166
1167 fn currency_adjective(&self, code: &str) -> Option<&str> {
1168 self.currency_adjectives.get(code).copied()
1169 }
1170
1171 /// `Num2Word_EUR.pluralize` — inherited; CA does not define its own.
1172 ///
1173 /// ```python
1174 /// def pluralize(self, n, forms):
1175 /// form = 0 if n == 1 else 1
1176 /// return forms[form]
1177 /// ```
1178 ///
1179 /// Note `n == 1`, not `abs(n) == 1`: the sign is not folded away. Every CA
1180 /// entry has two forms, so `forms[1]` is always in range and the IndexError
1181 /// arm below is unreachable in practice — it exists so that a one-form
1182 /// entry would crash the way Python's list indexing does rather than
1183 /// silently falling back to the singular.
1184 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1185 let form = if n.is_one() { 0 } else { 1 };
1186 forms
1187 .get(form)
1188 .cloned()
1189 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1190 }
1191
1192 /// `Num2Word_CA.to_currency`.
1193 ///
1194 /// CA intercepts **integers only** and hands every float straight back to
1195 /// `Num2Word_Base.to_currency` via `super()`. The two paths are not
1196 /// consistent with each other, and the differences are observable:
1197 ///
1198 /// 1. **Negative ints render a double space.** The int arm builds
1199 /// `("%s %s %s" % (minus_str, money_str, currency_str)).strip()` with
1200 /// `minus_str = self.negword` — and `negword` is `"menys "`, *already*
1201 /// carrying a trailing space, which the format string's own space then
1202 /// doubles. `.strip()` only trims the ends, so the pair survives in the
1203 /// middle: `to_currency(-5, "USD")` == "menys␣␣cinc dòlars". The float
1204 /// arm uses base's `"%s " % self.negword.strip()` and gets it right:
1205 /// `to_currency(-12.34, "EUR")` == "menys␣dotze euros amb …".
1206 /// Both verified against the interpreter.
1207 /// 2. **`adjective=True` is silently ignored for ints.** The int arm never
1208 /// consults `CURRENCY_ADJECTIVES`, so `to_currency(100, "USD",
1209 /// adjective=True)` == "cent dòlars" while the float `100.0` gives
1210 /// "cent US dòlars amb zero centaus".
1211 /// 3. The int arm reimplements `pluralize`'s rule inline
1212 /// (`cr1[0] if abs_val == 1 else cr1[1]`) but against `abs_val`, where
1213 /// base passes `abs(val_int)` to `pluralize` — same answer either way.
1214 ///
1215 /// The `except (KeyError, AttributeError)` fallback re-enters base for an
1216 /// unknown code, which then raises NotImplementedError with CA's class
1217 /// name. `AttributeError` is unreachable (`CURRENCY_FORMS` always exists),
1218 /// so only the missing-key path is modelled.
1219 fn to_currency(
1220 &self,
1221 val: &CurrencyValue,
1222 currency: &str,
1223 cents: bool,
1224 separator: Option<&str>,
1225 adjective: bool,
1226 ) -> Result<String> {
1227 // Trait now hands us None when the caller omitted separator=;
1228 // resolve it to this language's own default before the ported body.
1229 let separator = separator.unwrap_or(self.default_separator());
1230 // Restore CA's own `separator=" amb"` default; see SEPARATOR_UNSET.
1231 let separator = if separator == SEPARATOR_UNSET {
1232 SEPARATOR_DEFAULT
1233 } else {
1234 separator
1235 };
1236
1237 if let CurrencyValue::Int(v) = val {
1238 let forms = match self.currency_forms.get(currency) {
1239 Some(f) => f,
1240 // KeyError -> super().to_currency(...), which raises
1241 // NotImplementedError from its own CURRENCY_FORMS lookup.
1242 None => {
1243 return default_to_currency(self, val, currency, cents, separator, adjective)
1244 }
1245 };
1246 let cr1 = &forms.unit;
1247
1248 let minus_str = if v.is_negative() { NEGWORD } else { "" };
1249 let abs_val = v.abs();
1250 let money_str = self.to_cardinal(&abs_val)?;
1251
1252 let currency_str = if abs_val.is_one() {
1253 &cr1[0]
1254 } else if cr1.len() > 1 {
1255 &cr1[1]
1256 } else {
1257 &cr1[0]
1258 };
1259
1260 // Python: ("%s %s %s" % (...)).strip(). The interior double space
1261 // on negatives is bug 1 above — trim() must not collapse it.
1262 return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1263 .trim()
1264 .to_string());
1265 }
1266
1267 // Floats/Decimals: `super(Num2Word_CA, self).to_currency(...)`.
1268 default_to_currency(self, val, currency, cents, separator, adjective)
1269 }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275
1276 /// `precision` is what the Python shim derives from `repr(value)`.
1277 fn f(lang: &LangCa, value: f64, precision: u32) -> String {
1278 lang.to_cardinal_float(&FloatValue::Float { value, precision }, None)
1279 .unwrap()
1280 }
1281
1282 fn d(lang: &LangCa, lit: &str, precision: u32) -> String {
1283 lang.to_cardinal_float(
1284 &FloatValue::Decimal {
1285 value: BigDecimal::from_str(lit).unwrap(),
1286 precision,
1287 },
1288 None,
1289 )
1290 .unwrap()
1291 }
1292
1293 /// The float `to: "cardinal"` rows of the frozen `ca` corpus.
1294 #[test]
1295 fn corpus_float_rows() {
1296 let l = LangCa::new();
1297 assert_eq!(f(&l, 0.5, 1), "zero coma cinc");
1298 assert_eq!(f(&l, 1.5, 1), "un coma cinc");
1299 assert_eq!(f(&l, 2.25, 2), "dos coma vint-i-cinc");
1300 assert_eq!(f(&l, 3.14, 2), "tres coma catorze");
1301 assert_eq!(f(&l, 0.01, 2), "zero coma zero un");
1302 assert_eq!(f(&l, 0.1, 1), "zero coma un");
1303 assert_eq!(f(&l, 0.99, 2), "zero coma noranta-nou");
1304 assert_eq!(f(&l, 1.01, 2), "un coma zero un");
1305 assert_eq!(f(&l, 12.34, 2), "dotze coma trenta-quatre");
1306 assert_eq!(f(&l, 99.99, 2), "noranta-nou coma noranta-nou");
1307 assert_eq!(f(&l, 100.5, 1), "cent coma cinc");
1308 assert_eq!(
1309 f(&l, 1234.56, 2),
1310 "mil dos-cents trenta-quatre coma cinquanta-sis"
1311 );
1312 // int(-0.5) == 0 carries no minus, so negword is re-attached.
1313 assert_eq!(f(&l, -0.5, 1), "menys zero coma cinc");
1314 assert_eq!(f(&l, -1.5, 1), "menys un coma cinc");
1315 assert_eq!(f(&l, -12.34, 2), "menys dotze coma trenta-quatre");
1316 // The f64 artefacts: 1.005 floors, 2.675 gives 674.9999999999998 and
1317 // is rescued to 675 by float2tuple's `< 0.01` heuristic.
1318 assert_eq!(f(&l, 1.005, 3), "un coma zero zero cinc");
1319 assert_eq!(f(&l, 2.675, 3), "dos coma sis-cents setanta-cinc");
1320 }
1321
1322 /// The `to: "cardinal_dec"` rows — Decimal input, which CA float-casts.
1323 #[test]
1324 fn corpus_decimal_rows() {
1325 let l = LangCa::new();
1326 assert_eq!(d(&l, "0.01", 2), "zero coma zero un");
1327 // precision 1, not the Decimal's own 2: repr(float(Decimal("1.10")))
1328 // is "1.1". A base-path language says "un coma un zero".
1329 assert_eq!(d(&l, "1.10", 2), "un coma un");
1330 assert_eq!(d(&l, "12.345", 3), "dotze coma tres-cents quaranta-cinc");
1331 assert_eq!(d(&l, "0.001", 3), "zero coma zero zero un");
1332 // Issue #603 undone by CA's unconditional float cast: the exact
1333 // Decimal ends .99, but the double it collapses to ends .98.
1334 assert_eq!(
1335 d(&l, "98746251323029.99", 2),
1336 "noranta-vuit bilions set-cents quaranta-sis mil dos-cents \
1337 cinquanta-un milions tres-cents vint-i-tres mil vint-i-nou \
1338 coma noranta-vuit"
1339 );
1340 }
1341
1342 /// The quirks that separate CA's override from `Num2Word_Base`'s.
1343 #[test]
1344 fn ca_specific_quirks() {
1345 let l = LangCa::new();
1346 // An integral float reaches here only on a direct call; precision is 1
1347 // because repr(5.0) == "5.0", and `leading_zeros == 0` lets the lone
1348 // zero through.
1349 assert_eq!(f(&l, 5.0, 1), "cinc coma zero");
1350 assert_eq!(f(&l, 0.0, 1), "zero coma zero");
1351 assert_eq!(f(&l, 100.0, 1), "cent coma zero");
1352 // repr(1e16) == "1e+16" -> precision 16; the fraction is all zeros so
1353 // the final to_cardinal is skipped: fifteen "zero" and no sixteenth.
1354 assert_eq!(
1355 f(&l, 1e16, 16),
1356 "deu mil bilions coma zero zero zero zero zero zero zero zero \
1357 zero zero zero zero zero zero zero"
1358 );
1359 // precision 0 (17 significant digits, decpt 17): a bare trailing
1360 // "coma" with nothing after it. `en` prints no "point" at all.
1361 assert_eq!(
1362 f(&l, 1.2345678901234568e16, 0),
1363 "dotze mil tres-cents quaranta-cinc bilions sis-cents setanta-vuit \
1364 mil nou-cents un milions dos-cents trenta-quatre mil cinc-cents \
1365 seixanta-vuit coma"
1366 );
1367 // precision= is inert: float2tuple overwrites self.precision first.
1368 let v = FloatValue::Float { value: 2.675, precision: 3 };
1369 for p in [None, Some(1), Some(3), Some(5)] {
1370 assert_eq!(
1371 l.to_cardinal_float(&v, p).unwrap(),
1372 "dos coma sis-cents setanta-cinc"
1373 );
1374 }
1375 }
1376
1377 /// `float_repr_precision` reproduces `abs(Decimal(repr(f)).as_tuple()
1378 /// .exponent)`, including the ".0" and scientific-notation arms.
1379 #[test]
1380 fn repr_precision_matches_python() {
1381 for (v, want) in [
1382 (0.5, 1u32),
1383 (2.675, 3),
1384 (12.34, 2),
1385 (0.0, 1),
1386 (1.0, 1),
1387 (5.0, 1),
1388 (100.0, 1),
1389 (-1.5, 1),
1390 (1e15, 1),
1391 (1e16, 16),
1392 (1e17, 17),
1393 (1e21, 21),
1394 (1e-4, 4),
1395 (1e-5, 5),
1396 (1e-20, 20),
1397 (1.5e-25, 26),
1398 (1.2345e-7, 11),
1399 (98746251323029.98, 2),
1400 (1.2345678901234568e16, 0),
1401 (9.876543210987654e16, 1),
1402 ] {
1403 assert_eq!(float_repr_precision(v), want, "precision of {:e}", v);
1404 }
1405 }
1406
1407 /// The fractional-cents hook must reach CA's override, not base's.
1408 /// `to_currency(1.0125, "USD")` is "…un coma vint-i-cinc centaus"; routing
1409 /// through `floatpath` instead would say "…un coma dos cinc centaus".
1410 #[test]
1411 fn fractional_cents_uses_ca_float_path() {
1412 let l = LangCa::new();
1413 assert_eq!(
1414 l.cardinal_from_decimal(&BigDecimal::from_str("1.2500").unwrap())
1415 .unwrap(),
1416 "un coma vint-i-cinc"
1417 );
1418 let v = CurrencyValue::parse("1.0125", false, true, true).unwrap();
1419 assert_eq!(
1420 l.to_currency(&v, "USD", true, None, false).unwrap(),
1421 "un dòlar amb un coma vint-i-cinc centaus"
1422 );
1423 }
1424
1425 /// `int(value)` raises before anything else for a non-finite double.
1426 #[test]
1427 fn non_finite_raises_like_python() {
1428 let l = LangCa::new();
1429 assert!(matches!(
1430 l.to_cardinal_float(&FloatValue::Float { value: f64::NAN, precision: 1 }, None),
1431 Err(N2WError::Value(_))
1432 ));
1433 assert!(matches!(
1434 l.to_cardinal_float(
1435 &FloatValue::Float { value: f64::INFINITY, precision: 1 },
1436 None
1437 ),
1438 Err(N2WError::Overflow(_))
1439 ));
1440 }
1441}