num2words2_core/lang_sv.rs
1//! Port of `lang_SV.py` (`Num2Word_SV`), via `lang_EUR` → `Num2Word_Base`.
2//!
3//! Engine-style language: SV supplies `cards` + `merge` and lets the base
4//! `to_cardinal` drive `splitnum`/`clean`. It overrides `to_ordinal`,
5//! `to_ordinal_num` and `to_year` outright.
6//!
7//! SV redefines `set_high_numwords` with a body byte-identical to
8//! `Num2Word_EUR`'s; the only real change is the suffix pair
9//! `GIGA_SUFFIX = "iljarder"` / `MEGA_SUFFIX = "iljoner"`, which yields the
10//! Swedish long scale: 10^6 "miljoner", 10^9 "miljarder", 10^12 "biljoner",
11//! 10^15 "biljarder", … up to 10^600 "centiljoner" / 10^603 "centiljarder".
12//! MAXVAL is therefore 10^606 — far past u64/i128, so everything stays BigInt.
13//!
14//! # Faithfully reproduced Python bugs
15//!
16//! Per the porting contract these are preserved verbatim, not fixed:
17//!
18//! 1. **`ords["tjugo"]` is unreachable.** `to_ordinal` looks the last word up
19//! by its last *4* chars, then its last *3*. "tjugo" is 5 chars, so neither
20//! probe can ever hit it and the entry is dead code. Every multiple of 20
21//! falls through to the generic "de" suffix: `to_ordinal(20)` → "tjugode",
22//! not "tjugonde"; `to_ordinal(120)` → "etthundratjugode". Confirmed
23//! against the frozen corpus.
24//!
25//! 2. **Plural mega/giga words get ordinalised as-is.** The cards store the
26//! *plural* "miljoner"/"miljarder", and `merge` only singularises them on
27//! the `lnum == 1` path ("en miljon"). So `to_ordinal(10**7)` →
28//! "tio miljonerde" and `to_ordinal(10**10)` → "tio miljarderde", while
29//! `to_ordinal(10**6)` → "en miljonde". Corpus agrees.
30//!
31//! 3. **`merge` returns `lnum + rnum` where a product is meant.** The
32//! `rnum >= 1000000` branches precede the `rnum > lnum` multiply branch, so
33//! merging ("ett",1) with ("miljoner",10^6) yields num 1_000_001, not
34//! 10^6. The bogus totals never reach the output text (only the string is
35//! used at the top level, and the subsequent comparisons happen to land in
36//! the same branches), so cardinals are unaffected — but the numeric field
37//! of the tree is genuinely wrong and is reproduced as such.
38//!
39//! 4. **`to_ordinal_num` skips `verify_ordinal`.** Negatives are accepted and
40//! formatted off the decimal string: `to_ordinal_num(-1)` → "-1:a",
41//! `to_ordinal_num(-42)` → "-42:a". Its last two branches are also
42//! identical (both append ":e"), so the final `else` is dead — kept here
43//! for structural fidelity.
44//!
45//! 5. **SV's `to_currency` double-spaces the minus on the int path.** It
46//! builds `"%s %s %s" % (minus_str, money, unit)` with `minus_str =
47//! self.negword` — the *un-stripped* "minus " — where every other path
48//! uses `self.negword.strip()`. `.strip()` only removes the leading and
49//! trailing space, so the interior double space survives:
50//! `to_currency(-10, "EUR")` → "minus tio euros". The float path
51//! delegates to `Num2Word_Base` and correctly yields a single space
52//! ("minus tolv euros, trettiofyra cents"), so the same call spaces
53//! differently depending on whether the caller passed `-10` or `-10.0`.
54//!
55//! 6. **SV's int path silently ignores `adjective=`.** `Num2Word_Base`'s int
56//! path applies `prefix_currency`, but SV's override never consults
57//! `CURRENCY_ADJECTIVES`. So `to_currency(2, "USD", adjective=True)` is
58//! "två dollars" while `to_currency(2.0, "USD", adjective=True)` is
59//! "två US dollars, noll cents". `cents=` and `separator=` are likewise
60//! dead on the int path. `CURRENCY_ADJECTIVES` is still wired up below
61//! because the float path (via the base) does honour it.
62//!
63//! # Where SV's currency table actually comes from
64//!
65//! `Num2Word_SV` defines no `CURRENCY_FORMS` of its own and inherits
66//! `Num2Word_EUR`'s. That dict is a *class* attribute, and
67//! `Num2Word_EN.__init__` does `self.CURRENCY_FORMS["CHF"] = ...` — a
68//! subscript assignment, which mutates the inherited dict **in place** rather
69//! than shadowing it with an instance copy. `num2words2/__init__.py`
70//! instantiates every converter at import time, so importing the package
71//! rewrites `Num2Word_EUR.CURRENCY_FORMS` for all of its subclasses, SV
72//! included.
73//!
74//! That is why the table below is not what `lang_EUR.py` reads:
75//!
76//! * EUR is `("euro", "euros")`, not lang_EUR's `("euro", "euro")`.
77//! * GBP is `("pound", "pounds")`, not `("pound sterling", "pounds sterling")`.
78//! * SAR is `("riyal", "riyals")`, not `("saudi riyal", "saudi riyals")`.
79//! * 17 codes absent from lang_EUR (CHF, CNY, KWD, BHD, NGN, …) are present.
80//!
81//! The 39 entries here are transcribed from the live post-import state, which
82//! is what the frozen corpus was generated against. `Num2Word_EN` is the only
83//! class that pollutes the EUR dict, and it is instantiated unconditionally,
84//! so the state is deterministic — but see the port notes: this is global
85//! mutable state and a Python-side reordering would silently invalidate it.
86//!
87//! `CURRENCY_PRECISION` is *not* affected: EN sets it with `self.X = {...}`
88//! (a plain assignment), which binds an instance attribute and leaves
89//! `Num2Word_Base.CURRENCY_PRECISION` an empty dict. SV therefore resolves
90//! every code to the default divisor of 100 — including the ones that are
91//! 3-decimal (KWD/BHD/OMR/…) or 0-decimal (JPY/KRW) elsewhere. Hence
92//! `to_currency(12.34, "KWD")` → "tolv dinars, trettiofyra fils" (cents, not
93//! mils) and `to_currency(12.34, "JPY")` → "tolv yen, trettiofyra sen"
94//! rather than rounding to a whole yen. The corpus confirms both. So
95//! `currency_precision` is deliberately left at the trait default.
96
97use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
98use crate::currency::{CurrencyForms, CurrencyValue};
99use crate::floatpath::{default_to_cardinal_float, FloatValue};
100use crate::strnum::python_decimal_str;
101use bigdecimal::BigDecimal;
102use num_bigint::BigInt;
103use num_traits::{FromPrimitive, One, Signed, Zero};
104use std::collections::HashMap;
105
106/// Port of `Num2Word_EUR.gen_high_numwords`.
107///
108/// Reimplemented locally rather than imported from `lang_en` so this file
109/// stays self-contained (the registry wiring is generated mechanically).
110fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
111 let mut out: Vec<String> = Vec::new();
112 // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
113 for t in tens {
114 for u in units {
115 out.push(format!("{}{}", u, t));
116 }
117 }
118 out.reverse();
119
120 const REPLACEMENTS: &[(&str, &str)] = &[
121 ("novemn", "noven"),
122 ("novemo", "novo"),
123 ("octoo", "octo"),
124 ("quintd", "quind"),
125 ("quintn", "quin"),
126 ("quintq", "quinq"),
127 ("quints", "quins"),
128 ("quintt", "quint"),
129 ("quintv", "quinv"),
130 ("septenn", "septen"),
131 ("septent", "sept"),
132 ("sexn", "sen"),
133 ("sexs", "ses"),
134 ("tresd", "tred"),
135 ("tresn", "tren"),
136 ("tress", "tres"),
137 ("tresv", "trev"),
138 ("unno", "uno"),
139 ];
140 for (k, v) in REPLACEMENTS {
141 out = out.iter().map(|o| o.replace(k, v)).collect();
142 }
143 out.extend(lows.iter().map(|s| s.to_string()));
144 out
145}
146
147/// Python's `s[-n:]` — by character, and clamping instead of panicking when
148/// the string is shorter than `n`. Swedish words carry å/ä/ö, so byte
149/// slicing would corrupt them (and can panic mid-codepoint).
150fn last_n_chars(s: &str, n: usize) -> String {
151 let count = s.chars().count();
152 s.chars().skip(count.saturating_sub(n)).collect()
153}
154
155/// Python's `s[:-n]` — by character. `n` larger than the string yields "",
156/// matching Python (`"ett"[:-4] == ""`), which the `ords` lookup relies on:
157/// "ett" is found via its 4-char probe, then sliced by 4 down to "".
158fn drop_last_n_chars(s: &str, n: usize) -> String {
159 let count = s.chars().count();
160 s.chars().take(count.saturating_sub(n)).collect()
161}
162
163pub struct LangSv {
164 cards: Cards,
165 maxval: BigInt,
166 ords: HashMap<&'static str, &'static str>,
167 exclude_title: Vec<String>,
168 /// `CURRENCY_FORMS` as it exists *after* package import — see the module
169 /// docs. Built once here; constructing it per call is what made an
170 /// earlier revision of this port 10x slower than the Python.
171 currency_forms: HashMap<&'static str, CurrencyForms>,
172 /// `CURRENCY_ADJECTIVES`, inherited unmodified from `Num2Word_EUR`.
173 currency_adjectives: HashMap<&'static str, &'static str>,
174}
175
176impl Default for LangSv {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl LangSv {
183 pub fn new() -> Self {
184 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
185 let units = [
186 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
187 ];
188 let tens = [
189 "dec",
190 "vigint",
191 "trigint",
192 "quadragint",
193 "quinquagint",
194 "sexagint",
195 "septuagint",
196 "octogint",
197 "nonagint",
198 ];
199 let mut high = vec!["cent".to_string()];
200 high.extend(gen_high_numwords(&units, &tens, &lows));
201
202 let mut cards = Cards::new();
203
204 // SV's set_high_numwords: cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
205 // Long scale, step -6: each word supplies BOTH a giga (10^n) and a
206 // mega (10^(n-3)) card. len(high) == 100, so cap == 603 and the
207 // range yields exactly 100 values (603, 597, …, 9) — zip consumes
208 // every high word.
209 let cap = 3 + 6 * high.len() as i64;
210 let mut n = cap;
211 for word in high.iter() {
212 if n <= 3 {
213 break;
214 }
215 // Both suffixes are non-empty for SV, so both branches always fire.
216 cards.insert(
217 BigInt::from(10u8).pow(n as u32),
218 format!("{}{}", word, "iljarder"),
219 );
220 cards.insert(
221 BigInt::from(10u8).pow((n - 3) as u32),
222 format!("{}{}", word, "iljoner"),
223 );
224 n -= 6;
225 }
226
227 set_mid_numwords(
228 &mut cards,
229 &[
230 (1000, "tusen"),
231 (100, "hundra"),
232 (90, "nittio"),
233 (80, "åttio"),
234 (70, "sjuttio"),
235 (60, "sextio"),
236 (50, "femtio"),
237 (40, "fyrtio"),
238 (30, "trettio"),
239 ],
240 );
241 set_low_numwords(
242 &mut cards,
243 &[
244 "tjugo", "nitton", "arton", "sjutton", "sexton", "femton", "fjorton", "tretton",
245 "tolv", "elva", "tio", "nio", "åtta", "sju", "sex", "fem", "fyra", "tre", "två",
246 "ett", "noll",
247 ],
248 );
249
250 // MAXVAL = 1000 * highest card = 1000 * 10^603 = 10^606.
251 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
252
253 // Note "tjugo": dead entry, see the module docs. Kept because the
254 // Python dict ships it and its absence would be a behaviour change if
255 // the lookup were ever widened.
256 let ords: HashMap<&str, &str> = [
257 ("noll", "nollte"),
258 ("ett", "första"),
259 ("två", "andra"),
260 ("tre", "tredje"),
261 ("fyra", "fjärde"),
262 ("fem", "femte"),
263 ("sex", "sjätte"),
264 ("sju", "sjunde"),
265 ("åtta", "åttonde"),
266 ("nio", "nionde"),
267 ("tio", "tionde"),
268 ("elva", "elfte"),
269 ("tolv", "tolfte"),
270 ("tjugo", "tjugonde"),
271 ]
272 .into_iter()
273 .collect();
274
275 // Arity is load-bearing: `pluralize` indexes these, and PLN/RON carry
276 // a third form that must not be dropped even though EUR's `pluralize`
277 // never reaches index 2.
278 let currency_forms: HashMap<&'static str, CurrencyForms> = [
279 ("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"])),
280 ("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
281 ("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
282 ("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"])),
283 ("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
284 ("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
285 ("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"])),
286 ("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"])),
287 ("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"])),
288 ("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"])),
289 ("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"])),
290 ("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
291 ("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"])),
292 ("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"])),
293 ("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
294 ("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"])),
295 ("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
296 ("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"])),
297 ("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"])),
298 ("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
299 ("LTL", CurrencyForms::new(&["litas", "litas"], &["cent", "cents"])),
300 ("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"])),
301 ("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"])),
302 ("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"])),
303 ("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"])),
304 ("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"])),
305 ("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
306 ("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"])),
307 ("PLN", CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"])),
308 ("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"])),
309 ("RON", CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"])),
310 ("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
311 ("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"])),
312 ("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"])),
313 ("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
314 ("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"])),
315 ("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
316 ("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"])),
317 ("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"])),
318 ]
319 .into_iter()
320 .collect();
321
322 let currency_adjectives: HashMap<&'static str, &'static str> = [
323 ("AUD", "Australian"),
324 ("BYN", "Belarusian"),
325 ("CAD", "Canadian"),
326 ("EEK", "Estonian"),
327 ("HUF", "Hungarian"),
328 ("INR", "Indian"),
329 ("ISK", "íslenskar"),
330 ("JPY", "Japanese"),
331 ("KRW", "Korean"),
332 ("MXN", "Mexican"),
333 ("NOK", "Norwegian"),
334 ("RON", "Romanian"),
335 ("RUB", "Russian"),
336 ("SAR", "Saudi"),
337 ("USD", "US"),
338 ("UZS", "Uzbekistan"),
339 ]
340 .into_iter()
341 .collect();
342
343 LangSv {
344 cards,
345 maxval,
346 ords,
347 exclude_title: vec!["och".into(), "komma".into(), "minus".into()],
348 currency_forms,
349 currency_adjectives,
350 }
351 }
352
353 /// Port of `Num2Word_Base.verify_ordinal`. The float check is vacuous for
354 /// BigInt input; only the negative check can fire.
355 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
356 if value.sign() == num_bigint::Sign::Minus {
357 return Err(N2WError::Type(format!(
358 "Cannot treat negative num {} as ordinal.",
359 value
360 )));
361 }
362 Ok(())
363 }
364
365 /// `verify_ordinal` for a float/Decimal: the float check fires first,
366 /// then the negative one — both TypeError with Base's default wording.
367 /// `-0.0` passes both. Returns the whole value on success.
368 fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
369 let whole = match value.as_whole_int() {
370 Some(i) => i,
371 None => {
372 return Err(N2WError::Type(format!(
373 "Cannot treat float {} as ordinal.",
374 sv_py_num_str(value)
375 )))
376 }
377 };
378 if whole.is_negative() {
379 return Err(N2WError::Type(format!(
380 "Cannot treat negative num {} as ordinal.",
381 sv_py_num_str(value)
382 )));
383 }
384 Ok(whole)
385 }
386
387 /// Python's `self.to_cardinal(x)` for an f64 — Base semantics: whole →
388 /// integer path, fractional → the base float grammar ("komma" + digits).
389 fn cardinal_num_f64(&self, x: f64) -> Result<String> {
390 if x.fract() == 0.0 {
391 if let Some(i) = BigInt::from_f64(x) {
392 return self.to_cardinal(&i);
393 }
394 }
395 default_to_cardinal_float(
396 self,
397 &FloatValue::Float {
398 value: x,
399 precision: sv_float_repr_precision(x),
400 },
401 None,
402 )
403 }
404
405 /// Python's `self.to_cardinal(x)` for a Decimal.
406 fn cardinal_num_dec(&self, x: &BigDecimal) -> Result<String> {
407 if x.is_integer() {
408 return self.to_cardinal(&x.with_scale(0).as_bigint_and_exponent().0);
409 }
410 let precision = x.as_bigint_and_exponent().1.max(0) as u32;
411 default_to_cardinal_float(
412 self,
413 &FloatValue::Decimal {
414 value: x.clone(),
415 precision,
416 },
417 None,
418 )
419 }
420}
421
422/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64. Mirrors the
423/// private helper in `floatpath`.
424fn sv_float_repr_precision(f: f64) -> u32 {
425 let s = format!("{}", f);
426 match s.split_once('.') {
427 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
428 _ => 0,
429 }
430}
431
432/// Best-effort `str(number)` for the TypeError messages (the corpus compares
433/// exception types only, so the float arm's repr need not be byte-exact).
434fn sv_py_num_str(value: &FloatValue) -> String {
435 match value {
436 FloatValue::Float { value, .. } => format!("{}", value),
437 FloatValue::Decimal { value, .. } => python_decimal_str(value),
438 }
439}
440
441impl Lang for LangSv {
442 /// This language's own `to_currency(currency=...)` default,
443 /// read from the live Python signature. Only 44 of 156 use EUR.
444 fn default_currency(&self) -> &str {
445 "EUR"
446 }
447
448 /// This language's own `to_currency(separator=...)` default,
449 /// read from the live Python signature. Base's is ",", but only
450 /// 36 of 149 languages actually use it — most default to " " or a
451 /// conjunction, so inheriting Base's comma silently corrupts them.
452 fn default_separator(&self) -> &str {
453 ","
454 }
455
456 fn cards(&self) -> &Cards {
457 &self.cards
458 }
459 fn maxval(&self) -> &BigInt {
460 &self.maxval
461 }
462 fn negword(&self) -> &str {
463 "minus "
464 }
465 fn pointword(&self) -> &str {
466 "komma"
467 }
468 fn exclude_title(&self) -> &[String] {
469 &self.exclude_title
470 }
471
472 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
473 let (ltext, lnum) = l;
474 let (rtext, rnum) = r;
475 let hundred = BigInt::from(100);
476 let million = BigInt::from(1_000_000);
477
478 if lnum.is_one() && rnum < &hundred {
479 (rtext.to_string(), rnum.clone())
480 } else if &hundred > lnum && lnum > rnum {
481 (format!("{}{}", ltext, rtext), lnum + rnum)
482 } else if lnum >= &hundred && &hundred > rnum {
483 (format!("{}{}", ltext, rtext), lnum + rnum)
484 } else if rnum >= &million && lnum.is_one() {
485 // Singularise the plural card word: "miljoner" -> "miljon",
486 // "miljarder" -> "miljard". Python: "%s %s" % ("en", rtext[:-2]).
487 // Note the `lnum + rnum` — a product is clearly intended; see the
488 // module docs, bug 3.
489 (format!("en {}", drop_last_n_chars(rtext, 2)), lnum + rnum)
490 } else if rnum >= &million && lnum > &BigInt::one() {
491 (format!("{} {}", ltext, rtext), lnum + rnum)
492 } else if rnum > lnum {
493 // Swedish drops "ett" before "hundra" in the hundred-thousand
494 // compound: 100000 is "hundratusen", not "etthundratusen".
495 if lnum == &hundred && rnum == &BigInt::from(1000) {
496 return ("hundratusen".to_string(), BigInt::from(100_000));
497 }
498 // No triple consonants: "ett" + "tusen" = "etttusen" -> "ettusen";
499 // likewise 21000 -> "tjugoettusen". Python's str.replace is
500 // replace-all, and so is Rust's.
501 (
502 format!("{}{}", ltext, rtext).replace("ttt", "tt"),
503 lnum * rnum,
504 )
505 } else {
506 (format!("{} {}", ltext, rtext), lnum + rnum)
507 }
508 }
509
510 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
511 self.verify_ordinal(value)?;
512 let cardinal = self.to_cardinal(value)?;
513 let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
514 // to_cardinal never returns "", so split(' ') always yields >= 1 item.
515 let lastword = outwords.last().unwrap().clone();
516
517 // Probe ords by the last 4 chars, then the last 3. Python swallows the
518 // KeyErrors; the final fallback is the generic "de" suffix with
519 // ending_length left at its initial 0.
520 let mut ending_length: usize = 0;
521 let lastword_ending: String = match self.ords.get(last_n_chars(&lastword, 4).as_str()) {
522 Some(e) => {
523 ending_length = 4;
524 (*e).to_string()
525 }
526 None => match self.ords.get(last_n_chars(&lastword, 3).as_str()) {
527 Some(e) => {
528 ending_length = 3;
529 (*e).to_string()
530 }
531 None => "de".to_string(),
532 },
533 };
534
535 // Python compares the *value* to "de", so an ords entry that happened
536 // to equal "de" would also take the no-truncation path. None does.
537 let lastword_first_part = if lastword_ending == "de" {
538 self.title(&lastword)
539 } else {
540 drop_last_n_chars(&self.title(&lastword), ending_length)
541 };
542
543 let n = outwords.len();
544 outwords[n - 1] = format!("{}{}", lastword_first_part, lastword_ending);
545 Ok(outwords.join(" "))
546 }
547
548 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
549 // No verify_ordinal call here — negatives pass straight through.
550 let s = value.to_string();
551 let one = BigInt::one();
552 let two = BigInt::from(2);
553 let eleven = BigInt::from(11);
554 let twelve = BigInt::from(12);
555
556 if value == &one || value == &two {
557 Ok(format!("{}:a", s))
558 } else if (s.ends_with('1') || s.ends_with('2')) && value != &eleven && value != &twelve {
559 Ok(format!("{}:a", s))
560 } else if s.ends_with(|c: char| matches!(c, '3'..='9' | '0')) {
561 Ok(format!("{}:e", s))
562 } else {
563 // Unreachable in Python too: identical to the branch above.
564 Ok(format!("{}:e", s))
565 }
566 }
567
568 fn to_year(&self, value: &BigInt) -> Result<String> {
569 let thousand = BigInt::from(1000);
570 let two_thousand = BigInt::from(2000);
571
572 if value.sign() == num_bigint::Sign::Minus {
573 return Ok(format!("{} f.Kr.", self.to_cardinal(&(-value))?));
574 }
575 if value < &thousand {
576 return self.to_cardinal(value);
577 }
578 if value < &two_thousand {
579 // 1000..1999 read as "<century>hundra<remainder>": 1492 ->
580 // "fjortonhundranittiotvå". Both operands are non-negative here,
581 // so Python's floor division matches a plain BigInt divide.
582 let hundred = BigInt::from(100);
583 let century = value / &hundred;
584 let remainder = value % &hundred;
585 if remainder.is_zero() {
586 return Ok(format!("{}hundra", self.to_cardinal(¢ury)?));
587 }
588 return Ok(format!(
589 "{}hundra{}",
590 self.to_cardinal(¢ury)?,
591 self.to_cardinal(&remainder)?
592 ));
593 }
594 // 2000+ read as a plain cardinal: "tvåtusentjugofyra".
595 self.to_cardinal(value)
596 }
597
598 /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for
599 /// any fractional or negative value; a whole non-negative one flows
600 /// through `to_cardinal` + the `ords` suffix table — identical to the
601 /// integer ordinal ("5.0" → "femte", `1e+16` → "tio biljarderde").
602 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
603 let whole = self.verify_ordinal_num(value)?;
604 self.to_ordinal(&whole)
605 }
606
607 /// `to_ordinal_num(float/Decimal)` — no verify_ordinal at all:
608 ///
609 /// ```python
610 /// if value == 1 or value == 2: str(value) + ":a"
611 /// elif str(value).endswith(("1","2")) and value not in (11,12): ":a"
612 /// else: str(value) + ":e"
613 /// ```
614 ///
615 /// Numeric equality plus a *lexical* suffix test on `str(value)` — so
616 /// "1.0"/"2.0" take ":a" (== 1/2), "42.0" takes ":e" (ends "0"), and
617 /// "1E+2" takes ":a" (ends "2").
618 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
619 let one = BigInt::one();
620 let two = BigInt::from(2);
621 let eleven = BigInt::from(11);
622 let twelve = BigInt::from(12);
623 let whole = value.as_whole_int();
624 let eq = |n: &BigInt| whole.as_ref() == Some(n);
625
626 if eq(&one) || eq(&two) {
627 Ok(format!("{}:a", repr_str))
628 } else if (repr_str.ends_with('1') || repr_str.ends_with('2'))
629 && !eq(&eleven)
630 && !eq(&twelve)
631 {
632 Ok(format!("{}:a", repr_str))
633 } else {
634 Ok(format!("{}:e", repr_str))
635 }
636 }
637
638 /// `to_year(float/Decimal)` — `Num2Word_SV.to_year`'s arithmetic on the
639 /// raw value: BC years render the *absolute* cardinal + " f.Kr."
640 /// (`-1.5` → "ett komma fem f.Kr."), 1000..1999 splits into
641 /// "<century>hundra<remainder>" ("1234.0" → "tolvhundratrettiofyra"),
642 /// everything else is a plain cardinal.
643 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
644 match value {
645 FloatValue::Float { value: v, precision } => {
646 let v = *v;
647 if v < 0.0 {
648 // to_cardinal(-val) + " f.Kr."
649 let abs = FloatValue::Float {
650 value: -v,
651 precision: *precision,
652 };
653 return Ok(format!("{} f.Kr.", self.cardinal_float_entry(&abs, None)?));
654 }
655 if v < 1000.0 {
656 return self.cardinal_float_entry(value, None);
657 }
658 if v < 2000.0 {
659 let century = (v / 100.0).floor();
660 let remainder = v.rem_euclid(100.0);
661 if remainder == 0.0 {
662 return Ok(format!("{}hundra", self.cardinal_num_f64(century)?));
663 }
664 return Ok(format!(
665 "{}hundra{}",
666 self.cardinal_num_f64(century)?,
667 self.cardinal_num_f64(remainder)?
668 ));
669 }
670 self.cardinal_float_entry(value, None)
671 }
672 FloatValue::Decimal { value: d, precision } => {
673 if d.is_negative() {
674 let abs = FloatValue::Decimal {
675 value: -d.clone(),
676 precision: *precision,
677 };
678 return Ok(format!("{} f.Kr.", self.cardinal_float_entry(&abs, None)?));
679 }
680 if d < &BigDecimal::from(1000) {
681 return self.cardinal_float_entry(value, None);
682 }
683 if d < &BigDecimal::from(2000) {
684 let hundred = BigDecimal::from(100);
685 let century =
686 (d / &hundred).with_scale_round(0, bigdecimal::RoundingMode::Down);
687 let remainder = d - ¢ury * &hundred;
688 if remainder.is_zero() {
689 return Ok(format!("{}hundra", self.cardinal_num_dec(¢ury)?));
690 }
691 return Ok(format!(
692 "{}hundra{}",
693 self.cardinal_num_dec(¢ury)?,
694 self.cardinal_num_dec(&remainder)?
695 ));
696 }
697 self.cardinal_float_entry(value, None)
698 }
699 }
700 }
701
702 // ---- currency ----------------------------------------------------
703 //
704 // `currency_precision` is intentionally NOT overridden: SV resolves
705 // `CURRENCY_PRECISION` to `Num2Word_Base`'s empty dict, so every code
706 // takes the default divisor of 100. See the module docs.
707 //
708 // `money_verbose` / `cents_verbose` / `cents_terse` are likewise left
709 // alone — SV inherits the base versions, which the trait defaults already
710 // mirror (`to_cardinal`, `to_cardinal`, and zero-padded digits at the
711 // currency's precision).
712 //
713 // `to_cheque` is not overridden either: SV inherits
714 // `Num2Word_Base.to_cheque` verbatim, which `default_to_cheque` ports.
715 // With divisor 100 it renders "<words> AND <NN>/100 <PLURAL UNIT>"
716 // upper-cased — e.g. 1234.56 EUR → "ETTUSEN TVÅHUNDRATRETTIOFYRA AND
717 // 56/100 EUROS". All nine cheque corpus rows land on that default.
718
719 fn lang_name(&self) -> &str {
720 "Num2Word_SV"
721 }
722
723 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
724 self.currency_forms.get(code)
725 }
726
727 fn currency_adjective(&self, code: &str) -> Option<&str> {
728 self.currency_adjectives.get(code).copied()
729 }
730
731 /// Port of `Num2Word_EUR.pluralize`: `form = 0 if n == 1 else 1`.
732 ///
733 /// Reached only from the base float path — SV's own int branch open-codes
734 /// the same choice instead of calling this.
735 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
736 let form = if n.is_one() { 0 } else { 1 };
737 // Python would raise IndexError on a 1-element tuple with n != 1.
738 // Unreachable with SV's table (every entry has >= 2 forms), but the
739 // exception *type* is part of the contract if it ever were.
740 forms
741 .get(form)
742 .cloned()
743 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
744 }
745
746 /// Port of `Num2Word_SV.to_currency`.
747 ///
748 /// Only true ints take SV's own branch; everything else is handed to
749 /// `Num2Word_Base.to_currency` verbatim, which is what Python's
750 /// `super().to_currency(...)` does. The int/non-int split is the whole
751 /// point of the override — `1` renders "ett euro" while `1.0` renders
752 /// "ett euro, noll cents".
753 fn to_currency(
754 &self,
755 val: &CurrencyValue,
756 currency: &str,
757 cents: bool,
758 separator: Option<&str>,
759 adjective: bool,
760 ) -> Result<String> {
761 // Trait now hands us None when the caller omitted separator=;
762 // resolve it to this language's own default before the ported body.
763 let separator = separator.unwrap_or(self.default_separator());
764 let v = match val {
765 CurrencyValue::Int(v) => v,
766 // "For floats, use the parent class implementation."
767 CurrencyValue::Decimal { value: _, .. } => {
768 return crate::currency::default_to_currency(
769 self, val, currency, cents, separator, adjective,
770 )
771 }
772 };
773
774 let forms = match self.currency_forms.get(currency) {
775 Some(f) => f,
776 // Python catches (KeyError, AttributeError) and re-enters
777 // super().to_currency(), whose int branch looks the same code up
778 // and raises NotImplementedError. Delegating reproduces that
779 // exactly — including the class name in the message — without
780 // duplicating the raise.
781 None => {
782 return crate::currency::default_to_currency(
783 self, val, currency, cents, separator, adjective,
784 )
785 }
786 };
787
788 // `minus_str = self.negword` — un-stripped, unlike every other call
789 // site. The trailing space plus the format's own separator is the
790 // double-space bug; see the module docs, bug 5.
791 let minus_str = if v.is_negative() { self.negword() } else { "" };
792 let abs_val = v.abs();
793 // Python calls to_cardinal directly here, not _money_verbose. Same
794 // result for SV, but kept literal.
795 let money_str = self.to_cardinal(&abs_val)?;
796
797 // Open-coded rather than routed through `pluralize`, mirroring the
798 // Python. The isinstance(cr1, tuple) guards are vacuous — every entry
799 // is a tuple — so this reduces to forms[0] / forms[1], with the
800 // len-1 fallback preserved for structural fidelity.
801 let currency_str = if abs_val.is_one() {
802 &forms.unit[0]
803 } else if forms.unit.len() > 1 {
804 &forms.unit[1]
805 } else {
806 &forms.unit[0]
807 };
808
809 // `adjective` and `cents`/`separator` are deliberately unused here —
810 // Python's int branch ignores all three. See the module docs, bug 6.
811 Ok(format!("{} {} {}", minus_str, money_str, currency_str)
812 .trim()
813 .to_string())
814 }
815}