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}