num2words2_core/lang_af.rs
1//! Port of `lang_AF.py` (Afrikaans) via its `Num2Word_EUR` → `Num2Word_Base`
2//! ancestry.
3//!
4//! Engine-style: AF supplies `high_numwords`/`mid_numwords`/`low_numwords` +
5//! `merge` and lets `Num2Word_Base.to_cardinal` drive `splitnum`/`clean`.
6//!
7//! # Inherited behaviour chased to its definition
8//!
9//! * `set_high_numwords` comes from `Num2Word_EUR`: long scale, step -6, with
10//! *two* cards per stem — `GIGA_SUFFIX` at `10**n` and `MEGA_SUFFIX` at
11//! `10**(n-3)`. AF overrides the suffixes to "iljard"/"iljoen", giving
12//! miljoen 10^6 / miljard 10^9 / biljoen 10^12 / biljard 10^15 / ...
13//! * `gen_high_numwords` also comes from `Num2Word_EUR` and is reproduced
14//! verbatim below (see `gen_high_numwords`), elision table included.
15//! * `AF.setup()` calls `super().setup()`, which assigns the *English* EUR
16//! `high_numwords`; AF then immediately reassigns `high_numwords` to its own
17//! Afrikaans list, so the EUR assignment is dead. Only AF's list is modelled.
18//! * `is_title` stays false (`Num2Word_Base.__init__`), so `title()` is a
19//! no-op; `exclude_title` is `[]`. Both left at the trait defaults.
20//!
21//! # Faithfully reproduced Python oddities
22//!
23//! Per the porting contract these are preserved verbatim, not fixed:
24//!
25//! * `to_ordinal(0)` → "nullde" (double "l"). `ords["nul"] = "nulld"` and
26//! `to_ordinal` appends "e", so "nul" → "nulld" → "nullde". Idiomatic
27//! Afrikaans is "nulde".
28//! * `to_year(1801)` → "agttien een", `to_year(1901)` → "negentien een".
29//! The tens-less years drop the "nul": normal usage is "agttien nul een".
30//! * `to_year(10000)` → "een honderd honderd" (century=100, year_part=0).
31//! Nonsense, but that is what the `val >= 2000` branch computes.
32//! * `to_ordinal_num` special-cases only 2..=8 for the "de" suffix, so
33//! 22 → "22ste" while 2 → "2de".
34//! * The 10^600/10^603 stem is "send" (AF's `high_numwords[0]`), yielding
35//! "sendiljoen"/"sendiljard" rather than a "sentiljoen" spelling.
36//! * `merge`'s `nnum == 100 or nnum == 1000` space rule is redundant with the
37//! `nnum >= 10**6` arm for every card AF actually has, but is kept as-is.
38//!
39//! # Verification
40//!
41//! All 305 integer in-scope corpus rows (cardinal/ordinal/ordinal_num/year)
42//! reproduce exactly, cross-checked by re-implementing this exact logic and
43//! diffing against `Num2Word_AF` over ~73k additional calls spanning 0..2200,
44//! powers of ten to 10^606, random values to 10^60, negatives, the overflow
45//! boundary, and a -60..12000 year sweep. Zero mismatches.
46//!
47//! Currency: all 117 `currency:*`/`cheque:*` corpus rows reproduce exactly,
48//! plus 2268 differential cases diffed against the live `Num2Word_AF` covering
49//! what the corpus does not reach — ZAR (AF's *own* default currency, which the
50//! corpus never exercises), `adjective=True`, `cents=False`, fractional cents,
51//! 1e21, half-up rounding ties (0.005/2.675), and unknown/lowercased codes.
52//! Zero mismatches.
53
54use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
55use crate::currency::CurrencyForms;
56use crate::floatpath::{default_to_cardinal_float, FloatValue};
57use crate::strnum::python_decimal_str;
58use bigdecimal::BigDecimal;
59use num_bigint::{BigInt, Sign};
60use num_integer::Integer;
61use num_traits::{FromPrimitive, One, Signed, Zero};
62use std::collections::HashMap;
63
64/// Port of `Num2Word_EUR.gen_high_numwords`.
65///
66/// The elision table is Latin/English-oriented while AF's units and tens are
67/// Afrikaans ("kwin" not "quint", "seks" not "sex", "tre" not "tres", "okto"
68/// not "octo"), so most rules are dead here. Exactly three still fire:
69/// `novemn`, `novemo` and `unno`. They are all kept anyway — reproducing the
70/// inherited method, not an optimised equivalent.
71///
72/// Note `"novemnonagint"` → `"novenonagint"`: the rule's trailing `n` consumes
73/// the first `n` of "nonagint", so the result is *not* "novennonagint".
74fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
75 let mut out: Vec<String> = Vec::new();
76 // Python: [u + t for t in tens for u in units] — tens is the outer loop.
77 for t in tens {
78 for u in units {
79 out.push(format!("{}{}", u, t));
80 }
81 }
82 out.reverse();
83
84 const REPLACEMENTS: &[(&str, &str)] = &[
85 ("novemn", "noven"),
86 ("novemo", "novo"),
87 ("octoo", "octo"),
88 ("quintd", "quind"),
89 ("quintn", "quin"),
90 ("quintq", "quinq"),
91 ("quints", "quins"),
92 ("quintt", "quint"),
93 ("quintv", "quinv"),
94 ("septenn", "septen"),
95 ("septent", "sept"),
96 ("sexn", "sen"),
97 ("sexs", "ses"),
98 ("tresd", "tred"),
99 ("tresn", "tren"),
100 ("tress", "tres"),
101 ("tresv", "trev"),
102 ("unno", "uno"),
103 ];
104 for (k, v) in REPLACEMENTS {
105 out = out.iter().map(|o| o.replace(k, v)).collect();
106 }
107 out.extend(lows.iter().map(|s| s.to_string()));
108 out
109}
110
111/// `Num2Word_AF.ords`, in **insertion order**.
112///
113/// This is a `&[(…)]` slice and not a map on purpose: `to_ordinal` scans the
114/// dict and `break`s on the first `endswith` hit, so Python's dict insertion
115/// order is load-bearing. "nul" must be tested before "een"; the single-word
116/// keys must all be tested before the compound endings "ig"/"erd"/"end"/
117/// "joen"/"rd". Re-ordering silently changes output (e.g. "dertien" would hit
118/// "drie"-family keys or "tien" depending on order).
119const ORDS: &[(&str, &str)] = &[
120 ("nul", "nulld"),
121 ("een", "eerst"),
122 ("twee", "tweed"),
123 ("drie", "derd"),
124 ("vier", "vierd"),
125 ("vyf", "vyfd"),
126 ("ses", "sesd"),
127 ("sewe", "sewend"),
128 ("agt", "agst"),
129 ("nege", "negend"),
130 ("tien", "tiend"),
131 ("elf", "elfd"),
132 ("twaalf", "twaalfd"),
133 // Compound endings.
134 ("ig", "igst"),
135 ("erd", "erdst"),
136 ("end", "endst"),
137 ("joen", "joenst"),
138 ("rd", "rdst"),
139];
140
141/// `Num2Word_AF.CURRENCY_FORMS`, verbatim from the class body.
142///
143/// AF declares its **own** `CURRENCY_FORMS` in the class body, which shadows
144/// `Num2Word_EUR.CURRENCY_FORMS` entirely. That matters: `Num2Word_EN.__init__`
145/// mutates the *EUR* dict in place (`self.CURRENCY_FORMS["EUR"] = ...`) and
146/// `__init__.py` instantiates `Num2Word_EN()` at import time, so every class
147/// that merely inherits the dict silently picks up English forms plus EN's ~24
148/// extra codes. AF does not: `Num2Word_AF.CURRENCY_FORMS is
149/// Num2Word_EUR.CURRENCY_FORMS` is `False` on the live interpreter, so the
150/// table is exactly these five codes and nothing else.
151///
152/// Every other code — JPY, KWD, BHD, INR, CHF, and the ~20 more EN would have
153/// contributed — therefore raises NotImplementedError. The corpus pins that:
154/// AF has no 3-decimal (KWD/BHD) or 0-decimal (JPY) currency to render.
155fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
156 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
157 m.insert("ZAR", CurrencyForms::new(&["rand", "rand"], &["sent", "sent"]));
158 m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["sent", "sent"]));
159 m.insert("GBP", CurrencyForms::new(&["pond", "pond"], &["penny", "pence"]));
160 m.insert("USD", CurrencyForms::new(&["dollar", "dollar"], &["sent", "sent"]));
161 m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["jiao", "fen"]));
162 m
163}
164
165/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
166///
167/// The mirror image of the forms table above: AF defines no
168/// `CURRENCY_ADJECTIVES`, so it reads EUR's shared dict — and unlike
169/// `CURRENCY_FORMS`, EN never writes to that one, so no mutation leaks in and
170/// the source literal *is* what runs (`Num2Word_AF.CURRENCY_ADJECTIVES is
171/// Num2Word_EUR.CURRENCY_ADJECTIVES` is `True`).
172///
173/// Only USD overlaps AF's five currency codes, so "US" is the sole reachable
174/// entry — `adjective=True` on any other code finds nothing and leaves the
175/// forms bare. The 15 dead entries are kept because the ported artefact is the
176/// inherited dict, not the reachable subset of it.
177fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
178 [
179 ("AUD", "Australian"),
180 ("BYN", "Belarusian"),
181 ("CAD", "Canadian"),
182 ("EEK", "Estonian"),
183 ("USD", "US"),
184 ("RUB", "Russian"),
185 ("NOK", "Norwegian"),
186 ("MXN", "Mexican"),
187 ("RON", "Romanian"),
188 ("INR", "Indian"),
189 ("HUF", "Hungarian"),
190 ("ISK", "íslenskar"),
191 ("UZS", "Uzbekistan"),
192 ("SAR", "Saudi"),
193 ("JPY", "Japanese"),
194 ("KRW", "Korean"),
195 ]
196 .into_iter()
197 .collect()
198}
199
200/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the fractional
201/// digit count of the shortest round-trip repr. Mirrors the private helper in
202/// `floatpath`.
203fn float_repr_precision(f: f64) -> u32 {
204 let s = format!("{}", f);
205 match s.split_once('.') {
206 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
207 _ => 0,
208 }
209}
210
211/// Best-effort `str(number)` for the TypeError messages (the corpus compares
212/// exception types only, so the float arm's repr need not be byte-exact).
213fn py_num_str(value: &FloatValue) -> String {
214 match value {
215 FloatValue::Float { value, .. } => format!("{}", value),
216 FloatValue::Decimal { value, .. } => python_decimal_str(value),
217 }
218}
219
220pub struct LangAf {
221 cards: Cards,
222 maxval: BigInt,
223 hundred: BigInt,
224 thousand: BigInt,
225 mega: BigInt,
226 currency_forms: HashMap<&'static str, CurrencyForms>,
227 currency_adjectives: HashMap<&'static str, &'static str>,
228}
229
230impl Default for LangAf {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236impl LangAf {
237 pub fn new() -> Self {
238 // AF's own Afrikaans stems (these replace the EUR ones set by super().setup()).
239 let lows = ["non", "okt", "sept", "sext", "kwint", "kwadr", "tr", "b", "m"];
240 let units = [
241 "", "un", "duo", "tre", "kwattuor", "kwin", "seks", "sept", "okto", "novem",
242 ];
243 let tens = [
244 "des",
245 "vigint",
246 "trigint",
247 "kwadragint",
248 "kwinquagint",
249 "seksagint",
250 "septuagint",
251 "oktogint",
252 "nonagint",
253 ];
254 let mut high = vec!["send".to_string()];
255 high.extend(gen_high_numwords(&units, &tens, &lows));
256 // 1 + (9 tens * 10 units) + 9 lows = 100 stems.
257
258 let mut cards = Cards::new();
259
260 // Num2Word_EUR.set_high_numwords: cap = 3 + 6*len(high) = 603;
261 // zip(high, range(603, 3, -6)) pairs all 100 stems (range has exactly
262 // 100 elements), GIGA at 10**n and MEGA at 10**(n-3). The `n <= 3`
263 // guard reproduces the range's stop bound, and `zip` truncation is
264 // moot here since both sides are length 100.
265 let cap = 3 + 6 * high.len() as i64;
266 let mut n = cap;
267 for word in high.iter() {
268 if n <= 3 {
269 break;
270 }
271 cards.insert(
272 BigInt::from(10u8).pow(n as u32),
273 format!("{}{}", word, "iljard"), // AF GIGA_SUFFIX
274 );
275 cards.insert(
276 BigInt::from(10u8).pow((n - 3) as u32),
277 format!("{}{}", word, "iljoen"), // AF MEGA_SUFFIX
278 );
279 n -= 6;
280 }
281
282 set_mid_numwords(
283 &mut cards,
284 &[
285 (1000, "duisend"),
286 (100, "honderd"),
287 (90, "negentig"),
288 (80, "tagtig"),
289 (70, "sewentig"),
290 (60, "sestig"),
291 (50, "vyftig"),
292 (40, "veertig"),
293 (30, "dertig"),
294 ],
295 );
296 set_low_numwords(
297 &mut cards,
298 &[
299 "twintig",
300 "negentien",
301 "agttien",
302 "sewentien",
303 "sestien",
304 "vyftien",
305 "veertien",
306 "dertien",
307 "twaalf",
308 "elf",
309 "tien",
310 "nege",
311 "agt",
312 "sewe",
313 "ses",
314 "vyf",
315 "vier",
316 "drie",
317 "twee",
318 "een",
319 "nul",
320 ],
321 );
322
323 // MAXVAL = 1000 * first inserted card. Python's OrderedDict insertion
324 // order here is strictly descending (high 10^603 down, then mid, then
325 // low), so keys()[0] == the highest card == 10^603 → MAXVAL = 10^606.
326 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
327
328 LangAf {
329 cards,
330 maxval,
331 hundred: BigInt::from(100),
332 thousand: BigInt::from(1000),
333 mega: BigInt::from(10u8).pow(6),
334 // Built once here, never per call: the currency path only reads
335 // these, and rebuilding them on each call is what made an earlier
336 // revision of this port slower than the Python it replaces.
337 currency_forms: build_currency_forms(),
338 currency_adjectives: build_currency_adjectives(),
339 }
340 }
341
342 /// `Num2Word_Base.verify_ordinal`. Integer input can only trip the
343 /// negative check; both arms raise TypeError in Python.
344 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
345 if value.sign() == Sign::Minus {
346 return Err(N2WError::Type(format!(
347 "Kan nie die negatiewe getal {} as 'n ordinale getal behandel nie.",
348 value
349 )));
350 }
351 Ok(())
352 }
353
354 /// `verify_ordinal` for a float/Decimal: the float check fires first,
355 /// then the negative one — both TypeError, with AF's Afrikaans wording.
356 /// `-0.0` passes both. Returns the whole value on success.
357 fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
358 let whole = match value.as_whole_int() {
359 Some(i) => i,
360 None => {
361 return Err(N2WError::Type(format!(
362 "Kan nie die desimale getal {} as 'n ordinale getal behandel nie.",
363 py_num_str(value)
364 )))
365 }
366 };
367 if whole.is_negative() {
368 return Err(N2WError::Type(format!(
369 "Kan nie die negatiewe getal {} as 'n ordinale getal behandel nie.",
370 py_num_str(value)
371 )));
372 }
373 Ok(whole)
374 }
375
376 /// Python's `self.to_cardinal(x)` for an f64 — Base semantics: whole →
377 /// integer path, fractional → the base float grammar ("komma" + digits).
378 fn cardinal_num_f64(&self, x: f64) -> Result<String> {
379 if x.fract() == 0.0 {
380 if let Some(i) = BigInt::from_f64(x) {
381 return self.to_cardinal(&i);
382 }
383 }
384 default_to_cardinal_float(
385 self,
386 &FloatValue::Float {
387 value: x,
388 precision: float_repr_precision(x),
389 },
390 None,
391 )
392 }
393
394 /// Python's `self.to_cardinal(x)` for a Decimal.
395 fn cardinal_num_dec(&self, x: &BigDecimal) -> Result<String> {
396 if x.is_integer() {
397 return self.to_cardinal(&x.with_scale(0).as_bigint_and_exponent().0);
398 }
399 let precision = x.as_bigint_and_exponent().1.max(0) as u32;
400 default_to_cardinal_float(
401 self,
402 &FloatValue::Decimal {
403 value: x.clone(),
404 precision,
405 },
406 None,
407 )
408 }
409
410 /// Shared tail of `to_year`'s two century-splitting branches.
411 fn year_century_split(&self, val: &BigInt) -> Result<String> {
412 let century = val.div_floor(&self.hundred);
413 let year_part = val.mod_floor(&self.hundred);
414 if year_part.is_zero() {
415 Ok(format!("{} honderd", self.to_cardinal(¢ury)?))
416 } else {
417 Ok(format!(
418 "{} {}",
419 self.to_cardinal(¢ury)?,
420 self.to_cardinal(&year_part)?
421 ))
422 }
423 }
424}
425
426impl Lang for LangAf {
427 /// This language's own `to_currency(currency=...)` default,
428 /// read from the live Python signature. Only 44 of 156 use EUR.
429 fn default_currency(&self) -> &str {
430 "ZAR"
431 }
432
433 /// This language's own `to_currency(separator=...)` default,
434 /// read from the live Python signature. Base's is ",", but only
435 /// 36 of 149 languages actually use it — most default to " " or a
436 /// conjunction, so inheriting Base's comma silently corrupts them.
437 fn default_separator(&self) -> &str {
438 " en"
439 }
440
441 fn cards(&self) -> &Cards {
442 &self.cards
443 }
444 fn maxval(&self) -> &BigInt {
445 &self.maxval
446 }
447 fn negword(&self) -> &str {
448 "minus "
449 }
450 fn pointword(&self) -> &str {
451 "komma"
452 }
453
454 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
455 let (ctext0, cnum) = l;
456 let (ntext0, nnum) = r;
457 let mut ctext = ctext0.to_string();
458 let mut ntext = ntext0.to_string();
459
460 if cnum.is_one() {
461 if nnum == &self.hundred || nnum == &self.thousand {
462 return (format!("een {}", ntext), nnum.clone());
463 } else if nnum < &self.mega {
464 // Python `return next` — the right tuple, unmodified.
465 return (ntext, nnum.clone());
466 }
467 ctext = "een".to_string();
468 }
469
470 let val;
471 if nnum > cnum {
472 if nnum >= &self.mega {
473 ctext.push(' ');
474 } else if nnum == &self.hundred || nnum == &self.thousand {
475 ctext.push(' ');
476 }
477 val = cnum * nnum;
478 } else {
479 let ten = BigInt::from(10);
480 // Python chained comparison: nnum < 10 and 10 < cnum and cnum < 100.
481 if nnum < &ten && &ten < cnum && cnum < &self.hundred {
482 if nnum.is_one() {
483 ntext = "een".to_string();
484 }
485 // Afrikaans compound formation: vier-en-dertig.
486 ntext = format!("{}-en-{}", ntext, ctext);
487 ctext = String::new();
488 } else if cnum >= &self.mega {
489 ctext.push(' ');
490 } else if cnum >= &self.hundred {
491 ctext.push(' ');
492 }
493 val = cnum + nnum;
494 }
495
496 (format!("{}{}", ctext, ntext), val)
497 }
498
499 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
500 self.verify_ordinal(value)?;
501 let mut outword = self.to_cardinal(value)?;
502
503 // First matching suffix wins, then `break` — see ORDS on why order matters.
504 for (key, rep) in ORDS.iter() {
505 if outword.ends_with(key) {
506 // All AF numwords are ASCII, and a matched suffix always ends
507 // on a char boundary regardless, so this slice is safe.
508 outword = format!("{}{}", &outword[..outword.len() - key.len()], rep);
509 break;
510 }
511 }
512
513 Ok(format!("{}e", outword))
514 }
515
516 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
517 self.verify_ordinal(value)?;
518 // Python: `if value in [2, 3, 4, 5, 6, 7, 8]` — contiguous, so a range
519 // test is equivalent for integer input.
520 if value >= &BigInt::from(2) && value <= &BigInt::from(8) {
521 Ok(format!("{}de", value))
522 } else {
523 Ok(format!("{}ste", value))
524 }
525 }
526
527 fn to_year(&self, value: &BigInt) -> Result<String> {
528 let two_thousand = BigInt::from(2000);
529
530 if value >= &two_thousand {
531 if value == &two_thousand {
532 self.to_cardinal(value)
533 } else if value < &BigInt::from(2010) {
534 // 2001-2009: "twee duisend een", etc.
535 Ok(format!(
536 "{} {}",
537 self.to_cardinal(&two_thousand)?,
538 self.to_cardinal(&(value - &two_thousand))?
539 ))
540 } else {
541 // 2010+: "twintig tien", "twintig elf", etc.
542 self.year_century_split(value)
543 }
544 } else if value < &BigInt::from(1000) {
545 // Also the path every negative year takes, so the floor-division
546 // below is never reached with a negative operand.
547 self.to_cardinal(value)
548 } else if value
549 .div_floor(&self.hundred)
550 .mod_floor(&BigInt::from(10))
551 .is_zero()
552 {
553 // Python: `elif not (val // 100) % 10` — e.g. 1000..1099 stay cardinal.
554 self.to_cardinal(value)
555 } else {
556 self.year_century_split(value)
557 }
558 }
559
560 /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for any
561 /// fractional or negative value; a whole non-negative one flows through
562 /// `to_cardinal` + the `ords` suffix table — identical to the integer
563 /// ordinal ("5.0" → "vyfde", `1e+16` → "tien biljardste").
564 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
565 let whole = self.verify_ordinal_num(value)?;
566 self.to_ordinal(&whole)
567 }
568
569 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal`, then
570 /// `str(value) + ("de" if value in [2..8] else "ste")` — the membership
571 /// test is numeric, so `5.0` and `Decimal("5.00")` both take "de"
572 /// ("5.0de", "5.00de") while everything else takes "ste" ("-0.0ste",
573 /// "1e+16ste").
574 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
575 let whole = self.verify_ordinal_num(value)?;
576 let is_2_to_8 = value.as_whole_int().is_some()
577 && whole >= BigInt::from(2)
578 && whole <= BigInt::from(8);
579 if is_2_to_8 {
580 Ok(format!("{}de", repr_str))
581 } else {
582 Ok(format!("{}ste", repr_str))
583 }
584 }
585
586 /// `to_year(float/Decimal)` — `Num2Word_AF.to_year`'s arithmetic run on
587 /// the raw float/Decimal: negatives and sub-1000 values go straight to
588 /// the cardinal, values whose hundreds digit is zero stay cardinal, and
589 /// the rest split as `val // 100` + `val % 100` — so `1234.0` reads
590 /// "twaalf vier-en-dertig", `100000.0` "een duisend honderd", and
591 /// `1e+20` "een triljoen honderd".
592 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
593 match value {
594 FloatValue::Float { value: v, .. } => {
595 let v = *v;
596 if v >= 2000.0 {
597 if v == 2000.0 {
598 return self.cardinal_num_f64(v);
599 }
600 if v < 2010.0 {
601 // "twee duisend een", etc.
602 return Ok(format!(
603 "{} {}",
604 self.to_cardinal(&BigInt::from(2000))?,
605 self.cardinal_num_f64(v - 2000.0)?
606 ));
607 }
608 } else if v < 1000.0 {
609 // Also every negative year and every small fraction.
610 return self.cardinal_num_f64(v);
611 } else if (v / 100.0).floor().rem_euclid(10.0) == 0.0 {
612 return self.cardinal_num_f64(v);
613 }
614 // Century split: val // 100 and val % 100.
615 let century = (v / 100.0).floor();
616 let year_part = v.rem_euclid(100.0);
617 if year_part == 0.0 {
618 Ok(format!("{} honderd", self.cardinal_num_f64(century)?))
619 } else {
620 Ok(format!(
621 "{} {}",
622 self.cardinal_num_f64(century)?,
623 self.cardinal_num_f64(year_part)?
624 ))
625 }
626 }
627 FloatValue::Decimal { value: d, .. } => {
628 let hundred = BigDecimal::from(100);
629 let two_thousand = BigDecimal::from(2000);
630 if d >= &two_thousand {
631 if d == &two_thousand {
632 return self.cardinal_num_dec(d);
633 }
634 if d < &BigDecimal::from(2010) {
635 return Ok(format!(
636 "{} {}",
637 self.to_cardinal(&BigInt::from(2000))?,
638 self.cardinal_num_dec(&(d - &two_thousand))?
639 ));
640 }
641 } else if d < &BigDecimal::from(1000) {
642 return self.cardinal_num_dec(d);
643 } else {
644 // Decimal // truncates toward zero; positive range here.
645 let q = (d / &hundred)
646 .with_scale_round(0, bigdecimal::RoundingMode::Down);
647 let q_int = q.as_bigint_and_exponent().0;
648 if (&q_int % BigInt::from(10)).is_zero() {
649 return self.cardinal_num_dec(d);
650 }
651 }
652 let century = (d / &hundred).with_scale_round(0, bigdecimal::RoundingMode::Down);
653 let year_part = d - ¢ury * &hundred;
654 if year_part.is_zero() {
655 Ok(format!("{} honderd", self.cardinal_num_dec(¢ury)?))
656 } else {
657 Ok(format!(
658 "{} {}",
659 self.cardinal_num_dec(¢ury)?,
660 self.cardinal_num_dec(&year_part)?
661 ))
662 }
663 }
664 }
665 }
666
667 // ---- currency -------------------------------------------------------
668 //
669 // `Num2Word_AF.to_currency` only restates `Num2Word_Base.to_currency`'s
670 // signature with Afrikaans defaults (`currency="ZAR"`, `separator=" en"`)
671 // and forwards every argument to `super()` unchanged — EUR defines no
672 // `to_currency`, so the call lands straight on Base. Those two defaults are
673 // already carried by `default_currency`/`default_separator` above, so the
674 // trait's `to_currency` is that method, and overriding it here would only
675 // re-express the delegation Python is doing.
676 //
677 // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
678 // likewise inherited from Base untouched, and the trait defaults already
679 // mirror them. So only the two data tables, the class name and AF's own
680 // plural rule are overridden.
681
682 fn lang_name(&self) -> &str {
683 "Num2Word_AF"
684 }
685
686 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
687 self.currency_forms.get(code)
688 }
689
690 fn currency_adjective(&self, code: &str) -> Option<&str> {
691 self.currency_adjectives.get(code).copied()
692 }
693
694 // `currency_precision` is deliberately NOT overridden. AF inherits
695 // `Num2Word_Base.CURRENCY_PRECISION`, which is `{}`: EN *rebinds* rather
696 // than mutates it (`self.CURRENCY_PRECISION = {...}` makes an instance
697 // attribute), so EN's 1000-divisor entries never leak out to siblings. The
698 // live AF instance reports `{}`, hence `.get(code, 100)` is always 100 —
699 // exactly the trait default. AF has no 3-decimal or 0-decimal currency, so
700 // the `divisor == 1` fast path in `default_to_currency` is unreachable here
701 // and KWD/BHD/JPY fail earlier, on the forms lookup.
702
703 /// `Num2Word_AF.pluralize`: `return forms[0]` — the count is ignored.
704 ///
705 /// This is the one hook where AF diverges from `Num2Word_EUR.pluralize`
706 /// (`forms[0 if n == 1 else 1]`), and it is load-bearing rather than a
707 /// no-op — but only just: 8 of AF's 10 form pairs are reduplicated
708 /// ("rand"/"rand"), so the two rules agree there and the divergence hides.
709 /// It surfaces on exactly the other two, both subunits: GBP's
710 /// ("penny", "pence") and CNY's ("jiao", "fen"). EUR's rule would render
711 /// 12.34 GBP as "...vier-en-dertig pence"; AF's says "...vier-en-dertig
712 /// penny". Both readings are plausible Afrikaans, so a mis-port here is
713 /// invisible to inspection. The corpus pins both.
714 ///
715 /// Python indexes the tuple directly, so an empty forms tuple would raise
716 /// IndexError. Every entry in AF's table has two forms, so that is
717 /// unreachable — but it is mapped to `Index` rather than panicking, to keep
718 /// the exception type honest if the table ever changes.
719 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
720 forms
721 .first()
722 .cloned()
723 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
724 }
725}