Skip to main content

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(&century)?))
416        } else {
417            Ok(format!(
418                "{} {}",
419                self.to_cardinal(&century)?,
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 - &century * &hundred;
654                if year_part.is_zero() {
655                    Ok(format!("{} honderd", self.cardinal_num_dec(&century)?))
656                } else {
657                    Ok(format!(
658                        "{} {}",
659                        self.cardinal_num_dec(&century)?,
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}