Skip to main content

num2words2_core/
lang_ko.rs

1//! Port of `lang_KO.py` (Korean).
2//!
3//! Shape: **engine**. `Num2Word_KO` subclasses `Num2Word_Base` directly and
4//! supplies `high_numwords` / `mid_numwords` / `low_numwords` plus a `merge`,
5//! so Python builds `self.cards` and lets `Num2Word_Base.to_cardinal` drive
6//! `splitnum`/`clean`. Cards, maxval and merge are therefore all live here and
7//! `to_cardinal` stays at the trait default (`base::default_to_cardinal`).
8//!
9//! # Card table
10//!
11//! Korean groups by **myriads** (10^4), not thousands. KO overrides
12//! `set_high_numwords` with a step of -4:
13//!
14//! ```text
15//! max = 4 * len(high)              # 4 * 17 = 68
16//! zip(high, range(max, 0, -4))     # 68, 64, 60, ... 8, 4
17//! ```
18//!
19//! giving 무량대수=10^68 … 조=10^12, 억=10^8, 만=10^4, then mid 천=1000,
20//! 백=100 and low 십=10 … 일=1, 영=0. Insertion order is already strictly
21//! descending, so `Cards`' sorted-descending storage reproduces Python's
22//! `OrderedDict` iteration order exactly.
23//!
24//! `MAXVAL = 1000 * next(iter(cards))` = 1000 * 10^68 = **10^71**. Note the
25//! `1000` factor is `Num2Word_Base.__init__`'s hardcoded short-scale
26//! assumption; it does not match KO's myriad grouping, but it is what Python
27//! computes, so the overflow threshold is 10^71 and not 10^72.
28//!
29//! # merge quirks worth knowing
30//!
31//! * `lnum == 1 and rnum <= 10000` swallows the leading 일, which is why
32//!   10^4 renders "만" (bare) while 10^8 renders "일억" — 10^8 exceeds the
33//!   `<= 10000` cutoff and falls through to the multiplying `else` arm.
34//! * `lnum >= 10000 and lnum > rnum` is the only arm that emits a **space**,
35//!   so spaces separate myriad groups: 12345 → "만 이천삼백사십오".
36//!
37//! # to_ordinal
38//!
39//! Only the **last space-separated word** is converted to native-Korean
40//! ordinal counting words; everything left of it keeps Sino-Korean cardinal
41//! form. Hence 999999 → "구십구만 구천구백아흔아홉 번째" (the leading
42//! "구십구만" is untouched).
43//!
44//! `value % 100 == 0` skips the rewrite entirely, so 100 → "백 번째" and
45//! 10^12 → "일조 번째" keep cardinal words.
46//!
47//! Python splits the last word with the zero-width regex
48//! `re.split("(?<=천)|(?<=백)", w)`, i.e. cut *after* every 천 and 백.
49//! [`split_after_mid`] reproduces this exactly, including the trailing empty
50//! string Python yields when the word ends in 천/백 (e.g. "천백" →
51//! `["천", "백", ""]`). That trailing-empty case is unreachable from
52//! `to_ordinal` because a word ending in 천/백 implies `value % 100 == 0`,
53//! which the guard above already excludes — but the helper matches Python
54//! regardless.
55//!
56//! # Faithfully reproduced Python behaviour
57//!
58//! * `to_ordinal(0)` → "영 번째". `0 % 100 == 0` skips the ords rewrite, so
59//!   the Sino-Korean 영 survives rather than becoming a counting word.
60//! * `to_ordinal(1)` short-circuits to "첫 번째" before `to_cardinal` runs.
61//! * The `if not ten_one[1]` arm rewrites 스물 → 스무 only for bare tens, so
62//!   20 → "스무 번째" but 21 → "스물한 번째".
63//! * `to_ordinal_num` ignores every table and just formats the digits:
64//!   `"%s 번째" % value` → 0 → "0 번째".
65//! * Negative input to `to_ordinal` / `to_ordinal_num` raises `TypeError` via
66//!   `Num2Word_Base.verify_ordinal` (→ [`N2WError::Type`]). `to_cardinal` and
67//!   `to_year` accept negatives.
68//!
69//! # Currency
70//!
71//! `Num2Word_KO` overrides `to_currency` **wholesale** and shares almost
72//! nothing with `Num2Word_Base`'s version. It never calls `pluralize`,
73//! `_money_verbose`, `_cents_verbose` or `_cents_terse` — it goes straight to
74//! `to_cardinal` — so those hooks all stay at their trait defaults. See
75//! [`LangKo::to_currency`] for the divergences, which are substantial.
76//!
77//! `to_cheque` is *not* overridden in Python, so `Num2Word_Base.to_cheque`
78//! runs against KO's table — and promptly trips over it. See the `to_cheque`
79//! impl below for the 1-tuple unpack bug.
80//!
81//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` for KO
82//! (verified against the live interpreter: KO subclasses `Num2Word_Base`
83//! directly, and `Num2Word_EN.__init__` *rebinds* `CURRENCY_PRECISION` on the
84//! instance rather than mutating the class dict, so nothing leaks in). KO also
85//! declares its own `CURRENCY_FORMS` class attribute, so EN's in-place
86//! mutation of `Num2Word_EUR.CURRENCY_FORMS` cannot reach it — hence EUR/GBP
87//! and EN's ~24 added codes all raise NotImplementedError here.
88
89use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
90use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
91use crate::floatpath::FloatValue;
92use bigdecimal::BigDecimal;
93use num_bigint::BigInt;
94use num_traits::{One, Signed, Zero};
95use std::collections::HashMap;
96
97/// Emulates Python's `re.split("(?<=천)|(?<=백)", s)`.
98///
99/// Both alternatives are zero-width lookbehinds, so the split points sit
100/// *after* each 천 / 백. Python 3.7+ splits on empty matches, which means a
101/// word ending in 천/백 produces a trailing empty string — reproduced here.
102///
103/// ```text
104/// "천이백삼십사" -> ["천", "이백", "삼십사"]
105/// "구백구십구"   -> ["구백", "구십구"]
106/// "십"           -> ["십"]
107/// "천백"         -> ["천", "백", ""]
108/// ```
109fn split_after_mid(s: &str) -> Vec<String> {
110    let mut out: Vec<String> = Vec::new();
111    let mut cur = String::new();
112    for c in s.chars() {
113        cur.push(c);
114        if c == '천' || c == '백' {
115            out.push(std::mem::take(&mut cur));
116        }
117    }
118    out.push(cur);
119    out
120}
121
122/// `Num2Word_KO.CURRENCY_FORMS`, exactly as the class body declares it:
123///
124/// ```python
125/// CURRENCY_FORMS = {
126///     "KRW": ("원",),            # KRW doesn't use fractional units
127///     "USD": ("달러", "센트"),
128///     "JPY": ("엔",),            # JPY doesn't use fractional units
129/// }
130/// ```
131///
132/// The arity is load-bearing twice over, so the 1-tuples map to an **empty**
133/// `subunit` rather than a duplicated or invented one:
134///
135/// * `to_currency` does `minor = curr_forms[1] if len(curr_forms) > 1 else None`
136///   — an empty `subunit` is what makes KRW/JPY take the no-minor-unit branch.
137/// * `to_cheque` does `cr1, _cr2 = self.CURRENCY_FORMS[currency]`, a 2-tuple
138///   unpack that *raises* on the 1-tuples.
139///
140/// Note each side holds a single form, not a singular/plural pair: Korean does
141/// not inflect for number, and KO never calls `pluralize`.
142fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
143    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
144    m.insert("KRW", CurrencyForms::new(&["원"], &[]));
145    m.insert("USD", CurrencyForms::new(&["달러"], &["센트"]));
146    m.insert("JPY", CurrencyForms::new(&["엔"], &[]));
147    m
148}
149
150pub struct LangKo {
151    cards: Cards,
152    maxval: BigInt,
153    ords: HashMap<&'static str, &'static str>,
154    currency_forms: HashMap<&'static str, CurrencyForms>,
155}
156
157impl Default for LangKo {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163impl LangKo {
164    pub fn new() -> Self {
165        // Python `high_numwords`, most significant first.
166        const HIGH: [&str; 17] = [
167            "무량대수",
168            "불가사의",
169            "나유타",
170            "아승기",
171            "항하사",
172            "극",
173            "재",
174            "정",
175            "간",
176            "구",
177            "양",
178            "자",
179            "해",
180            "경",
181            "조",
182            "억",
183            "만",
184        ];
185
186        let mut cards = Cards::new();
187
188        // KO's set_high_numwords: max = 4 * len(high); zip(high, range(max, 0, -4)).
189        // zip stops at the shorter sequence; here both have 17 entries.
190        let max = 4 * HIGH.len() as u32;
191        let mut n = max;
192        for word in HIGH.iter() {
193            if n == 0 {
194                break;
195            }
196            cards.insert(BigInt::from(10u8).pow(n), *word);
197            n -= 4;
198        }
199
200        set_mid_numwords(&mut cards, &[(1000, "천"), (100, "백")]);
201        set_low_numwords(
202            &mut cards,
203            &["십", "구", "팔", "칠", "육", "오", "사", "삼", "이", "일", "영"],
204        );
205
206        // Num2Word_Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0]
207        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
208
209        let ords: HashMap<&str, &str> = [
210            ("일", "한"),
211            ("이", "두"),
212            ("삼", "세"),
213            ("사", "네"),
214            ("오", "다섯"),
215            ("육", "여섯"),
216            ("칠", "일곱"),
217            ("팔", "여덟"),
218            ("구", "아홉"),
219            ("십", "열"),
220            ("이십", "스물"),
221            ("삼십", "서른"),
222            ("사십", "마흔"),
223            ("오십", "쉰"),
224            ("육십", "예순"),
225            ("칠십", "일흔"),
226            ("팔십", "여든"),
227            ("구십", "아흔"),
228        ]
229        .into_iter()
230        .collect();
231
232        LangKo {
233            cards,
234            maxval,
235            ords,
236            // Built once here, never per call.
237            currency_forms: build_currency_forms(),
238        }
239    }
240
241    /// `Num2Word_Base.verify_ordinal`. The float check is vacuous for BigInt
242    /// input; only the negative check can fire.
243    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
244        if value.is_negative() {
245            return Err(N2WError::Type(format!(
246                "Cannot treat negative num {} as ordinal.",
247                value
248            )));
249        }
250        Ok(())
251    }
252
253    /// `self.ords.get(key, key)` — miss returns the key itself.
254    fn ord_or_self(&self, key: &str) -> String {
255        self.ords
256            .get(key)
257            .map(|s| s.to_string())
258            .unwrap_or_else(|| key.to_string())
259    }
260}
261
262impl Lang for LangKo {
263    /// This language's own `to_currency(currency=...)` default,
264    /// read from the live Python signature. Only 44 of 156 use EUR.
265    fn default_currency(&self) -> &str {
266        "KRW"
267    }
268
269    /// This language's own `to_currency(separator=...)` default,
270    /// read from the live Python signature. Base's is ",", but only
271    /// 36 of 149 languages actually use it — most default to " " or a
272    /// conjunction, so inheriting Base's comma silently corrupts them.
273    fn default_separator(&self) -> &str {
274        " "
275    }
276
277    fn cards(&self) -> &Cards {
278        &self.cards
279    }
280
281    fn maxval(&self) -> &BigInt {
282        &self.maxval
283    }
284
285    fn negword(&self) -> &str {
286        "마이너스 "
287    }
288
289    fn pointword(&self) -> &str {
290        "점"
291    }
292
293    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
294        let (ltext, lnum) = l;
295        let (rtext, rnum) = r;
296        let myriad = BigInt::from(10000);
297
298        if lnum.is_one() && rnum <= &myriad {
299            // Swallows the leading 일: 10^4 -> "만", not "일만".
300            (rtext.to_string(), rnum.clone())
301        } else if &myriad > lnum && lnum > rnum {
302            (format!("{}{}", ltext, rtext), lnum + rnum)
303        } else if lnum >= &myriad && lnum > rnum {
304            // The only arm that emits a space — separates myriad groups.
305            (format!("{} {}", ltext, rtext), lnum + rnum)
306        } else {
307            (format!("{}{}", ltext, rtext), lnum * rnum)
308        }
309    }
310
311    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
312        self.verify_ordinal(value)?;
313        if value.is_one() {
314            return Ok("첫 번째".to_string());
315        }
316
317        let cardinal = self.to_cardinal(value)?;
318        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
319
320        if !(value % BigInt::from(100)).is_zero() {
321            // `outwords` is never empty: split(' ') always yields >= 1 item.
322            let last = outwords[outwords.len() - 1].clone();
323            let mut lastwords = split_after_mid(&last);
324            let li = lastwords.len() - 1;
325
326            if lastwords[li].contains('십') {
327                // str.split("십") on a string containing 십 yields >= 2 parts,
328                // so ten_one[0] / ten_one[1] are always in range. Guarded
329                // anyway: Python would raise IndexError here.
330                let mut ten_one: Vec<String> =
331                    lastwords[li].split('십').map(|s| s.to_string()).collect();
332                if ten_one.len() < 2 {
333                    return Err(N2WError::Index("list index out of range".into()));
334                }
335                ten_one[0] = self.ord_or_self(&format!("{}십", ten_one[0]));
336                ten_one[1] = self.ord_or_self(&ten_one[1].clone());
337                if ten_one[1].is_empty() {
338                    // Bare tens: 스물 -> 스무 (20 -> "스무 번째").
339                    ten_one[0] = ten_one[0].replace("스물", "스무");
340                }
341                lastwords[li] = ten_one.concat();
342            } else {
343                lastwords[li] = self.ord_or_self(&lastwords[li].clone());
344            }
345
346            let n = outwords.len();
347            outwords[n - 1] = lastwords.concat();
348        }
349
350        // Python: " ".join(x.strip() for x in outwords if x.strip()) + " 번째"
351        let joined = outwords
352            .iter()
353            .map(|x| x.trim())
354            .filter(|x| !x.is_empty())
355            .collect::<Vec<_>>()
356            .join(" ");
357        Ok(format!("{} 번째", joined))
358    }
359
360    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
361        self.verify_ordinal(value)?;
362        Ok(format!("{} 번째", value))
363    }
364
365    fn to_year(&self, value: &BigInt) -> Result<String> {
366        // Python signature: to_year(val, suffix=None, longval=True). The trait
367        // exposes only `value`, so `suffix` is always None on entry and can
368        // only be set by the negative branch.
369        let mut val = value.clone();
370        let mut suffix: Option<&str> = None;
371        if val.is_negative() {
372            val = val.abs();
373            suffix = Some("기원전");
374        }
375        let valtext = self.to_cardinal(&val)?;
376        Ok(match suffix {
377            Some(s) => format!("{} {}", s, valtext),
378            None => valtext,
379        })
380    }
381
382    /// `to_ordinal(float/Decimal)`. `verify_ordinal` runs first: a
383    /// non-integral value raises TypeError (`errmsg_floatord`), then the
384    /// negative check raises TypeError (`errmsg_negord`) — the latter is
385    /// reproduced inside `to_ordinal(BigInt)`'s own verify. `-0.0` passes
386    /// both checks (`abs(-0.0) == -0.0`) and lands on the 0 path.
387    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
388        match value.as_whole_int() {
389            // Whole: to_ordinal's own verify_ordinal rejects negatives.
390            Some(i) => self.to_ordinal(&i),
391            None => Err(N2WError::Type(
392                "Cannot treat float as ordinal.".into(),
393            )),
394        }
395    }
396
397    /// `to_ordinal_num(float/Decimal)`: same `verify_ordinal`, then
398    /// `"%s 번째" % value` — the `%s` is Python's `str(value)`, i.e. the
399    /// repr the binding computed ("5.0 번째", "1e+16 번째", "-0.0 번째").
400    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
401        match value.as_whole_int() {
402            Some(i) => {
403                if i.is_negative() {
404                    return Err(N2WError::Type(format!(
405                        "Cannot treat negative num {} as ordinal.",
406                        repr_str
407                    )));
408                }
409                Ok(format!("{} 번째", repr_str))
410            }
411            None => Err(N2WError::Type(
412                "Cannot treat float as ordinal.".into(),
413            )),
414        }
415    }
416
417    /// `to_year(float/Decimal)`: `if val < 0` — a *numeric* test, so `-0.0`
418    /// takes the positive arm — then `기원전 ` + cardinal of the absolute
419    /// value, which keeps its float grammar ("기원전 일 점 오").
420    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
421        let negative = match value {
422            FloatValue::Float { value, .. } => *value < 0.0,
423            FloatValue::Decimal { value, .. } => value.is_negative(),
424        };
425        if negative {
426            let abs = match value {
427                FloatValue::Float { value, precision } => FloatValue::Float {
428                    value: value.abs(),
429                    precision: *precision,
430                },
431                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
432                    value: value.abs(),
433                    precision: *precision,
434                },
435            };
436            Ok(format!("기원전 {}", self.cardinal_float_entry(&abs, None)?))
437        } else {
438            self.cardinal_float_entry(value, None)
439        }
440    }
441
442    // ---- currency -------------------------------------------------------
443    //
444    // `currency_precision` and `currency_adjective` stay at their defaults:
445    // KO's `CURRENCY_PRECISION` / `CURRENCY_ADJECTIVES` are both `{}`, so
446    // `.get(code, 100)` is always 100 and the adjective lookup always misses.
447    //
448    // `pluralize`, `money_verbose`, `cents_verbose` and `cents_terse` also
449    // stay at their defaults — KO's `to_currency` never calls any of them.
450    // (`money_verbose`'s default *is* reached, but only via `to_cheque`.)
451
452    fn lang_name(&self) -> &str {
453        "Num2Word_KO"
454    }
455
456    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
457        self.currency_forms.get(code)
458    }
459
460    /// `Num2Word_KO.to_currency` — a wholesale override that shares almost
461    /// nothing with `Num2Word_Base.to_currency`. The divergences are all
462    /// deliberate ports, not cleanups:
463    ///
464    /// * **No space before the unit.** Python emits `"%s%s%s"`, so 100 USD is
465    ///   "백달러", not "백 달러". Base would insert a space.
466    /// * **`negword` is used unstripped.** Python writes `self.negword`, not
467    ///   `self.negword.strip()` as Base does. KO's negword already carries the
468    ///   trailing space ("마이너스 "), so the output happens to look right —
469    ///   but it arrives by a different route, and a caller who changed negword
470    ///   would see the difference.
471    /// * **The divisor is hardcoded to 100.** Python calls
472    ///   `parse_currency_parts(...)` without a `divisor=` argument and computes
473    ///   `(decimal_val * 100) % 1`, never consulting `CURRENCY_PRECISION`. So
474    ///   `self.currency_precision()` is deliberately *not* called here. It is
475    ///   moot today (KO's table is empty → always 100) but it is why 100.5 KRW
476    ///   raises instead of rounding to a whole won: cents are still extracted
477    ///   at 1/100 even for a currency KO itself calls fractionless.
478    /// * **`has_decimal` is never consulted.** Base gates the cents segment on
479    ///   `isinstance(val, float) or "." in str(val)`; KO gates it purely on
480    ///   `isinstance(val, int)`. So `Decimal("5")` renders "오달러 영센트"
481    ///   here where Base would say "five dollars". Hence the `has_decimal`
482    ///   field of [`CurrencyValue::Decimal`] is intentionally ignored.
483    /// * **`adjective` is accepted and silently discarded.** No
484    ///   `prefix_currency` call exists in KO, so the kwarg is inert.
485    fn to_currency(
486        &self,
487        val: &CurrencyValue,
488        currency: &str,
489        cents: bool,
490        separator: Option<&str>,
491        _adjective: bool,
492    ) -> Result<String> {
493        // Python's `separator=" "` default; `None` means the kwarg was omitted.
494        let separator = separator.unwrap_or(self.default_separator());
495
496        // is_integer_input = isinstance(val, int)
497        let is_integer_input = matches!(val, CurrencyValue::Int(_));
498
499        // decimal_val = Decimal(str(val))
500        // has_fractional_cents = (decimal_val * 100) % 1 != 0
501        //
502        // The literal 100 is Python's, not CURRENCY_PRECISION — see above.
503        // Testing `!= 0` makes truncation-vs-floor irrelevant for negatives:
504        // both leave a zero remainder exactly when the value is integral.
505        let decimal_val: BigDecimal = match val {
506            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
507            CurrencyValue::Decimal { value, .. } => value.clone(),
508        };
509        let scaled = &decimal_val * BigDecimal::from(100);
510        let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
511
512        // parse_currency_parts(val, is_int_with_cents=False,
513        //                      keep_precision=has_fractional_cents)
514        // — divisor left at its 100 default.
515        let (left, right, is_negative) = parse_currency_parts(val, false, has_fractional_cents, 100);
516
517        // try: curr_forms = self.CURRENCY_FORMS[currency] ... except KeyError
518        //
519        // Note the message: KO says `Currency "X" ...`, where Base's
520        // `to_cheque` (and `to_currency`) say `Currency code "X" ...`. Both
521        // spellings are live in this file; do not unify them.
522        let forms = self.currency_forms(currency).ok_or_else(|| {
523            N2WError::NotImplemented(format!(
524                "Currency \"{}\" not implemented for \"{}\"",
525                currency,
526                self.lang_name()
527            ))
528        })?;
529        // major = curr_forms[0] — every KO entry has one, so the fallback is
530        // unreachable; Python would raise IndexError on an empty tuple.
531        let major = forms.unit.first().map(String::as_str).unwrap_or("");
532        // minor = curr_forms[1] if len(curr_forms) > 1 else None
533        let minor: Option<&str> = forms.subunit.first().map(String::as_str);
534
535        // minus_str = self.negword if is_negative else ""  (NOT .strip()'d)
536        let minus_str = if is_negative { self.negword() } else { "" };
537        let money_str = self.to_cardinal(&left)?;
538
539        // Currencies without a minor unit (KRW, JPY).
540        let minor = match minor {
541            None => {
542                // A float carrying cents is rejected outright rather than
543                // rounded down to a whole unit the way Base would. Note the
544                // single quotes around the code — Python's own spelling.
545                if !is_integer_input && right > BigDecimal::zero() {
546                    return Err(N2WError::Value(format!(
547                        "Currency '{}' does not support decimals",
548                        currency
549                    )));
550                }
551                return Ok(format!("{}{}{}", minus_str, money_str, major));
552            }
553            Some(m) => m,
554        };
555
556        // Pure ints never show cents.
557        if is_integer_input {
558            return Ok(format!("{}{}{}", minus_str, money_str, major));
559        }
560
561        // Python: `isinstance(right, Decimal) and has_fractional_cents`.
562        // `keep_precision` *is* `has_fractional_cents`, and it is exactly what
563        // decides whether parse_currency_parts returns a Decimal or an int, so
564        // the isinstance check is redundant with the flag and collapses away.
565        let cents_str = if has_fractional_cents {
566            // cents_str = self.to_cardinal_float(float(right))
567            //
568            // `right` is provably non-integral here: cents = (val - int(val))
569            // * 100 differs from val * 100 only by an integer, so it carries
570            // the same non-zero fraction. That makes the trait's
571            // `cardinal_from_decimal` an exact stand-in — its f64 cast and
572            // repr-derived precision reproduce `to_cardinal_float(float(...))`
573            // step for step.
574            self.cardinal_from_decimal(&right)?
575        } else if cents {
576            // self.to_cardinal(int(right)) if right > 0 else self.to_cardinal(0)
577            // Both arms are to_cardinal(right) since right >= 0; kept split to
578            // mirror the source.
579            let right_int = right.with_scale(0).as_bigint_and_exponent().0;
580            if right_int > BigInt::zero() {
581                self.to_cardinal(&right_int)?
582            } else {
583                self.to_cardinal(&BigInt::zero())?
584            }
585        } else {
586            // str(int(right)) — bare, NOT zero-padded the way Base's
587            // `_cents_terse` would be. 0.01 USD gives "1센트", not "01센트".
588            right.with_scale(0).as_bigint_and_exponent().0.to_string()
589        };
590
591        Ok(format!(
592            "{}{}{}{}{}{}",
593            minus_str, money_str, major, separator, cents_str, minor
594        ))
595    }
596
597    /// `Num2Word_Base.to_cheque` — KO does **not** override it, and it does not
598    /// survive contact with KO's table.
599    ///
600    /// Base opens with a 2-tuple unpack inside a `try`/`except KeyError`:
601    ///
602    /// ```python
603    /// try:
604    ///     cr1, _cr2 = self.CURRENCY_FORMS[currency]
605    /// except KeyError:
606    ///     raise NotImplementedError(...)
607    /// ```
608    ///
609    /// KRW `("원",)` and JPY `("엔",)` are 1-tuples, so the unpack raises
610    /// **ValueError**, which `except KeyError` does not catch — it escapes as
611    /// ValueError, not NotImplementedError. Only USD, the lone 2-tuple, ever
612    /// produces a cheque. The corpus pins this:
613    /// `{"lang": "ko", "to": "cheque:JPY", "arg": "1234.56", "err": "ValueError"}`.
614    ///
615    /// So this override exists purely to reproduce that crash; USD delegates
616    /// straight to the shared Base implementation.
617    ///
618    /// (Base then does `unit = cr1[-1] if isinstance(cr1, tuple) else cr1`.
619    /// KO's `cr1` is a bare `str`, so `unit` is the whole word — `"달러"`, not
620    /// the last *character* `"러"`. `default_to_cheque`'s `unit.last()` over a
621    /// one-element vec lands on the same string.)
622    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
623        let forms = self.currency_forms(currency).ok_or_else(|| {
624            // Base's message — with "code", unlike KO's own to_currency.
625            N2WError::NotImplemented(format!(
626                "Currency code \"{}\" not implemented for \"{}\"",
627                currency,
628                self.lang_name()
629            ))
630        })?;
631        // len(CURRENCY_FORMS[currency]) == 1 -> the unpack cannot fill _cr2.
632        if forms.subunit.is_empty() {
633            return Err(N2WError::Value(
634                "not enough values to unpack (expected 2, got 1)".into(),
635            ));
636        }
637        crate::currency::default_to_cheque(self, val, currency)
638    }
639}