num2words2_core/lang_nl.rs
1//! Port of `lang_NL.py` (via `lang_EUR` → `Num2Word_Base`).
2//!
3//! Engine-style: NL supplies cards + `merge` and lets the base `to_cardinal`
4//! drive `splitnum`/`clean`.
5//!
6//! NL keeps EUR's long-scale `set_high_numwords` (step -6, pairing an
7//! `iljard` at 10^n with an `iljoen` at 10^(n-3)) and EUR's
8//! `gen_high_numwords`, but replaces the Latin stems with Dutch-flavoured
9//! ones ("dez"/"okto"/"tre"/"quin"/"sept" instead of
10//! "dec"/"octo"/"tres"/"quint"/"septen") and swaps the "cent" head for
11//! "zend". Only five of EUR's eighteen elision rules can still fire against
12//! the NL stems (novemn, novemo, sexn, sexs, unno) — the rest are dead code
13//! here, but the list is ported whole because Python applies it whole.
14
15use crate::base::{
16 default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
17};
18use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
19use crate::floatpath::{default_to_cardinal_float, float2tuple, FloatValue};
20use crate::strnum::python_decimal_str;
21use bigdecimal::BigDecimal;
22use num_bigint::BigInt;
23use num_integer::Integer;
24use num_traits::{FromPrimitive, One, Signed, Zero};
25use std::collections::HashMap;
26
27/// Port of `Num2Word_EUR.gen_high_numwords`.
28///
29/// Kept private to this module: `lang_en.rs` exports an identical function,
30/// but the porting contract is one self-contained file per language.
31fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
32 let mut out: Vec<String> = Vec::new();
33 // Python: [u + t for t in tens for u in units] — tens is the outer loop.
34 for t in tens {
35 for u in units {
36 out.push(format!("{}{}", u, t));
37 }
38 }
39 out.reverse();
40
41 const REPLACEMENTS: &[(&str, &str)] = &[
42 ("novemn", "noven"),
43 ("novemo", "novo"),
44 ("octoo", "octo"),
45 ("quintd", "quind"),
46 ("quintn", "quin"),
47 ("quintq", "quinq"),
48 ("quints", "quins"),
49 ("quintt", "quint"),
50 ("quintv", "quinv"),
51 ("septenn", "septen"),
52 ("septent", "sept"),
53 ("sexn", "sen"),
54 ("sexs", "ses"),
55 ("tresd", "tred"),
56 ("tresn", "tren"),
57 ("tress", "tres"),
58 ("tresv", "trev"),
59 ("unno", "uno"),
60 ];
61 for &(k, v) in REPLACEMENTS {
62 out = out.iter().map(|o| o.replace(k, v)).collect();
63 }
64 out.extend(lows.iter().map(|s| s.to_string()));
65 out
66}
67
68/// `self.ords`, in Python dict insertion order.
69///
70/// `to_ordinal` scans this with `break` on the first suffix hit, so the order
71/// is load-bearing: "erd" must precede "rd" ("honderd" → "honderdst", not
72/// "hondeerdst"), and the spelled-out digits must precede the generic "ig" /
73/// "end" / "joen" suffixes.
74const ORDS: &[(&str, &str)] = &[
75 ("nul", "nuld"),
76 ("één", "eerst"),
77 ("twee", "tweed"),
78 ("drie", "derd"),
79 ("vier", "vierd"),
80 ("vijf", "vijfd"),
81 ("zes", "zesd"),
82 ("zeven", "zevend"),
83 ("acht", "achtst"),
84 ("negen", "negend"),
85 ("tien", "tiend"),
86 ("elf", "elfd"),
87 ("twaalf", "twaalfd"),
88 ("ig", "igst"),
89 ("erd", "erdst"),
90 ("end", "endst"),
91 ("joen", "joenst"),
92 ("rd", "rdst"),
93];
94
95/// `Num2Word_NL.CURRENCY_FORMS`.
96///
97/// Unlike the 16 classes that read `Num2Word_EUR`'s dict after
98/// `Num2Word_EN.__init__` has mutated it in place, NL declares
99/// `CURRENCY_FORMS` in its **own** class body. That shadows EUR's attribute
100/// outright, so NL never sees either EUR's 22 entries or the ~24 codes EN
101/// grafts on — the live interpreter confirms `Num2Word_NL.CURRENCY_FORMS is
102/// Num2Word_EUR.CURRENCY_FORMS` is False and holds exactly these four keys.
103///
104/// Hence JPY, KWD, BHD, INR and CHF all raise NotImplementedError for NL,
105/// which the corpus asserts row for row. Adding them "for completeness" would
106/// break 60 cases.
107///
108/// The subunit arity is load-bearing. `Num2Word_NL.pluralize` returns
109/// `forms[0]` unconditionally, so the ordinary cents path never reaches the
110/// second form — but `base.to_currency`'s fractional-cents branch takes
111/// `cr2[1]` *directly*, bypassing `pluralize` entirely. That splits GBP and
112/// CNY across the two paths:
113///
114/// ```text
115/// to_currency(12.34, "GBP") -> "twaalf pond en vierendertig penny"
116/// to_currency(1.011, "GBP") -> "één pond en één komma één pence"
117/// to_currency(1.011, "CNY") -> "één yuan en één komma één fen"
118/// ```
119///
120/// So dropping "pence"/"fen" as apparently-dead plurals would silently change
121/// output on any value with fractional subunits. All three verified against
122/// the live interpreter.
123fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
124 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
125 m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
126 m.insert("GBP", CurrencyForms::new(&["pond", "pond"], &["penny", "pence"]));
127 m.insert(
128 "USD",
129 CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
130 );
131 m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["jiao", "fen"]));
132 m
133}
134
135/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — NL declares no
136/// adjectives of its own, and EN mutates only `CURRENCY_FORMS`, never this.
137///
138/// Nearly all of it is unreachable: `base.to_currency` looks up
139/// `CURRENCY_FORMS` *before* consulting the adjectives, so any code outside
140/// NL's four raises NotImplementedError first. Of those four only USD has an
141/// entry, making `to_currency(12.34, "USD", adjective=True)` -> "twaalf US
142/// dollar en vierendertig cent" the single reachable case (verified against
143/// the live interpreter). The full table is kept because it is the inherited
144/// data, exactly as `lang_en.rs` keeps it.
145fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
146 [
147 ("AUD", "Australian"),
148 ("BYN", "Belarusian"),
149 ("CAD", "Canadian"),
150 ("EEK", "Estonian"),
151 ("USD", "US"),
152 ("RUB", "Russian"),
153 ("NOK", "Norwegian"),
154 ("MXN", "Mexican"),
155 ("RON", "Romanian"),
156 ("INR", "Indian"),
157 ("HUF", "Hungarian"),
158 ("ISK", "íslenskar"),
159 ("UZS", "Uzbekistan"),
160 ("SAR", "Saudi"),
161 ("JPY", "Japanese"),
162 ("KRW", "Korean"),
163 ]
164 .into_iter()
165 .collect()
166}
167
168/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the fractional
169/// digit count of the shortest round-trip repr. Mirrors the private helper in
170/// `floatpath`.
171fn float_repr_precision(f: f64) -> u32 {
172 let s = format!("{}", f);
173 match s.split_once('.') {
174 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
175 _ => 0,
176 }
177}
178
179/// Best-effort `str(number)` for the TypeError messages (the corpus compares
180/// exception types only, so the float arm's repr need not be byte-exact).
181fn py_num_str(value: &FloatValue) -> String {
182 match value {
183 FloatValue::Float { value, .. } => format!("{}", value),
184 FloatValue::Decimal { value, .. } => python_decimal_str(value),
185 }
186}
187
188pub struct LangNl {
189 cards: Cards,
190 maxval: BigInt,
191 currency_forms: HashMap<&'static str, CurrencyForms>,
192 currency_adjectives: HashMap<&'static str, &'static str>,
193}
194
195impl Default for LangNl {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201impl LangNl {
202 pub fn new() -> Self {
203 let lows = ["non", "okt", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
204 let units = [
205 "", "un", "duo", "tre", "quattuor", "quin", "sex", "sept", "okto", "novem",
206 ];
207 let tens = [
208 "dez",
209 "vigint",
210 "trigint",
211 "quadragint",
212 "quinquagint",
213 "sexagint",
214 "septuagint",
215 "oktogint",
216 "nonagint",
217 ];
218 // NL.setup() calls super().setup() (which sets high_numwords with the
219 // "cent" head) and then overwrites high_numwords outright, so only
220 // this assignment survives.
221 let mut high = vec!["zend".to_string()];
222 high.extend(gen_high_numwords(&units, &tens, &lows));
223
224 let mut cards = Cards::new();
225
226 // EUR.set_high_numwords: cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
227 // GIGA_SUFFIX = "iljard", MEGA_SUFFIX = "iljoen" — both non-empty for
228 // NL, so every step inserts a pair. len(high) is 100 and the range
229 // yields exactly 100 values (603 down to 9), so zip truncates neither.
230 let cap = 3 + 6 * high.len() as i64;
231 let mut n = cap;
232 for word in high.iter() {
233 if n <= 3 {
234 break;
235 }
236 cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}iljard", word));
237 cards.insert(
238 BigInt::from(10u8).pow((n - 3) as u32),
239 format!("{}iljoen", word),
240 );
241 n -= 6;
242 }
243
244 set_mid_numwords(
245 &mut cards,
246 &[
247 (1000, "duizend"),
248 (100, "honderd"),
249 (90, "negentig"),
250 (80, "tachtig"),
251 (70, "zeventig"),
252 (60, "zestig"),
253 (50, "vijftig"),
254 (40, "veertig"),
255 (30, "dertig"),
256 ],
257 );
258 set_low_numwords(
259 &mut cards,
260 &[
261 "twintig",
262 "negentien",
263 "achttien",
264 "zeventien",
265 "zestien",
266 "vijftien",
267 "veertien",
268 "dertien",
269 "twaalf",
270 "elf",
271 "tien",
272 "negen",
273 "acht",
274 "zeven",
275 "zes",
276 "vijf",
277 "vier",
278 "drie",
279 "twee",
280 "één",
281 "nul",
282 ],
283 );
284
285 // MAXVAL = 1000 * first card key. Python's OrderedDict is filled
286 // high-first, so key[0] is 10^603 — the same as the max key.
287 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
288
289 LangNl {
290 cards,
291 maxval,
292 // Built once here, never per call: `to_currency` only reads these,
293 // and rebuilding them per call is what made an earlier revision of
294 // this port slower than the Python it replaces.
295 currency_forms: build_currency_forms(),
296 currency_adjectives: build_currency_adjectives(),
297 }
298 }
299
300 /// `Num2Word_Base.verify_ordinal` for a float/Decimal: the float check
301 /// (`errmsg_floatord`) fires first, then the negative one — both with
302 /// NL's Dutch wording. `-0.0` passes both (`abs(-0.0) == -0.0`).
303 /// Returns the whole value on success.
304 fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
305 let whole = match value.as_whole_int() {
306 Some(i) => i,
307 None => {
308 return Err(N2WError::Type(format!(
309 "Het zwevende puntnummer {} kan niet omgezet worden naar een ordernummer.",
310 py_num_str(value)
311 )))
312 }
313 };
314 if whole.is_negative() {
315 return Err(N2WError::Type(format!(
316 "Het negatieve getal {} kan niet omgezet worden naar een ordernummer.",
317 py_num_str(value)
318 )));
319 }
320 Ok(whole)
321 }
322
323 /// Python's `self.to_cardinal(x)` for an f64 — Base semantics: whole →
324 /// integer path, fractional → the base float grammar ("komma" + digits).
325 fn cardinal_num_f64(&self, x: f64) -> Result<String> {
326 if x.fract() == 0.0 {
327 if let Some(i) = BigInt::from_f64(x) {
328 return self.to_cardinal(&i);
329 }
330 }
331 default_to_cardinal_float(
332 self,
333 &FloatValue::Float {
334 value: x,
335 precision: float_repr_precision(x),
336 },
337 None,
338 )
339 }
340
341 /// Python's `self.to_cardinal(x)` for a Decimal.
342 fn cardinal_num_dec(&self, x: &BigDecimal) -> Result<String> {
343 if x.is_integer() {
344 return self.to_cardinal(&x.with_scale(0).as_bigint_and_exponent().0);
345 }
346 let precision = x.as_bigint_and_exponent().1.max(0) as u32;
347 default_to_cardinal_float(
348 self,
349 &FloatValue::Decimal {
350 value: x.clone(),
351 precision,
352 },
353 None,
354 )
355 }
356
357 /// `Num2Word_Base.verify_ordinal` with NL's `errmsg_negord`.
358 /// The float branch is out of scope (integer input only).
359 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
360 if value.is_negative() {
361 return Err(N2WError::Type(format!(
362 "Het negatieve getal {} kan niet omgezet worden naar een ordernummer.",
363 value
364 )));
365 }
366 Ok(())
367 }
368
369 /// `Num2Word_Base.to_splitnum` specialised to NL's only call site:
370 /// `hightxt="honderd"`, `lowtxt=""`, `jointxt=""`, `divisor=100`,
371 /// `longval=True`, `cents=True`.
372 ///
373 /// `inflect(high, "honderd")` returns "honderd" for every `high`
374 /// ("honderd".split("/") has one element, so both branches agree), and
375 /// `title` is the identity because NL leaves `is_title` False.
376 fn to_splitnum(&self, val: &BigInt) -> Result<String> {
377 // Python's divmod is floor-based; this matters for negative years,
378 // e.g. divmod(-44, 100) == (-1, 56).
379 let (high, low) = val.div_mod_floor(&BigInt::from(100));
380 let mut out: Vec<String> = Vec::new();
381
382 if !high.is_zero() {
383 out.push(self.to_cardinal(&high)?);
384 if !low.is_zero() {
385 // longval is True and hightxt is non-empty; jointxt is ""
386 // so the join segment is skipped.
387 out.push("honderd".to_string());
388 } else {
389 out.push("honderd".to_string());
390 }
391 }
392
393 if !low.is_zero() {
394 // cents=True, so the low part is spelled out rather than "%02d".
395 out.push(self.to_cardinal(&low)?);
396 }
397
398 Ok(out.join(" "))
399 }
400}
401
402impl Lang for LangNl {
403 /// This language's own `to_currency(currency=...)` default,
404 /// read from the live Python signature. Only 44 of 156 use EUR.
405 fn default_currency(&self) -> &str {
406 "EUR"
407 }
408
409 /// This language's own `to_currency(separator=...)` default,
410 /// read from the live Python signature. Base's is ",", but only
411 /// 36 of 149 languages actually use it — most default to " " or a
412 /// conjunction, so inheriting Base's comma silently corrupts them.
413 fn default_separator(&self) -> &str {
414 " en"
415 }
416
417 fn cards(&self) -> &Cards {
418 &self.cards
419 }
420 fn maxval(&self) -> &BigInt {
421 &self.maxval
422 }
423 fn negword(&self) -> &str {
424 "min "
425 }
426 fn pointword(&self) -> &str {
427 "komma"
428 }
429
430 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
431 // NL overrides errmsg_toobig, so the overflow check is done here with
432 // NL's wording rather than leaving it to `default_to_cardinal`'s
433 // base-class message. The predicate is identical, so the base check
434 // can never fire after this one passes.
435 let v = value.abs();
436 if &v >= self.maxval() {
437 return Err(N2WError::Overflow(format!(
438 "Het getal {} moet minder zijn dan {}.",
439 v,
440 self.maxval()
441 )));
442 }
443 default_to_cardinal(self, value)
444 }
445
446 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
447 let (ctext0, cnum) = l;
448 let (ntext0, nnum) = r;
449 let mut ctext = ctext0.to_string();
450 let mut ntext = ntext0.to_string();
451
452 let ten = BigInt::from(10);
453 let hundred = BigInt::from(100);
454 let thousand = BigInt::from(1000);
455 let million = BigInt::from(1_000_000);
456
457 if cnum.is_one() {
458 if nnum < &million {
459 return (ntext, nnum.clone());
460 }
461 // "één miljoen" would be wrong; the multiplier drops its accents.
462 ctext = "een".to_string();
463 }
464
465 let val;
466 if nnum > cnum {
467 if nnum >= &million {
468 ctext.push(' ');
469 }
470 val = cnum * nnum;
471 } else {
472 // Python's chained `nnum < 10 < cnum < 100`.
473 if nnum < &ten && ten < *cnum && cnum < &hundred {
474 if nnum.is_one() {
475 ntext = "een".to_string();
476 }
477 if ntext.ends_with('e') {
478 ntext.push_str("ën");
479 } else {
480 ntext.push_str("en");
481 }
482 // Python: `ntext, ctext = ctext, ntext` — the unit word moves
483 // in front of the ten ("eenentwintig").
484 std::mem::swap(&mut ntext, &mut ctext);
485 } else if cnum >= &thousand {
486 ctext.push(' ');
487 }
488 val = cnum + nnum;
489 }
490
491 (format!("{}{}", ctext, ntext), val)
492 }
493
494 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
495 self.verify_ordinal(value)?;
496 let mut outword = self.to_cardinal(value)?;
497 for &(key, rep) in ORDS {
498 // Python slices `outword[:len(outword) - len(key)]` by character
499 // count. Because the suffix matched, that split point is exactly
500 // where `strip_suffix` cuts — no byte/char discrepancy survives
501 // (this matters for "één": 3 chars, 5 bytes).
502 if let Some(head) = outword.strip_suffix(key) {
503 outword = format!("{}{}", head, rep);
504 break;
505 }
506 }
507 Ok(outword + "e")
508 }
509
510 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
511 self.verify_ordinal(value)?;
512 Ok(format!("{}e", value))
513 }
514
515 fn to_year(&self, value: &BigInt) -> Result<String> {
516 let hundred = BigInt::from(100);
517
518 // Years shaped "[N]honderd[M]" (1100..9999), written without spaces —
519 // except the 2000..2099 window, which keeps the "tweeduizend X" form.
520 if *value >= BigInt::from(1100)
521 && *value <= BigInt::from(9999)
522 && !(*value >= BigInt::from(2000) && *value <= BigInt::from(2099))
523 {
524 let hundreds = value / &hundred;
525 let remainder = value % &hundred;
526 let mut result = self.to_cardinal(&hundreds)?.replace(' ', "") + "honderd";
527 if !remainder.is_zero() {
528 result.push_str(&self.to_cardinal(&remainder)?.replace(' ', ""));
529 }
530 return Ok(result);
531 }
532
533 // `if not (val // 100) % 10` — Python floor-division and Python
534 // modulo (result takes the divisor's sign), hence div_floor/mod_floor.
535 // For val = -44 this is (-1) % 10 == 9, which is truthy, so negative
536 // years fall through to the to_splitnum branch.
537 if (value.div_floor(&hundred))
538 .mod_floor(&BigInt::from(10))
539 .is_zero()
540 {
541 return self.to_cardinal(value);
542 }
543
544 self.to_splitnum(value)
545 }
546
547 /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for any
548 /// fractional or negative value; a whole non-negative one flows through
549 /// `to_cardinal` + the `ords` suffix table — identical to the integer
550 /// ordinal ("5.0" → "vijfde", `1e+16` → "tien biljardste").
551 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
552 let whole = self.verify_ordinal_num(value)?;
553 self.to_ordinal(&whole)
554 }
555
556 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal` then
557 /// `str(value) + "e"` — "5.0e", "1E+2e", "-0.0e".
558 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
559 self.verify_ordinal_num(value)?;
560 Ok(format!("{}e", repr_str))
561 }
562
563 /// `to_year(float/Decimal)` — `Num2Word_NL.to_year`'s arithmetic run on
564 /// the raw float/Decimal, quirks and all:
565 ///
566 /// * 1100..=9999 (minus the 2000..2099 window): "[N]honderd[M]" from
567 /// `val // 100` and `val % 100` — `1234.0` → "twaalfhonderdvierendertig".
568 /// * hundreds digit zero (`not (val // 100) % 10`): plain cardinal.
569 /// * otherwise `to_splitnum(val, hightxt="honderd")`, whose **float** arm
570 /// goes through `float2tuple` — so `100.0` splits into `(100, 0)` and
571 /// reads "honderd honderd", and `-21.0` into `(-21, 0)` →
572 /// "min eenentwintig honderd" — while the **Decimal** arm divmods by
573 /// 100: `Decimal("100")` → "één honderd",
574 /// `Decimal("12345.000")` → "honderddrieëntwintig honderd vijfenveertig".
575 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
576 match value {
577 FloatValue::Float { value: v, .. } => {
578 let v = *v;
579 if (1100.0..=9999.0).contains(&v) && !(2000.0..=2099.0).contains(&v) {
580 let hundreds = (v / 100.0).floor();
581 let remainder = v.rem_euclid(100.0);
582 let mut result =
583 format!("{}honderd", self.cardinal_num_f64(hundreds)?.replace(' ', ""));
584 if remainder != 0.0 {
585 result.push_str(&self.cardinal_num_f64(remainder)?.replace(' ', ""));
586 }
587 return Ok(result);
588 }
589 if (v / 100.0).floor().rem_euclid(10.0) == 0.0 {
590 return self.cardinal_float_entry(value, None);
591 }
592 // to_splitnum, float arm: high/low from base.float2tuple —
593 // int() truncation for `pre`, repr digits for `post`.
594 let (high, low) = float2tuple(value);
595 let mut out: Vec<String> = Vec::new();
596 if !high.is_zero() {
597 out.push(self.to_cardinal(&high)?);
598 out.push("honderd".to_string());
599 }
600 if !low.is_zero() {
601 out.push(self.to_cardinal(&low)?);
602 }
603 Ok(out.join(" "))
604 }
605 FloatValue::Decimal { value: d, .. } => {
606 let hundred = BigDecimal::from(100);
607 let lo = BigDecimal::from(1100);
608 let hi = BigDecimal::from(9999);
609 let w_lo = BigDecimal::from(2000);
610 let w_hi = BigDecimal::from(2099);
611 if d >= &lo && d <= &hi && !(d >= &w_lo && d <= &w_hi) {
612 // Decimal // and % truncate toward zero; positive here.
613 let hundreds = (d / &hundred).with_scale_round(
614 0,
615 bigdecimal::RoundingMode::Down,
616 );
617 let remainder = d - &hundreds * &hundred;
618 let mut result = format!(
619 "{}honderd",
620 self.cardinal_num_dec(&hundreds)?.replace(' ', "")
621 );
622 if !remainder.is_zero() {
623 result.push_str(&self.cardinal_num_dec(&remainder)?.replace(' ', ""));
624 }
625 return Ok(result);
626 }
627 let q = (d / &hundred).with_scale_round(0, bigdecimal::RoundingMode::Down);
628 let q_int = q.as_bigint_and_exponent().0;
629 // Python `%` on the int-valued Decimal quotient: sign of the
630 // dividend (truncated), matching Rust's BigInt `%`.
631 if (&q_int % BigInt::from(10)).is_zero() {
632 return self.cardinal_float_entry(value, None);
633 }
634 // to_splitnum, Decimal arm: `high, low = divmod(val, 100)`.
635 let high = q;
636 let low = d - &high * &hundred;
637 let mut out: Vec<String> = Vec::new();
638 if !high.is_zero() {
639 out.push(self.cardinal_num_dec(&high)?);
640 out.push("honderd".to_string());
641 }
642 if !low.is_zero() {
643 out.push(self.cardinal_num_dec(&low)?);
644 }
645 Ok(out.join(" "))
646 }
647 }
648 }
649
650 // ---- currency -------------------------------------------------------
651 //
652 // NL overrides only `to_currency` and `pluralize`; `to_cheque`,
653 // `_money_verbose`, `_cents_verbose` and `_cents_terse` all come from
654 // `Num2Word_Base` unchanged, and `CURRENCY_PRECISION` stays base's empty
655 // dict (EN *rebinds* it on the instance rather than mutating the class
656 // attribute, so its 3-decimal entries never leak here). Every code is
657 // therefore divisor 100 and the trait defaults already match — hence no
658 // `currency_precision`, `money_verbose` or `to_cheque` override below.
659
660 fn lang_name(&self) -> &str {
661 "Num2Word_NL"
662 }
663
664 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
665 self.currency_forms.get(code)
666 }
667
668 fn currency_adjective(&self, code: &str) -> Option<&str> {
669 self.currency_adjectives.get(code).copied()
670 }
671
672 /// `Num2Word_NL.pluralize`: `return forms[0]` — the plural form is *never*
673 /// selected, overriding `Num2Word_EUR`'s `forms[0 if n == 1 else 1]`.
674 ///
675 /// The docstring cites onzetaal.nl on euro/euro's and admits uncertainty
676 /// about the scope of the rule ("not sure if it's applied only to euro").
677 /// Its practical effect is that GBP reads "vierendertig penny" rather than
678 /// "...pence", and CNY "vierendertig jiao" rather than "...fen". The
679 /// corpus pins both of those singular readings, so this is behaviour to
680 /// preserve, not a wrinkle to fix. (The plurals are still reachable via
681 /// the fractional-cents branch, which skips this method — see
682 /// `build_currency_forms`.)
683 ///
684 /// Python indexes the tuple directly, so an empty entry would raise
685 /// IndexError. Unreachable for NL's table — mapped rather than panicking
686 /// so the exception type survives if the data ever changes.
687 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
688 forms
689 .first()
690 .cloned()
691 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
692 }
693
694 /// `Num2Word_NL.to_currency`.
695 ///
696 /// NL intercepts the `isinstance(val, int)` case with its own body and
697 /// delegates everything else to `Num2Word_Base.to_currency`. The int arm
698 /// diverges from base's in three ways, all of them bugs kept for parity:
699 ///
700 /// 1. `minus_str = self.negword` keeps negword's **trailing space** where
701 /// base does `"%s " % self.negword.strip()`. Feeding that to
702 /// `"%s %s %s"` yields a doubled interior space: `to_currency(-1)` is
703 /// `"min één euro"`, not `"min één euro"`. `.strip()` only trims the
704 /// ends, so the double space survives — as must `trim()` here. Verified
705 /// against the live interpreter; no negative *int* row exists in the
706 /// corpus, so this rests on that check alone.
707 /// 2. `adjective` is accepted and then **ignored** — the int arm never
708 /// calls `prefix_currency`. So `to_currency(1, "USD", adjective=True)`
709 /// is "één dollar" while the float `1.0` gives "één US dollar en nul
710 /// cent".
711 /// 3. The arm inlines a plural rule (`cr1[0] if abs_val == 1 else cr1[1]`)
712 /// that contradicts NL's own `pluralize` (always `forms[0]`). Harmless
713 /// in practice: every NL unit tuple has cr1[0] == cr1[1].
714 ///
715 /// The `except (KeyError, AttributeError)` fallback re-enters base for an
716 /// unknown code, which raises NotImplementedError with NL's class name.
717 /// `AttributeError` is unreachable (`CURRENCY_FORMS` always exists), so
718 /// only the missing-key path is modelled.
719 fn to_currency(
720 &self,
721 val: &CurrencyValue,
722 currency: &str,
723 cents: bool,
724 separator: Option<&str>,
725 adjective: bool,
726 ) -> Result<String> {
727 // Trait hands us None when the caller omitted separator=; resolve it
728 // to NL's own default (" en") before the ported body runs.
729 let separator = separator.unwrap_or(self.default_separator());
730
731 if let CurrencyValue::Int(v) = val {
732 let forms = match self.currency_forms.get(currency) {
733 Some(f) => f,
734 // KeyError -> super().to_currency(...), which raises
735 // NotImplementedError from its own CURRENCY_FORMS lookup.
736 None => {
737 return default_to_currency(self, val, currency, cents, separator, adjective)
738 }
739 };
740 let cr1 = &forms.unit;
741
742 let minus_str = if v.is_negative() { self.negword() } else { "" };
743 let abs_val = v.abs();
744 let money_str = self.to_cardinal(&abs_val)?;
745
746 let currency_str = if abs_val.is_one() {
747 &cr1[0]
748 } else if cr1.len() > 1 {
749 &cr1[1]
750 } else {
751 &cr1[0]
752 };
753
754 // Python: ("%s %s %s" % (...)).strip() — see bug 1 above; trim()
755 // must not collapse the interior double space on negatives.
756 return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
757 .trim()
758 .to_string());
759 }
760
761 // Floats/Decimals: `super(Num2Word_NL, self).to_currency(...)`.
762 default_to_currency(self, val, currency, cents, separator, adjective)
763 }
764}