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(¢ury)?;
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(¤cy)
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}