Skip to main content

num2words2_core/
lang_de.rs

1//! Port of `lang_DE.py` (via its `lang_EUR` → `Num2Word_Base` ancestry).
2//!
3//! DE is an *engine-style* language: it supplies `high/mid/low_numwords` +
4//! `merge` and lets `Num2Word_Base.to_cardinal` drive `splitnum`/`clean`.
5//!
6//! Unlike EN, DE does **not** override `set_high_numwords`, so it inherits
7//! `Num2Word_EUR`'s long scale (step -6, pairing `illion`/`illiarde`):
8//! 10^6 Million, 10^9 Milliarde, 10^12 Billion, 10^15 Billiarde, ...
9//! The top card is 10^603 `zentilliarde`, so MAXVAL is 10^606 — well beyond
10//! any fixed-width integer, hence `BigInt` throughout.
11//!
12//! Note the case asymmetry inherited from Python: DE's `lows` are
13//! capitalised (`M`, `B`, `Tr`, ...) while the generated Latin stems are
14//! lowercase. So 10^6 renders as "Million" but 10^600 as "zentillion".
15//! That is the Python behaviour and is reproduced verbatim.
16
17use crate::base::{
18    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
19};
20use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
21use crate::floatpath::FloatValue;
22use num_bigint::BigInt;
23use num_traits::{FromPrimitive, One, Signed, Zero};
24use std::collections::HashMap;
25
26/// Port of `Num2Word_EUR.gen_high_numwords`.
27///
28/// The elision rules are plain sequential string replaces, not morphological
29/// logic — e.g. "novem"+"nonagint" = "novemnonagint", and replacing the
30/// 6-char "novemn" with the 5-char "noven" yields "novenonagint" (a single
31/// `n`, not "novennonagint"). Verified against Python: the resulting
32/// 100-entry list matches `Num2Word_DE().high_numwords` exactly.
33fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
34    let mut out: Vec<String> = Vec::new();
35    // Python: [u + t for t in tens for u in units] — tens is the outer loop.
36    for t in tens {
37        for u in units {
38            out.push(format!("{}{}", u, t));
39        }
40    }
41    out.reverse();
42
43    const REPLACEMENTS: &[(&str, &str)] = &[
44        ("novemn", "noven"),
45        ("novemo", "novo"),
46        ("octoo", "octo"),
47        ("quintd", "quind"),
48        ("quintn", "quin"),
49        ("quintq", "quinq"),
50        ("quints", "quins"),
51        ("quintt", "quint"),
52        ("quintv", "quinv"),
53        ("septenn", "septen"),
54        ("septent", "sept"),
55        ("sexn", "sen"),
56        ("sexs", "ses"),
57        ("tresd", "tred"),
58        ("tresn", "tren"),
59        ("tress", "tres"),
60        ("tresv", "trev"),
61        ("unno", "uno"),
62    ];
63    for (k, v) in REPLACEMENTS {
64        out = out.iter().map(|o| o.replace(*k, *v)).collect();
65    }
66    out.extend(lows.iter().map(|s| s.to_string()));
67    out
68}
69
70/// `self.ords` from `setup()`. **Order is load-bearing**: `to_ordinal`
71/// iterates the dict and breaks on the first suffix hit, and Python 3.7+
72/// dicts iterate in insertion order. A `HashMap` would be wrong here.
73///
74/// In particular `rde` precedes `rden`, but `rden` is still reachable:
75/// "milliarden" does not end in "rde" (its last three chars are "den").
76const ORDS: &[(&str, &str)] = &[
77    ("eins", "ers"),
78    ("drei", "drit"),
79    ("acht", "ach"),
80    ("sieben", "sieb"),
81    ("ig", "igs"),
82    ("ert", "erts"),
83    ("end", "ends"),
84    ("ion", "ions"),
85    ("nen", "ns"),
86    ("rde", "rds"),
87    ("rden", "rds"),
88];
89
90/// `[a-z]+` — one or more ASCII lowercase. Deliberately excludes the
91/// non-ASCII letters that appear in DE output (ü, ö, ä, ß): Python's
92/// `[a-z]` is a literal codepoint range and does not match them either.
93fn is_ascii_lower_run(s: &str) -> bool {
94    !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase())
95}
96
97/// Predicate for `^[a-z]+(illion|illiard)ste$`.
98///
99/// The trailing `$` pins the alternation's end to `len-3`, so only two start
100/// positions are ever viable and they are mutually exclusive (a string
101/// cannot end in both "illionste" and "illiardste"). That makes the greedy
102/// backtracking order irrelevant and reduces the regex to two suffix tests.
103fn matches_illion_ste(rest: &str) -> bool {
104    if let Some(core) = rest.strip_suffix("illionste") {
105        if is_ascii_lower_run(core) {
106            return true;
107        }
108    }
109    if let Some(core) = rest.strip_suffix("illiardste") {
110        if is_ascii_lower_run(core) {
111            return true;
112        }
113    }
114    false
115}
116
117/// `re.sub(r"eine ([a-z]+(illion|illiard)ste)$", lambda m: m.group(1), res)`
118///
119/// Group 1 spans from just after "eine " to end-of-string, so the
120/// replacement is simply "drop the literal `eine `". `$` permits at most one
121/// match; Python scans left-to-right, so the leftmost viable `eine ` wins.
122fn sub_eine_illion_ste(res: &str) -> String {
123    let b = res.as_bytes();
124    let n = b.len();
125    let mut i = 0usize;
126    while i + 5 <= n {
127        // ASCII bytes never occur inside a multi-byte UTF-8 sequence, so a
128        // byte match on "eine " guarantees i and i+5 are char boundaries.
129        if &b[i..i + 5] == b"eine " {
130            let rest = &res[i + 5..];
131            if matches_illion_ste(rest) {
132                return format!("{}{}", &res[..i], rest);
133            }
134        }
135        i += 1;
136    }
137    res.to_string()
138}
139
140/// `re.sub(r" ([a-z]+(illion|illiard)ste)$", lambda m: m.group(1), res)`
141fn sub_space_illion_ste(res: &str) -> String {
142    let b = res.as_bytes();
143    for i in 0..b.len() {
144        if b[i] == b' ' {
145            let rest = &res[i + 1..];
146            if matches_illion_ste(rest) {
147                return format!("{}{}", &res[..i], rest);
148            }
149        }
150    }
151    res.to_string()
152}
153
154/// `re.sub(r"eine (million|milliard|billion|billiard)", r"ein\1", res)`
155///
156/// Unanchored, so this replaces *every* non-overlapping occurrence.
157/// Alternation order matters in principle; in practice only one alternative
158/// can match at any given position ("million" and "milliard" diverge at the
159/// 6th char), so the outcome is order-independent. "eine milliarde" matches
160/// only the "milliard" stem, leaving the trailing "e" → "einmilliarde".
161fn sub_eine_prefix(res: &str) -> String {
162    const ALTS: [&str; 4] = ["million", "milliard", "billion", "billiard"];
163    let b = res.as_bytes();
164    let n = b.len();
165    let mut out = String::new();
166    let mut i = 0usize;
167    while i < n {
168        let mut matched = false;
169        if i + 5 <= n && &b[i..i + 5] == b"eine " {
170            for alt in ALTS.iter() {
171                let end = i + 5 + alt.len();
172                if end <= n && &b[i + 5..end] == alt.as_bytes() {
173                    out.push_str("ein");
174                    out.push_str(alt);
175                    i = end;
176                    matched = true;
177                    break;
178                }
179            }
180        }
181        if !matched {
182            let ch = res[i..].chars().next().unwrap();
183            out.push(ch);
184            i += ch.len_utf8();
185        }
186    }
187    out
188}
189
190/// `Num2Word_DE.FEMININE_CURRENCIES` (issue #69).
191///
192/// Currencies whose German noun is feminine, so a value ending in 1 needs the
193/// numeral "eine" rather than "ein"/"eins" — "eine Rupie", not "eins Rupie".
194const FEMININE_CURRENCIES: [&str; 2] = ["DEM", "INR"];
195
196/// `Num2Word_DE.CURRENCY_FORMS`.
197///
198/// DE declares its **own** class-level dict, so it shadows `Num2Word_EUR`'s
199/// and is therefore *not* one of the 16 tables `Num2Word_EN.__init__` rewrites
200/// in place. The lang_EUR-source-vs-runtime trap does not apply here, and this
201/// is verified against the live interpreter: 15 entries, EUR staying
202/// ("Euro", "Euro") and GBP ("Pfund", "Pfund").
203///
204/// The corollary matters more: DE cannot see the ~24 codes EN *adds* to the
205/// shared EUR dict, so KWD/BHD/SGD/... genuinely raise NotImplementedError for
206/// German. The corpus agrees — every KWD and BHD row is a NotImplementedError.
207///
208/// Two entries look like data bugs and are ports, not typos:
209/// * CNY's subunits are ("Jiao", "Fen") — two *different* coins (1/10 and
210///   1/100 yuan) filling the singular/plural slots, so 0.01 CNY renders
211///   "eins Jiao" and 1.0 CNY "null Fen". Corpus-confirmed.
212/// * INR's subunit is ("Paisa", "Paisa"); the real plural is "Paise".
213fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
214    const DOLLAR: [&str; 2] = ["Dollar", "Dollar"];
215    const CENT: [&str; 2] = ["Cent", "Cent"];
216
217    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
218    m.insert("EUR", CurrencyForms::new(&["Euro", "Euro"], &CENT));
219    m.insert("GBP", CurrencyForms::new(&["Pfund", "Pfund"], &["Penny", "Pence"]));
220    m.insert("USD", CurrencyForms::new(&DOLLAR, &CENT));
221    m.insert("CAD", CurrencyForms::new(&DOLLAR, &CENT));
222    m.insert("AUD", CurrencyForms::new(&DOLLAR, &CENT));
223    m.insert("NZD", CurrencyForms::new(&DOLLAR, &CENT));
224    m.insert("HKD", CurrencyForms::new(&DOLLAR, &CENT));
225    m.insert("CNY", CurrencyForms::new(&["Yuan", "Yuan"], &["Jiao", "Fen"]));
226    m.insert("DEM", CurrencyForms::new(&["Mark", "Mark"], &["Pfennig", "Pfennig"]));
227    m.insert(
228        "CHF",
229        CurrencyForms::new(
230            &["Schweizer Franken", "Schweizer Franken"],
231            &["Rappen", "Rappen"],
232        ),
233    );
234    m.insert("JPY", CurrencyForms::new(&["Yen", "Yen"], &["Sen", "Sen"]));
235    m.insert("INR", CurrencyForms::new(&["Rupie", "Rupien"], &["Paisa", "Paisa"]));
236    m.insert("RUB", CurrencyForms::new(&["Rubel", "Rubel"], &["Kopeke", "Kopeken"]));
237    m.insert("KRW", CurrencyForms::new(&["Won", "Won"], &["Jeon", "Jeon"]));
238    m.insert("MXN", CurrencyForms::new(&["Peso", "Pesos"], &["Centavo", "Centavos"]));
239    m
240}
241
242/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, which DE inherits untouched — it
243/// declares no table of its own, and `Num2Word_EN` never writes to this dict
244/// (only to `CURRENCY_FORMS`), so the source literal *is* what runs. Confirmed
245/// against the live interpreter.
246///
247/// The adjectives stay English ("US", "Indian") in a German converter, and
248/// most of these codes are not even in DE's `CURRENCY_FORMS`. Only the eight
249/// in both tables are reachable, and only on the float path — DE's int path
250/// ignores `adjective` entirely. `to_currency(12.34, "USD", adjective=True)`
251/// is "zwölf US Dollar und vierunddreißig Cent".
252fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
253    [
254        ("AUD", "Australian"),
255        ("BYN", "Belarusian"),
256        ("CAD", "Canadian"),
257        ("EEK", "Estonian"),
258        ("USD", "US"),
259        ("RUB", "Russian"),
260        ("NOK", "Norwegian"),
261        ("MXN", "Mexican"),
262        ("RON", "Romanian"),
263        ("INR", "Indian"),
264        ("HUF", "Hungarian"),
265        ("ISK", "íslenskar"),
266        ("UZS", "Uzbekistan"),
267        ("SAR", "Saudi"),
268        ("JPY", "Japanese"),
269        ("KRW", "Korean"),
270    ]
271    .into_iter()
272    .collect()
273}
274
275pub struct LangDe {
276    cards: Cards,
277    maxval: BigInt,
278    currency_forms: HashMap<&'static str, CurrencyForms>,
279    currency_adjectives: HashMap<&'static str, &'static str>,
280}
281
282impl Default for LangDe {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl LangDe {
289    pub fn new() -> Self {
290        let lows = ["Non", "Okt", "Sept", "Sext", "Quint", "Quadr", "Tr", "B", "M"];
291        let units = [
292            "", "un", "duo", "tre", "quattuor", "quin", "sex", "sept", "okto", "novem",
293        ];
294        let tens = [
295            "dez",
296            "vigint",
297            "trigint",
298            "quadragint",
299            "quinquagint",
300            "sexagint",
301            "septuagint",
302            "oktogint",
303            "nonagint",
304        ];
305        let mut high = vec!["zent".to_string()];
306        high.extend(gen_high_numwords(&units, &tens, &lows));
307
308        let mut cards = Cards::new();
309
310        // Num2Word_EUR.set_high_numwords: cap = 3 + 6*len(high) = 603;
311        // zip(high, range(cap, 3, -6)). Both sides have exactly 100 elements,
312        // so nothing is truncated. `n <= 3` emulates range exhaustion.
313        // Both GIGA_SUFFIX ("illiarde") and MEGA_SUFFIX ("illion") are
314        // non-empty for DE, so both inserts always run.
315        let cap: i64 = 3 + 6 * high.len() as i64;
316        let mut n = cap;
317        for word in high.iter() {
318            if n <= 3 {
319                break;
320            }
321            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiarde", word));
322            cards.insert(
323                BigInt::from(10u8).pow((n - 3) as u32),
324                format!("{}illion", word),
325            );
326            n -= 6;
327        }
328
329        set_mid_numwords(
330            &mut cards,
331            &[
332                (1000, "tausend"),
333                (100, "hundert"),
334                (90, "neunzig"),
335                (80, "achtzig"),
336                (70, "siebzig"),
337                (60, "sechzig"),
338                (50, "f\u{fc}nfzig"),
339                (40, "vierzig"),
340                (30, "drei\u{df}ig"),
341            ],
342        );
343        set_low_numwords(
344            &mut cards,
345            &[
346                "zwanzig",
347                "neunzehn",
348                "achtzehn",
349                "siebzehn",
350                "sechzehn",
351                "f\u{fc}nfzehn",
352                "vierzehn",
353                "dreizehn",
354                "zw\u{f6}lf",
355                "elf",
356                "zehn",
357                "neun",
358                "acht",
359                "sieben",
360                "sechs",
361                "f\u{fc}nf",
362                "vier",
363                "drei",
364                "zwei",
365                "eins",
366                "null",
367            ],
368        );
369
370        // MAXVAL = 1000 * list(self.cards.keys())[0]. Python's OrderedDict is
371        // insertion-ordered and highs go in first, so key[0] is the largest:
372        // 10^603 → MAXVAL 10^606.
373        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
374
375        LangDe {
376            cards,
377            maxval,
378            // Built once here, never per call: `to_currency` only reads these,
379            // and rebuilding them per call is what made an earlier revision of
380            // this port slower than the Python it replaces.
381            currency_forms: build_currency_forms(),
382            currency_adjectives: build_currency_adjectives(),
383        }
384    }
385
386    /// `Num2Word_Base.verify_ordinal`, with DE's `errmsg_negord`.
387    /// The float check is unreachable here (integer input only).
388    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
389        if value.is_negative() {
390            return Err(N2WError::Type(format!(
391                "Die negative Zahl {} kann nicht in eine Ordnungszahl konvertiert werden.",
392                value
393            )));
394        }
395        Ok(())
396    }
397
398    /// `Num2Word_Base.verify_ordinal` on a float/Decimal, with DE's German
399    /// error messages. Order matters and both checks are **numeric**:
400    ///
401    /// ```python
402    /// if not value == int(value):   # 1.5, Decimal("3.25") -> errmsg_floatord
403    ///     raise TypeError(...)
404    /// if not abs(value) == value:   # -21.0 -> errmsg_negord; -0.0 PASSES
405    ///     raise TypeError(...)
406    /// ```
407    ///
408    /// Returns the whole value as a `BigInt` on success (for -0.0 that is 0,
409    /// the sign gone — exactly what `int(value)` hands the integer path).
410    /// `int(inf)`/`int(nan)` raise OverflowError/ValueError inside the first
411    /// comparison; corpus-unreachable but mapped for totality.
412    ///
413    /// `display` feeds the `%s` in the message — only the exception *type* is
414    /// corpus-checked, so a close-enough rendering suffices where the exact
415    /// Python repr is not to hand.
416    fn verify_ordinal_float(&self, value: &FloatValue, display: &str) -> Result<BigInt> {
417        if let FloatValue::Float { value: f, .. } = value {
418            if f.is_nan() {
419                return Err(N2WError::Value(
420                    "cannot convert float NaN to integer".into(),
421                ));
422            }
423            if f.is_infinite() {
424                return Err(N2WError::Overflow(
425                    "cannot convert float infinity to integer".into(),
426                ));
427            }
428        }
429        let whole = value.as_whole_int().ok_or_else(|| {
430            N2WError::Type(format!(
431                "Die Gleitkommazahl {} kann nicht in eine Ordnungszahl konvertiert werden.",
432                display
433            ))
434        })?;
435        if whole.is_negative() {
436            return Err(N2WError::Type(format!(
437                "Die negative Zahl {} kann nicht in eine Ordnungszahl konvertiert werden.",
438                display
439            )));
440        }
441        Ok(whole)
442    }
443}
444
445/// `den_word[0].upper() + den_word[1:]` — Python's char-based first-letter
446/// capitalisation. `to_uppercase()` can expand to more than one char (ß), but
447/// no reachable stem starts with one; iterate chars to stay UTF-8-safe.
448fn capitalize_first(s: &str) -> String {
449    let mut chars = s.chars();
450    match chars.next() {
451        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
452        None => String::new(),
453    }
454}
455
456/// A close-enough `str(value)` for DE's TypeError messages on the plain
457/// ordinal float path, where the binding supplies no repr. Only the exception
458/// type is corpus-checked.
459fn float_display(value: &FloatValue) -> String {
460    match value {
461        FloatValue::Float { value, precision } => {
462            if value.is_finite() {
463                format!("{:.*}", *precision as usize, value)
464            } else {
465                format!("{}", value)
466            }
467        }
468        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
469    }
470}
471
472impl Lang for LangDe {
473    /// This language's own `to_currency(currency=...)` default,
474    /// read from the live Python signature. Only 44 of 156 use EUR.
475    fn default_currency(&self) -> &str {
476        "EUR"
477    }
478
479    /// This language's own `to_currency(separator=...)` default,
480    /// read from the live Python signature. Base's is ",", but only
481    /// 36 of 149 languages actually use it — most default to " " or a
482    /// conjunction, so inheriting Base's comma silently corrupts them.
483    fn default_separator(&self) -> &str {
484        " und"
485    }
486
487    fn cards(&self) -> &Cards {
488        &self.cards
489    }
490    fn maxval(&self) -> &BigInt {
491        &self.maxval
492    }
493    fn negword(&self) -> &str {
494        "minus "
495    }
496    fn pointword(&self) -> &str {
497        "Komma"
498    }
499
500    /// Port of `Num2Word_DE.merge`.
501    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
502        let (ctext0, cnum) = l;
503        let (ntext0, nnum) = r;
504        let mut ctext = ctext0.to_string();
505        let mut ntext = ntext0.to_string();
506
507        let one = BigInt::from(1);
508        let ten = BigInt::from(10);
509        let hundred = BigInt::from(100);
510        let thousand = BigInt::from(1000);
511        let mega = BigInt::from(10u8).pow(6);
512
513        if cnum == &one {
514            if nnum == &hundred || nnum == &thousand {
515                return (format!("ein{}", ntext), nnum.clone());
516            } else if nnum < &mega {
517                // Python `return next` — hands back the right-hand pair as-is.
518                return (ntext, nnum.clone());
519            }
520            ctext = "eine".to_string();
521        }
522
523        let val: BigInt;
524        if nnum > cnum {
525            if nnum >= &mega {
526                if cnum > &one {
527                    // "Milliarde" → "Milliarden"; "Million" → "Millionen".
528                    if ntext.ends_with('e') {
529                        ntext.push('n');
530                    } else {
531                        ntext.push_str("en");
532                    }
533                }
534                ctext.push(' ');
535            }
536            val = cnum * nnum;
537        } else {
538            // Python's chained comparison: nnum < 10 AND 10 < cnum AND cnum < 100.
539            if nnum < &ten && &ten < cnum && cnum < &hundred {
540                if nnum == &one {
541                    ntext = "ein".to_string();
542                }
543                // Python: ntext, ctext = ctext, ntext + "und"  (RHS uses the
544                // OLD values) — e.g. 20 + 1 → "ein" + "und" + "zwanzig".
545                let old_ctext = ctext.clone();
546                ctext = format!("{}und", ntext);
547                ntext = old_ctext;
548            } else if cnum >= &mega {
549                ctext.push(' ');
550            }
551            val = cnum + nnum;
552        }
553
554        (format!("{}{}", ctext, ntext), val)
555    }
556
557    /// Delegates to the base engine, but pre-checks the overflow bound so the
558    /// message is DE's `errmsg_toobig` rather than base.rs's English default.
559    /// The condition is identical, so `default_to_cardinal`'s own check never
560    /// fires afterwards.
561    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
562        let v = value.abs();
563        if &v >= self.maxval() {
564            return Err(N2WError::Overflow(format!(
565                "Die Zahl {} muss kleiner als {} sein.",
566                v,
567                self.maxval()
568            )));
569        }
570        default_to_cardinal(self, value)
571    }
572
573    /// Port of `Num2Word_DE.to_ordinal`.
574    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
575        self.verify_ordinal(value)?;
576        let mut outword = self.to_cardinal(value)?.to_lowercase();
577
578        // First matching suffix wins, then break — see ORDS on why order matters.
579        for (key, repl) in ORDS.iter() {
580            if outword.ends_with(*key) {
581                // All ORDS keys are ASCII, so a byte-length cut is identical
582                // to Python's char-based outword[:len(outword)-len(key)].
583                let cut = outword.len() - key.len();
584                let newword = format!("{}{}", &outword[..cut], repl);
585                outword = newword;
586                break;
587            }
588        }
589
590        let mut res = format!("{}te", outword);
591
592        // "hundertste"/"tausendste" are preferred over "einhundertste"/"eintausendste".
593        if res == "eintausendste" || res == "einhundertste" {
594            res = res.replacen("ein", "", 1);
595        }
596        res = sub_eine_illion_ste(&res);
597        res = sub_space_illion_ste(&res);
598        res = sub_eine_prefix(&res);
599        res = res.replace(' ', "");
600
601        Ok(res)
602    }
603
604    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
605        self.verify_ordinal(value)?;
606        Ok(format!("{}.", value))
607    }
608
609    /// Port of `Num2Word_DE.to_year`.
610    ///
611    /// 1000..=1999 use the "<century>hundert<rest>" reading, so 1000 renders
612    /// as "zehnhundert" (not "eintausend"); everything else is plain cardinal.
613    fn to_year(&self, value: &BigInt) -> Result<String> {
614        let thousand = BigInt::from(1000);
615        let two_thousand = BigInt::from(2000);
616        let max_year = BigInt::from(2999);
617
618        if value < &thousand || value > &max_year {
619            return self.to_cardinal(value);
620        }
621        if value < &two_thousand {
622            // value is in 1000..=1999 here, so truncating / and % agree with
623            // Python's floor // and % — no negative operands can reach this.
624            let hundred = BigInt::from(100);
625            let century = value / &hundred;
626            let remainder = value % &hundred;
627            let head = self.to_cardinal(&century)?;
628            if remainder.is_zero() {
629                Ok(format!("{}hundert", head))
630            } else {
631                Ok(format!(
632                    "{}hundert{}",
633                    head,
634                    self.to_cardinal(&remainder)?
635                ))
636            }
637        } else {
638            self.to_cardinal(value)
639        }
640    }
641
642    /// `to_ordinal(float/Decimal)` — `verify_ordinal` first (TypeError for
643    /// any fractional or negative value, in that order), then the ordinal
644    /// machinery on the whole value: `5.0` -> "fünfte", `-0.0` -> "nullte",
645    /// `1e16` -> "zehnbilliardste", `-3.0`/`0.5` -> TypeError.
646    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
647        let whole = self.verify_ordinal_float(value, &float_display(value))?;
648        self.to_ordinal(&whole)
649    }
650
651    /// `to_ordinal_num(float/Decimal)` — `verify_ordinal`, then
652    /// `str(value) + "."` with the repr verbatim: "5.00.", "1e+16.",
653    /// "-0.0." (negative zero passes the numeric abs check).
654    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
655        self.verify_ordinal_float(value, repr_str)?;
656        Ok(format!("{}.", repr_str))
657    }
658
659    /// `to_year(float/Decimal)` — `val = int(val)` **truncates** before the
660    /// century logic: `1.5` -> "eins", `1000.0` -> "zehnhundert", `3.25` ->
661    /// "drei". No validation, so negatives keep their cardinal reading.
662    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
663        let val = match value {
664            FloatValue::Float { value: f, .. } => {
665                if f.is_nan() {
666                    return Err(N2WError::Value(
667                        "cannot convert float NaN to integer".into(),
668                    ));
669                }
670                if f.is_infinite() {
671                    return Err(N2WError::Overflow(
672                        "cannot convert float infinity to integer".into(),
673                    ));
674                }
675                BigInt::from_f64(f.trunc()).expect("finite after the guards above")
676            }
677            // `with_scale(0)` truncates toward zero — exactly `int(Decimal)`.
678            FloatValue::Decimal { value, .. } => value.with_scale(0).as_bigint_and_exponent().0,
679        };
680        self.to_year(&val)
681    }
682
683    /// Port of `Num2Word_DE.to_fraction` (issue #584): capitalised fraction
684    /// noun with invariant plural.
685    ///
686    /// ```python
687    /// if denominator == 0: raise ZeroDivisionError("denominator must not be zero")
688    /// if denominator == 1 or numerator == 0: return self.to_cardinal(numerator)
689    /// is_negative = (numerator < 0) ^ (denominator < 0)
690    /// abs_n, abs_d = abs(int(numerator)), abs(int(denominator))
691    /// ```
692    ///
693    /// * `abs_d == 2` -> "halb" (singular) / "halbe" (plural, adjectival).
694    /// * 3..=12 -> the idiomatic noun table (Drittel, Viertel, …, Zwölftel).
695    /// * 13..=19 -> cardinal + "tel", first letter upper-cased.
696    /// * 20+ -> cardinal + "stel"; a stem that **equals** "einhundert" /
697    ///   "eintausend" loses its "ein" ("Hundertstel", "Tausendstel"). The
698    ///   loop also probes "einemillion"/"einemilliarde", but those can never
699    ///   match — `to_cardinal(10**6)` is "eine Million", space and capital M
700    ///   — so 1/1000000 really is "ein Eine Millionstel" (corpus-pinned).
701    /// * numerator: "ein" for |n| == 1 (noun phrase, not "eins"), else the
702    ///   cardinal; sign word from the XOR, so "1/-2" -> "minus ein halb" and
703    ///   "-3/-4" -> "drei Viertel".
704    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
705        if denominator.is_zero() {
706            return Err(N2WError::ZeroDivision(
707                "denominator must not be zero".into(),
708            ));
709        }
710        if denominator.is_one() || numerator.is_zero() {
711            return self.to_cardinal(numerator);
712        }
713        let is_negative = numerator.is_negative() ^ denominator.is_negative();
714        let abs_n = numerator.abs();
715        let abs_d = denominator.abs();
716
717        let de_frac: [(i64, &str); 10] = [
718            (3, "Drittel"),
719            (4, "Viertel"),
720            (5, "Fünftel"),
721            (6, "Sechstel"),
722            (7, "Siebtel"),
723            (8, "Achtel"),
724            (9, "Neuntel"),
725            (10, "Zehntel"),
726            (11, "Elftel"),
727            (12, "Zwölftel"),
728        ];
729
730        let den_word = if abs_d == BigInt::from(2) {
731            if abs_n.is_one() { "halb" } else { "halbe" }.to_string()
732        } else if let Some((_, word)) = de_frac
733            .iter()
734            .find(|(k, _)| abs_d == BigInt::from(*k))
735        {
736            word.to_string()
737        } else if abs_d < BigInt::from(20) {
738            let stem = self.to_cardinal(&abs_d)?;
739            capitalize_first(&format!("{}tel", stem))
740        } else {
741            let mut stem = self.to_cardinal(&abs_d)?;
742            // `if stem.startswith(prefix) and stem == prefix` — the equality
743            // makes the startswith redundant; only the exact strings match.
744            for prefix in ["einhundert", "eintausend", "einemillion", "einemilliarde"] {
745                if stem == prefix {
746                    stem = stem[3..].to_string(); // drop "ein"
747                    break;
748                }
749            }
750            capitalize_first(&format!("{}stel", stem))
751        };
752
753        let num_word = if abs_n.is_one() {
754            "ein".to_string()
755        } else {
756            self.to_cardinal(&abs_n)?
757        };
758        // `"%s " % self.negword.strip()`.
759        let sign = if is_negative { "minus " } else { "" };
760        Ok(format!("{}{} {}", sign, num_word, den_word))
761    }
762
763    // ---- currency -------------------------------------------------------
764    //
765    // DE overrides only `to_currency`, and only its integer half. `to_cheque`,
766    // `_money_verbose`, `_cents_verbose` and `_cents_terse` come from
767    // `Num2Word_Base` and `pluralize` from `Num2Word_EUR`, so the trait
768    // defaults already cover them.
769    //
770    // `currency_precision` is deliberately NOT overridden. DE never rebinds
771    // `CURRENCY_PRECISION`, and EN's rebinding assigns an *instance* attribute
772    // rather than mutating the class dict, so DE still reads Base's empty
773    // mapping: `.get(code, 100)` is 100 for every code, JPY and KRW included.
774    // The corpus proves it — `JPY 12.34` renders "zwölf Yen und vierunddreißig
775    // Sen" rather than rounding to a whole yen — which also makes
776    // `default_to_currency`'s `divisor == 1` branch unreachable for German.
777
778    fn lang_name(&self) -> &str {
779        "Num2Word_DE"
780    }
781
782    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
783        self.currency_forms.get(code)
784    }
785
786    fn currency_adjective(&self, code: &str) -> Option<&str> {
787        self.currency_adjectives.get(code).copied()
788    }
789
790    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
791    ///
792    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
793    /// would raise IndexError. Every DE entry has two forms, so this is
794    /// unreachable — mapped to `Index` rather than panicking so the exception
795    /// type survives if the table ever changes.
796    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
797        let form = if n.is_one() { 0 } else { 1 };
798        forms
799            .get(form)
800            .cloned()
801            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
802    }
803
804    /// Port of `Num2Word_DE.to_currency`.
805    ///
806    /// Only the `isinstance(val, int)` branch is DE's own; floats defer to
807    /// `super()`, which is `Num2Word_Base.to_currency` (EUR does not override
808    /// it). The two halves disagree in ways that look like oversights and are
809    /// ported verbatim:
810    ///
811    /// * the int path never consults `adjective`, so
812    ///   `to_currency(2, "USD", adjective=True)` is "zwei Dollar" while
813    ///   `12.34` gives "zwölf US Dollar und vierunddreißig Cent";
814    /// * the feminine-numeral fix-up is likewise int-only, so `1` INR is
815    ///   "eine Rupie" but `1.0` INR is "eins Rupie und null Paisa" (both
816    ///   corpus rows);
817    /// * the int path bypasses `_money_verbose` and calls `to_cardinal`
818    ///   directly. Identical for DE, which overrides neither.
819    fn to_currency(
820        &self,
821        val: &CurrencyValue,
822        currency: &str,
823        cents: bool,
824        separator: Option<&str>,
825        adjective: bool,
826    ) -> Result<String> {
827        let separator = separator.unwrap_or(self.default_separator());
828
829        if let CurrencyValue::Int(v) = val {
830            // Python catches `(KeyError, AttributeError)` around the forms
831            // lookup and hands the call to `super()`, which repeats the lookup
832            // and turns the KeyError into NotImplementedError. Delegating
833            // reproduces that rather than raising here, so the message and its
834            // wording keep coming from one place.
835            let forms = match self.currency_forms.get(currency) {
836                Some(f) => f,
837                None => {
838                    return default_to_currency(self, val, currency, cents, separator, adjective)
839                }
840            };
841
842            // `minus_str = self.negword if val < 0 else ""` — DE takes the raw
843            // negword, trailing space intact, where Base uses
844            // `negword.strip() + " "`. Same bytes for "minus ", kept literal.
845            let minus_str = if v.is_negative() { self.negword() } else { "" };
846            let abs_val = v.abs();
847            let mut money_str = self.to_cardinal(&abs_val)?;
848
849            // Issue #69. `abs_val` is already non-negative, so `%` needs no
850            // floor correction.
851            if FEMININE_CURRENCIES.contains(&currency)
852                && (&abs_val % BigInt::from(100)).is_one()
853            {
854                if money_str.ends_with("eins") {
855                    // "eins" is ASCII, so dropping 4 bytes == Python's [:-4].
856                    let cut = money_str.len() - 4;
857                    money_str.truncate(cut);
858                    money_str.push_str("eine");
859                } else if money_str.ends_with("ein") {
860                    money_str.push('e');
861                }
862            }
863
864            // `cr1[0]` when abs_val == 1, else `cr1[1] if len(cr1) > 1 else cr1[0]`.
865            let unit = &forms.unit;
866            let currency_str = if abs_val.is_one() {
867                unit.first()
868            } else {
869                unit.get(1).or_else(|| unit.first())
870            }
871            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
872
873            // Python's trailing `.strip()`.
874            return Ok(format!("{}{} {}", minus_str, money_str, currency_str)
875                .trim()
876                .to_string());
877        }
878
879        default_to_currency(self, val, currency, cents, separator, adjective)
880    }
881}