num2words2_core/lang_fr.rs
1//! Port of `lang_FR.py` (via `lang_EUR` → `Num2Word_Base`).
2//!
3//! Engine-style language: FR supplies `mid_numwords`/`low_numwords` + `merge`
4//! and inherits `Num2Word_EUR.set_high_numwords`, letting the base engine's
5//! `splitnum`/`clean` drive `to_cardinal`.
6//!
7//! Unlike EN (which overrides `set_high_numwords` to the short scale), FR uses
8//! EUR's **long scale**: each Latin stem yields a `-illiard` at 10^n and an
9//! `-illion` at 10^(n-3), stepping by 6. So 10^9 = "milliard", 10^12 =
10//! "billion", 10^15 = "billiard", 10^18 = "trillion", … up to 10^603 =
11//! "centilliard". MAXVAL is therefore 1000 * 10^603 = 10^606.
12
13use crate::base::{clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result};
14use crate::currency::{CurrencyForms, CurrencyValue};
15use crate::floatpath::FloatValue;
16use num_bigint::BigInt;
17use num_integer::Integer;
18use num_traits::{FromPrimitive, One, Signed, Zero};
19use std::collections::HashMap;
20
21/// Port of `Num2Word_EUR.gen_high_numwords`.
22///
23/// Duplicated locally rather than imported from `lang_en` to keep this file
24/// self-contained (the registry in `lib.rs` is generated mechanically).
25fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
26 let mut out: Vec<String> = Vec::new();
27 // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
28 for t in tens {
29 for u in units {
30 out.push(format!("{}{}", u, t));
31 }
32 }
33 out.reverse();
34
35 const REPLACEMENTS: &[(&str, &str)] = &[
36 ("novemn", "noven"),
37 ("novemo", "novo"),
38 ("octoo", "octo"),
39 ("quintd", "quind"),
40 ("quintn", "quin"),
41 ("quintq", "quinq"),
42 ("quints", "quins"),
43 ("quintt", "quint"),
44 ("quintv", "quinv"),
45 ("septenn", "septen"),
46 ("septent", "sept"),
47 ("sexn", "sen"),
48 ("sexs", "ses"),
49 ("tresd", "tred"),
50 ("tresn", "tren"),
51 ("tress", "tres"),
52 ("tresv", "trev"),
53 ("unno", "uno"),
54 ];
55 for (k, v) in REPLACEMENTS {
56 out = out.iter().map(|o| o.replace(k, v)).collect();
57 }
58 out.extend(lows.iter().map(|s| s.to_string()));
59 out
60}
61
62/// `Num2Word_FR._BIG_UNITS`, in source order — the order is load-bearing
63/// because "trilliard" must be tested before "trillion", etc.
64const BIG_UNITS: [&str; 6] = [
65 "trilliard",
66 "trillion",
67 "billiard",
68 "billion",
69 "milliard",
70 "million",
71];
72
73/// `Num2Word_FR.ords`. A slice (not a map) to preserve Python's dict
74/// insertion order, which `to_ordinal` iterates.
75const ORDS: [(&str, &str); 2] = [("cinq", "cinquième"), ("neuf", "neuvième")];
76
77/// `Num2Word_FR.CURRENCY_FORMS`, transcribed from the class body verbatim.
78///
79/// FR declares its **own** `CURRENCY_FORMS`, so — unlike the 16 classes that
80/// inherit the dict — it never sees the entries `Num2Word_EN.__init__` mutates
81/// into `Num2Word_EUR.CURRENCY_FORMS`. Confirmed against the live interpreter:
82/// FR's EUR is `("euro", "euros")` / `("centime", "centimes")` because that is
83/// FR's own literal, not because English rewrote it. Nothing here is inherited.
84///
85/// Consequences worth naming, since they look like typos and are not:
86/// * CNY's subunits are `("fen", "jiaos")` — two *different* units, so 0.01
87/// CNY is "un fen" but 0.34 is "trente-quatre jiaos". Upstream data bug.
88/// * JPY/KRW carry sen/jeon subunits and FR has no `CURRENCY_PRECISION`
89/// override, so they are ordinary 100-subunit currencies here.
90/// * KWD/BHD/CHF are absent, which is what drives them down the
91/// KeyError paths in `to_currency` / `to_cheque` (see those methods).
92fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
93 const DOLLARS: [&str; 2] = ["dollar", "dollars"];
94 const CENTS: [&str; 2] = ["cent", "cents"];
95 const CENTIMES: [&str; 2] = ["centime", "centimes"];
96
97 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
98 m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTIMES));
99 m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
100 m.insert("FRF", CurrencyForms::new(&["franc", "francs"], &CENTIMES));
101 m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
102 m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
103 m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
104 m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
105 m.insert("GBP", CurrencyForms::new(&["livre", "livres"], &["penny", "pence"]));
106 m.insert("CNY", CurrencyForms::new(&["yuan", "yuans"], &["fen", "jiaos"]));
107 m.insert("JPY", CurrencyForms::new(&["yen", "yens"], &["sen", "sens"]));
108 m.insert("INR", CurrencyForms::new(&["roupie", "roupies"], &["paisa", "paisas"]));
109 m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopeck", "kopecks"]));
110 m.insert("KRW", CurrencyForms::new(&["won", "wons"], &["jeon", "jeons"]));
111 m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["centavo", "centavos"]));
112 m
113}
114
115/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — FR declares none
116/// and `Num2Word_EN.__init__` never touches this dict (it only mutates
117/// `CURRENCY_FORMS`), so the class-body literal is what runs. Verified against
118/// the live interpreter.
119///
120/// Half of these codes (BYN, EEK, HUF, ISK, NOK, RON, SAR, UZS) have no entry
121/// in FR's `CURRENCY_FORMS`, so they are unreachable: the forms lookup decides
122/// the outcome long before an adjective could be prefixed. Kept anyway — the
123/// dict is ported data, not a curated list.
124fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
125 [
126 ("AUD", "Australian"),
127 ("BYN", "Belarusian"),
128 ("CAD", "Canadian"),
129 ("EEK", "Estonian"),
130 ("USD", "US"),
131 ("RUB", "Russian"),
132 ("NOK", "Norwegian"),
133 ("MXN", "Mexican"),
134 ("RON", "Romanian"),
135 ("INR", "Indian"),
136 ("HUF", "Hungarian"),
137 ("ISK", "íslenskar"),
138 ("UZS", "Uzbekistan"),
139 ("SAR", "Saudi"),
140 ("JPY", "Japanese"),
141 ("KRW", "Korean"),
142 ]
143 .into_iter()
144 .collect()
145}
146
147/// Python `str(float)`/`repr(float)`, used only to build error-message text
148/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
149/// *types*; the text follows the Python format strings. Whole floats keep
150/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
151/// ("1e+20"), and -0.0 keeps its sign.
152fn py_float_str(f: f64) -> String {
153 if f.is_nan() {
154 return "nan".to_string();
155 }
156 if f.is_infinite() {
157 return if f.is_sign_positive() {
158 "inf".to_string()
159 } else {
160 "-inf".to_string()
161 };
162 }
163 let a = f.abs();
164 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
165 // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
166 let s = format!("{:e}", f);
167 if let Some((m, e)) = s.split_once('e') {
168 let (sign, digits) = match e.strip_prefix('-') {
169 Some(d) => ("-", d.to_string()),
170 None => ("+", e.to_string()),
171 };
172 let digits = if digits.len() < 2 {
173 format!("0{}", digits)
174 } else {
175 digits
176 };
177 return format!("{}e{}{}", m, sign, digits);
178 }
179 s
180 } else if f.fract() == 0.0 {
181 // repr keeps the trailing ".0" that Rust's `{}` would drop.
182 format!("{:.1}", f)
183 } else {
184 format!("{}", f)
185 }
186}
187
188/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
189fn py_num_str(v: &FloatValue) -> String {
190 match v {
191 FloatValue::Float { value, .. } => py_float_str(*value),
192 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
193 }
194}
195
196pub struct LangFr {
197 cards: Cards,
198 maxval: BigInt,
199 exclude_title: Vec<String>,
200 currency_forms: HashMap<&'static str, CurrencyForms>,
201 currency_adjectives: HashMap<&'static str, &'static str>,
202}
203
204impl Default for LangFr {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl LangFr {
211 pub fn new() -> Self {
212 // Num2Word_EUR.setup
213 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
214 let units = [
215 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
216 ];
217 let tens = [
218 "dec",
219 "vigint",
220 "trigint",
221 "quadragint",
222 "quinquagint",
223 "sexagint",
224 "septuagint",
225 "octogint",
226 "nonagint",
227 ];
228 let mut high = vec!["cent".to_string()];
229 high.extend(gen_high_numwords(&units, &tens, &lows));
230
231 let mut cards = Cards::new();
232
233 // Num2Word_EUR.set_high_numwords — long scale:
234 // cap = 3 + 6 * len(high) # 603
235 // for word, n in zip(high, range(cap, 3, -6)):
236 // cards[10**n] = word + GIGA_SUFFIX # "illiard"
237 // cards[10**(n-3)] = word + MEGA_SUFFIX # "illion"
238 // `high` has 100 entries and range(603, 3, -6) has 100 values, so the
239 // zip consumes both exactly.
240 let cap: i64 = 3 + 6 * high.len() as i64;
241 let mut n = cap;
242 for word in high.iter() {
243 if n <= 3 {
244 break;
245 }
246 cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiard", word));
247 cards.insert(
248 BigInt::from(10u8).pow((n - 3) as u32),
249 format!("{}illion", word),
250 );
251 n -= 6;
252 }
253
254 // Note: no cards for 70 or 90 — French builds those by merging
255 // (soixante-dix, quatre-vingt-dix).
256 set_mid_numwords(
257 &mut cards,
258 &[
259 (1000, "mille"),
260 (100, "cent"),
261 (80, "quatre-vingts"),
262 (60, "soixante"),
263 (50, "cinquante"),
264 (40, "quarante"),
265 (30, "trente"),
266 ],
267 );
268 set_low_numwords(
269 &mut cards,
270 &[
271 "vingt", "dix-neuf", "dix-huit", "dix-sept", "seize", "quinze", "quatorze",
272 "treize", "douze", "onze", "dix", "neuf", "huit", "sept", "six", "cinq", "quatre",
273 "trois", "deux", "un", "zéro",
274 ],
275 );
276
277 // MAXVAL = 1000 * list(self.cards.keys())[0] -> 1000 * 10**603
278 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
279
280 LangFr {
281 cards,
282 maxval,
283 exclude_title: vec!["et".into(), "virgule".into(), "moins".into()],
284 // Built once here, never per call — `to_currency` only reads them.
285 currency_forms: build_currency_forms(),
286 currency_adjectives: build_currency_adjectives(),
287 }
288 }
289
290 /// `Num2Word_Base.verify_ordinal`. The float check is unreachable for
291 /// BigInt input, so only the negative check survives.
292 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
293 if value.is_negative() {
294 return Err(N2WError::Type(format!(
295 "Cannot treat negative num {} as ordinal.",
296 value
297 )));
298 }
299 Ok(())
300 }
301
302 /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
303 /// Python's order:
304 ///
305 /// 1. `not value == int(value)` -> TypeError(errmsg_floatord)
306 /// 2. `not abs(value) == value` -> TypeError(errmsg_negord)
307 ///
308 /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
309 /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
310 /// "zéroième", not an error. A negative fractional value (-1.5) fails
311 /// check 1 first, so it raises the *float* message, as Python does.
312 /// Returns the integral value for the integer-path continuation.
313 /// (NaN/±inf would make `int(value)` raise before either check, but the
314 /// dispatcher keeps those on the Python side; `as_whole_int` maps them to
315 /// the float-message arm, which is unreachable for them in practice.)
316 fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
317 match value.as_whole_int() {
318 Some(i) => {
319 if i.is_negative() {
320 Err(N2WError::Type(format!(
321 "Cannot treat negative num {} as ordinal.",
322 py_num_str(value)
323 )))
324 } else {
325 Ok(i)
326 }
327 }
328 None => Err(N2WError::Type(format!(
329 "Cannot treat float {} as ordinal.",
330 py_num_str(value)
331 ))),
332 }
333 }
334}
335
336impl Lang for LangFr {
337 /// This language's own `to_currency(currency=...)` default,
338 /// read from the live Python signature. Only 44 of 156 use EUR.
339 fn default_currency(&self) -> &str {
340 "EUR"
341 }
342
343 /// This language's own `to_currency(separator=...)` default,
344 /// read from the live Python signature. Base's is ",", but only
345 /// 36 of 149 languages actually use it — most default to " " or a
346 /// conjunction, so inheriting Base's comma silently corrupts them.
347 fn default_separator(&self) -> &str {
348 " et"
349 }
350
351 fn cards(&self) -> &Cards {
352 &self.cards
353 }
354 fn maxval(&self) -> &BigInt {
355 &self.maxval
356 }
357 fn negword(&self) -> &str {
358 "moins "
359 }
360 fn pointword(&self) -> &str {
361 "virgule"
362 }
363 fn exclude_title(&self) -> &[String] {
364 &self.exclude_title
365 }
366
367 /// `Num2Word_Base.to_cardinal` for integral input.
368 ///
369 /// Reimplemented rather than delegating to `base::default_to_cardinal`
370 /// solely because FR overrides `errmsg_toobig` with a French message.
371 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
372 let mut out = String::new();
373 let mut v = value.clone();
374 if v.is_negative() {
375 v = v.abs();
376 out = format!("{} ", self.negword().trim());
377 }
378
379 if &v >= self.maxval() {
380 return Err(N2WError::Overflow(format!(
381 "Nombre trop grand pour être converti en mots (abs({}) > {}).",
382 v,
383 self.maxval()
384 )));
385 }
386
387 let tree = splitnum(self, &v).ok_or_else(|| {
388 N2WError::Overflow(format!(
389 "Nombre trop grand pour être converti en mots (abs({}) > {}).",
390 v,
391 self.maxval()
392 ))
393 })?;
394 let words = match clean(self, tree) {
395 Node::Leaf(t, _) => t,
396 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
397 };
398 Ok(self.title(&format!("{}{}", out, words)))
399 }
400
401 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
402 let (ctext_in, cnum) = l;
403 let (ntext_in, nnum) = r;
404 let mut ctext = ctext_in.to_string();
405 let mut ntext = ntext_in.to_string();
406
407 let hundred = BigInt::from(100);
408 let thousand = BigInt::from(1000);
409 let million = BigInt::from(1_000_000);
410
411 if cnum.is_one() {
412 if nnum < &million {
413 return (ntext, nnum.clone());
414 }
415 // NB: when nnum >= 1_000_000 Python falls through the `cnum == 1`
416 // branch without returning *and* without running the `else` body,
417 // so ctext/ntext stay untouched. Hence "un million" (no -s).
418 } else {
419 // if ((not (cnum - 80) % 100 or (not cnum % 100 and cnum < 1000))
420 // and nnum < 1000000 and ctext[-1] == "s"):
421 // ctext = ctext[:-1]
422 //
423 // Python's % on a negative left operand floors (e.g. (20-80) % 100
424 // == 40), so mod_floor is used rather than Rust's remainder. Only
425 // the ==0 test matters here, where the two agree, but keep it exact.
426 let c_minus_80 = cnum.clone() - BigInt::from(80);
427 let cond = (c_minus_80.mod_floor(&hundred).is_zero()
428 || (cnum.mod_floor(&hundred).is_zero() && cnum < &thousand))
429 && nnum < &million
430 && ctext.chars().last() == Some('s');
431 if cond {
432 ctext.pop();
433 }
434
435 // if cnum < 1000 and nnum != 1000 and ntext[-1] != "s" and not nnum % 100:
436 // ntext += "s"
437 // The `nnum != 1000` guard is what keeps "deux mille" singular.
438 if cnum < &thousand
439 && nnum != &thousand
440 && ntext.chars().last() != Some('s')
441 && nnum.mod_floor(&hundred).is_zero()
442 {
443 ntext.push('s');
444 }
445 }
446
447 // if nnum < cnum < 100 (Python chained comparison)
448 if nnum < cnum && cnum < &hundred {
449 if nnum.mod_floor(&BigInt::from(10)).is_one() && cnum != &BigInt::from(80) {
450 return (format!("{}-et-{}", ctext, ntext), cnum + nnum);
451 }
452 return (format!("{}-{}", ctext, ntext), cnum + nnum);
453 }
454 if nnum > cnum {
455 return (format!("{} {}", ctext, ntext), cnum * nnum);
456 }
457 (format!("{} {}", ctext, ntext), cnum + nnum)
458 }
459
460 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
461 self.verify_ordinal(value)?;
462 if value.is_one() {
463 return Ok("premier".to_string());
464 }
465 let word = self.to_cardinal(value)?;
466
467 // Big-unit ordinals: drop a leading "un ", strip trailing plural "s",
468 // re-pluralize the -ième suffix when the count was > 1.
469 for &unit in BIG_UNITS.iter() {
470 if word == format!("un {}", unit) || word.ends_with(&format!(" {}s", unit)) {
471 let plural = word.ends_with('s');
472 // Python's str.rstrip("s") strips *all* trailing 's'.
473 let rstripped = word.trim_end_matches('s');
474 let stripped = match rstripped.strip_prefix("un ") {
475 Some(rest) => rest,
476 None => rstripped,
477 };
478 return Ok(format!(
479 "{}ième{}",
480 stripped,
481 if plural { "s" } else { "" }
482 ));
483 }
484 if word == unit {
485 return Ok(format!("{}ième", unit));
486 }
487 }
488
489 let mut word = word;
490 let mut matched = false;
491 for &(src, repl) in ORDS.iter() {
492 if word.ends_with(src) {
493 // `src` is ASCII and is a suffix of `word`, so this byte index
494 // is a valid char boundary.
495 let head = word.len() - src.len();
496 word.truncate(head);
497 word.push_str(repl);
498 matched = true;
499 break;
500 }
501 }
502 if !matched {
503 // Python's for/else: only runs when no `src` matched.
504 if word.chars().last() == Some('e') {
505 word.pop();
506 }
507 // Drop the trailing 's' of "cents" / "vingts" before -ième.
508 // (The `endswith("ents")` arm is redundant — anything ending in
509 // "ents" already ends in "ts" — but it is kept for fidelity.)
510 if word.ends_with("ts") || word.ends_with("ents") {
511 word.pop();
512 }
513 word.push_str("ième");
514 }
515 Ok(word)
516 }
517
518 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
519 self.verify_ordinal(value)?;
520 let mut out = value.to_string();
521 out.push_str(if value.is_one() { "er" } else { "me" });
522 Ok(out)
523 }
524
525 /// FR's `to_year` ignores suffix/longval entirely and just delegates.
526 fn to_year(&self, value: &BigInt) -> Result<String> {
527 self.to_cardinal(value)
528 }
529
530 // ---- float/Decimal entries -------------------------------------------
531 //
532 // Python's dispatcher hands floats/Decimals straight to the converter
533 // methods, so `verify_ordinal`'s float/negative checks and `to_year`'s
534 // `int()` truncation become reachable here — unlike on the BigInt hooks
535 // above, where they are dead code. `to_cardinal` needs no override: FR
536 // inherits base's `assert int(value) == value` routing, which is exactly
537 // the trait default (whole -> int path).
538
539 /// `Num2Word_FR.to_ordinal(float/Decimal)`: `verify_ordinal`, then the
540 /// integer path. Whole values ordinalise (5.0 -> "cinquième", 1.0 ->
541 /// "premier" via the `value == 1` special case, Decimal("1E+2") ->
542 /// "centième", 1e+16 -> the plural quirk "dix billiardièmes");
543 /// fractional or negative values raise TypeError.
544 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
545 let i = self.verify_ordinal_float(value)?;
546 self.to_ordinal(&i)
547 }
548
549 /// `Num2Word_FR.to_ordinal_num(float/Decimal)`:
550 /// `str(value) + ("er" if value == 1 else "me")`. The numeric half is
551 /// Python's `str(value)` (`repr_str`), so 1.0 yields "1.0er",
552 /// Decimal("5.00") "5.00me", -0.0 "-0.0me" and Decimal("1E+20")
553 /// "1E+20me". No overflow is possible — Python never words the value.
554 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
555 let i = self.verify_ordinal_float(value)?;
556 Ok(format!(
557 "{}{}",
558 repr_str,
559 if i.is_one() { "er" } else { "me" }
560 ))
561 }
562
563 /// `Num2Word_FR.to_year(float/Decimal)`: `self.to_cardinal(int(val))` —
564 /// `int()` truncates toward zero, so 2.5 -> "deux", -1.5 -> "moins un",
565 /// 0.5 -> "zéro". No non-integer guard (contrast EN's TypeError):
566 /// fractional values are silently truncated, floats and Decimals alike.
567 /// NaN/±inf floats reproduce `int()`'s ValueError/OverflowError, though
568 /// the dispatcher keeps those on the Python side.
569 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
570 let i = match value {
571 FloatValue::Float { value: f, .. } => {
572 if f.is_nan() {
573 return Err(N2WError::Value(
574 "cannot convert float NaN to integer".into(),
575 ));
576 }
577 if f.is_infinite() {
578 return Err(N2WError::Overflow(
579 "cannot convert float infinity to integer".into(),
580 ));
581 }
582 // Exact: every whole f64 is exactly representable.
583 BigInt::from_f64(f.trunc()).ok_or_else(|| {
584 N2WError::Value("cannot convert float NaN to integer".into())
585 })?
586 }
587 // int(Decimal) truncates toward zero.
588 FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
589 };
590 self.to_cardinal(&i)
591 }
592
593 // ---- fractions --------------------------------------------------------
594
595 /// Port of `Num2Word_FR.to_fraction` (issue #584): idiomatic
596 /// demi/tiers/quart for denominators 2/3/4 — "tiers" is invariant in the
597 /// plural ("1000000/3" -> "un million tiers"), demi/quart take -s. Every
598 /// other denominator is ordinal-as-noun, appending "s" only when the
599 /// numerator isn't 1 *and* the ordinal doesn't already end in 's' (big
600 /// units like "dix millionièmes" arrive pre-pluralised from
601 /// `to_ordinal`). `d == 1` (exactly 1, not -1) or `n == 0`
602 /// short-circuits to the signed cardinal before any sign normalisation,
603 /// and a zero denominator raises ZeroDivisionError first ("0/0" raises).
604 fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
605 if denominator.is_zero() {
606 return Err(N2WError::ZeroDivision(
607 "denominator must not be zero".into(),
608 ));
609 }
610 if denominator.is_one() || numerator.is_zero() {
611 return self.to_cardinal(numerator);
612 }
613 // is_negative = (numerator < 0) ^ (denominator < 0)
614 let is_negative = numerator.is_negative() ^ denominator.is_negative();
615 let abs_n = numerator.abs();
616 let abs_d = denominator.abs();
617 let den_word = if abs_d == BigInt::from(2) {
618 (if abs_n.is_one() { "demi" } else { "demis" }).to_string()
619 } else if abs_d == BigInt::from(3) {
620 // 'tiers' is invariant in plural.
621 "tiers".to_string()
622 } else if abs_d == BigInt::from(4) {
623 (if abs_n.is_one() { "quart" } else { "quarts" }).to_string()
624 } else {
625 let mut w = self.to_ordinal(&abs_d)?;
626 if !abs_n.is_one() && !w.ends_with('s') {
627 w.push('s');
628 }
629 w
630 };
631 let sign = if is_negative {
632 format!("{} ", self.negword().trim())
633 } else {
634 String::new()
635 };
636 Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
637 }
638
639 // ---- currency -------------------------------------------------------
640 //
641 // FR overrides `CURRENCY_FORMS`, `pluralize` and `to_currency`. It does
642 // *not* override `_money_verbose`, `_cents_verbose`, `_cents_terse` or
643 // `to_cheque`, so those keep the trait defaults that mirror
644 // `Num2Word_Base`.
645 //
646 // `currency_precision` is deliberately NOT overridden: FR declares no
647 // `CURRENCY_PRECISION`, and `Num2Word_EN.__init__` *rebinds* rather than
648 // mutates that attribute, so its 3-decimal table never leaks up to
649 // `Num2Word_Base`. FR therefore sees `{}` and every currency divides by
650 // 100 — including JPY/KRW. That is why the corpus wants
651 // "douze yens et trente-quatre sens" instead of a rounded, subunit-less
652 // yen, and why `default_to_currency`'s `divisor == 1` branch is
653 // unreachable from FR.
654
655 fn lang_name(&self) -> &str {
656 "Num2Word_FR"
657 }
658
659 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
660 self.currency_forms.get(code)
661 }
662
663 fn currency_adjective(&self, code: &str) -> Option<&str> {
664 self.currency_adjectives.get(code).copied()
665 }
666
667 /// `Num2Word_FR.pluralize`: `forms[0] if abs(n) <= 1 else forms[1]`.
668 ///
669 /// Overrides `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`): in
670 /// French **zero takes the singular**, so 1.0 EUR is
671 /// "un euro et zéro centime", not "…zéro centimes". The `abs()` also means
672 /// -1 is singular, though the currency path always passes a magnitude.
673 ///
674 /// Python indexes `forms[1]` directly, so a one-form entry with abs(n) > 1
675 /// would raise IndexError. Every FR entry has two forms, making that
676 /// unreachable — it is mapped to `Index` rather than panicking so the
677 /// exception type survives if the table ever changes.
678 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
679 let form = if n.abs() <= BigInt::one() { 0 } else { 1 };
680 forms
681 .get(form)
682 .cloned()
683 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
684 }
685
686 /// `Num2Word_FR.to_currency`.
687 ///
688 /// FR intercepts the integer case with its own body and delegates
689 /// everything else to `Num2Word_Base.to_currency` via `super()`. The int
690 /// branch is not a reimplementation of Base's — it differs in three ways
691 /// that are all observable, and all reproduced here:
692 ///
693 /// 1. **An unknown currency does not raise.** Base turns the `KeyError`
694 /// into `NotImplementedError`; FR catches it and returns a bare
695 /// `to_cardinal(val)`. So `to_currency(0, "KWD")` is "zéro" — no unit,
696 /// no error — while `to_currency(0.5, "KWD")` *does* raise, because
697 /// that one reaches Base. The corpus pins both.
698 /// 2. **`adjective` is ignored**, since FR's branch never consults
699 /// `CURRENCY_ADJECTIVES`. `to_currency(2, "USD", adjective=True)` is
700 /// "deux dollars", not "deux US dollars" — the adjective only survives
701 /// on the float path.
702 /// 3. **The plural rule is inlined, not `self.pluralize`.** It agrees with
703 /// FR's `pluralize` on the 0/1-singular rule but degrades safely
704 /// (`cr1[1] if len(cr1) > 1 else cr1[0]`) where `pluralize` would
705 /// IndexError.
706 ///
707 /// Note the fallback returns `to_cardinal(val)` on the **signed** value,
708 /// not `abs_val`, so a negative int keeps its "moins".
709 fn to_currency(
710 &self,
711 val: &CurrencyValue,
712 currency: &str,
713 cents: bool,
714 separator: Option<&str>,
715 adjective: bool,
716 ) -> Result<String> {
717 // Python: `if isinstance(val, int)` — true ints only. A float that
718 // happens to be whole (1.0) is *not* one and still renders cents.
719 if let CurrencyValue::Int(v) = val {
720 let forms = match self.currency_forms.get(currency) {
721 Some(f) => f,
722 // except KeyError: return self.to_cardinal(val)
723 None => return self.to_cardinal(v),
724 };
725
726 let minus_str = if v.is_negative() {
727 format!("{} ", self.negword().trim())
728 } else {
729 String::new()
730 };
731 let abs_val = v.abs();
732 let money_str = self.to_cardinal(&abs_val)?;
733
734 let currency_str = if abs_val <= BigInt::one() {
735 forms.unit.first()
736 } else {
737 forms.unit.get(1).or_else(|| forms.unit.first())
738 }
739 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
740
741 return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
742 }
743
744 // Floats/Decimals: super(Num2Word_FR, self).to_currency(...).
745 crate::currency::default_to_currency(
746 self,
747 val,
748 currency,
749 cents,
750 separator.unwrap_or(self.default_separator()),
751 adjective,
752 )
753 }
754}