From 0467d6c1312cc63460bc0732ad664da2d4681fdc Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 8 Nov 2026 22:23:36 +0900 Subject: [PATCH] feat: spelled-out Korean readings (hangul / formal / hanja) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toKorean gains a `style` option next to the existing mixed "150만": - hangul "백오십만" — spoken reading, leading 일 dropped (천오백, 만 오천) but kept before 억 and above (일억, 일조) - formal "일백오십만" — 계약서·영수증: every 일 written, no spaces by default - hanja "壹佰伍拾萬" — 갖은자 anti-forgery numerals (수표·어음·입금표) The DOM hint reads the same choice from `data-numkey-korean-style`. The default is unchanged, unknown values fall back to mixed, and the "금 … 원정" frame stays the caller's business. New exported type KoreanReadingStyle. Demo page shows the three styles side by side; README en/ko and CHANGELOG updated. --- CHANGELOG.md | 13 ++++++ README.ko.md | 28 +++++++++++- README.md | 29 +++++++++++- site/index.html | 30 ++++++++++++ src/dom.test.ts | 32 +++++++++++++ src/dom.ts | 11 ++++- src/index.ts | 2 +- src/korean.test.ts | 97 +++++++++++++++++++++++++++++++++++++++ src/korean.ts | 112 +++++++++++++++++++++++++++++++++++++++++---- 9 files changed, 339 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50fe460..9ce51b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Added + +- **Spelled-out Korean readings** — `toKorean(value, { style })` and + `data-numkey-korean-style` add three spellings next to the existing mixed + "150만": `hangul` ("백오십만", spoken — leading 일 dropped), `formal` + ("일백오십만", contracts/receipts — every 일 written, no spaces), `hanja` + ("壹佰伍拾萬", 갖은자 anti-forgery numerals). The default is unchanged; + unknown style values fall back to it. New exported type `KoreanReadingStyle`. + / 한글 금액 병기에 완전 표기 3종 추가 — `hangul`(백오십만)·`formal`(일백오십만)· + `hanja`(壹佰伍拾萬). 기본값은 기존 "150만" 그대로. + ## 0.6.1 (2026-08-08) Docs-only republish — no code changes. Runnable `examples/svelte` and diff --git a/README.ko.md b/README.ko.md index a6c3eb1..99460e6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -92,6 +92,7 @@ numkey는 그 인풋을 한 번에 끝냅니다: | `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) | | `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 | | `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 | +| `data-numkey-korean-style` | 병기 표기 방식: `hangul`("백오십만")·`formal`("일백오십만")·`hanja`("壹佰伍拾萬"), 기본은 혼합("150만") — 아래 참조 | | `data-numkey-korean-entry` | 만/억 축약 입력 허용 ("3만5천" → blur → "35,000") — 아래 참조 | | `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 | | `data-numkey-min` / `data-numkey-max` | 최소/최대 — blur 시에만 적용 (입력 중 간섭 없음) | @@ -166,6 +167,31 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — 0인 그룹은 생략 ``` +#### 완전 한글·갖은자 표기 (`style`) + +혼합식 "150만"은 은행 UI용이고, 계약서·영수증·수표는 금액을 끝까지 풀어 +써야 합니다. `style`로 표기 방식을 고르고, 기본값은 그대로라 기존 필드는 +여전히 "150만"으로 나옵니다: + +```ts +toKorean('1500000', { style: 'hangul' }) // "백오십만" — 말하듯이: 앞의 일 생략(천오백, 만 오천 — 단 일억은 유지) +toKorean('1500000', { style: 'formal' }) // "일백오십만" — 계약서·영수증: 모든 자리에 일, 띄어쓰기 없음 +toKorean('1500000', { style: 'hanja' }) // "壹佰伍拾萬" — 갖은자(위조 방지 숫자, 수표·어음·입금표) +toKorean('15000', { style: 'formal' }) // "일만오천" +toKorean('0', { style: 'hangul' }) // "영" +``` + +```html + + +``` + +`formal`·`hanja`는 문서 금액이 한 덩어리여야 하므로 기본적으로 그룹 사이를 +띄우지 않습니다(`spacing: true`로 되돌릴 수 있고, `hangul`·혼합식은 기본 +띄움). "금 … 원정", "일금 … 원整" 같은 앞뒤 틀은 서식마다 달라 호출자가 +붙입니다. `fromKorean`은 `hangul`·`formal` 출력을 다시 읽을 수 있고, 갖은자 +입력은 범위 밖입니다. + 역방향도 됩니다 — 부동산·주식 앱에서 실제로 입력하는 축약형을 순수 함수로, 또는 필드에서 직접: @@ -307,7 +333,7 @@ const [amount, setAmount] = createSignal('') // 정식 값 | `parse(display, opts?)` | 표시 값/붙여넣기 → 정식 값 `"1234567.89"` | | `format(canonical, opts?)` | 정식 값 → 표시 값 `"1,234,567.89"` | | `finalize(canonical)` | 입력 중간 상태 정리 (`"1234."` → `"1234"`) | -| `toKorean(canonical, opts?)` | 한글 금액 병기 (`"1500000"` → `"150만"`) | +| `toKorean(canonical, opts?)` | 한글 금액 병기 (`"1500000"` → `"150만"`; `style: 'hangul' \| 'formal' \| 'hanja'`로 완전 표기) | | `fromKorean(text)` | 만/억 축약 → 정식 값 (`"3만5천"` → `"35000"`) | ### DOM diff --git a/README.md b/README.md index bc0ef37..4d9744f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ it does nothing: | `data-numkey-point=","` | decimal mark shown in the field (default `.`) | | `data-numkey-locale` | derive separators from a locale — see below | | `data-numkey-korean` | live Korean amount reading ("150만") — see below | +| `data-numkey-korean-style` | how that reading is spelled: `hangul` ("백오십만"), `formal` ("일백오십만"), `hanja` ("壹佰伍拾萬"); default mixed ("150만") — see below | | `data-numkey-korean-entry` | accept Korean shorthand entry ("3만5천" → blur → "35,000") — see below | | `data-numkey-name="amount"` | hidden input posting the canonical value — see below | | `data-numkey-min` / `data-numkey-max` | bounds, applied on blur only (typing is never interrupted) | @@ -169,6 +170,32 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — zero groups omitted ``` +#### Spelled-out readings (`style`) + +The mixed "150만" is what banking UIs show, but contracts, receipts, and bank +paper need the amount written out in full. `style` picks the spelling; the +default is unchanged, so existing fields keep rendering "150만": + +```ts +toKorean('1500000', { style: 'hangul' }) // "백오십만" — spoken: leading 일 dropped (천오백, 만 오천 — but 일억) +toKorean('1500000', { style: 'formal' }) // "일백오십만" — contracts/receipts: every 일 written, no spaces +toKorean('1500000', { style: 'hanja' }) // "壹佰伍拾萬" — 갖은자 anti-forgery numerals (수표·어음) +toKorean('15000', { style: 'formal' }) // "일만오천" +toKorean('0', { style: 'hangul' }) // "영" +``` + +```html + + +``` + +`formal` and `hanja` default to no spaces between groups because a document +amount is one unbroken token; pass `spacing: true` to opt back in (`hangul` +and mixed space the groups by default). The frame around the number — +"금 … 원정", "일금 … 원整" — differs per document and stays yours to add. +`fromKorean` reads the `hangul` and `formal` output back; 갖은자 input is out +of scope. + And the inverse — accept the shorthand people actually type into 부동산/주식 apps, either as a pure function or on the field itself: @@ -310,7 +337,7 @@ const [amount, setAmount] = createSignal('') // canonical | `parse(display, opts?)` | display/paste mess → canonical `"1234567.89"` | | `format(canonical, opts?)` | canonical → display `"1,234,567.89"` | | `finalize(canonical)` | settle transient typing states (`"1234."` → `"1234"`) | -| `toKorean(canonical, opts?)` | Korean amount reading (`"1500000"` → `"150만"`) | +| `toKorean(canonical, opts?)` | Korean amount reading (`"1500000"` → `"150만"`; `style: 'hangul' \| 'formal' \| 'hanja'` spells it out) | | `fromKorean(text)` | Korean shorthand → canonical (`"3만5천"` → `"35000"`) | ### DOM diff --git a/site/index.html b/site/index.html index 3de20e0..628dea5 100644 --- a/site/index.html +++ b/site/index.html @@ -81,6 +81,12 @@ .canonical code { color: var(--accent); } .numkey-korean { margin-left: 0.6rem; color: var(--accent); font-size: 0.95rem; } .numkey-korean:not(:empty)::after { content: " 원"; color: var(--muted); } + .styles { border-collapse: collapse; margin: 0.5rem 0 0.75rem; font-size: 0.95rem; } + .styles td { padding: 0.25rem 0.9rem 0.25rem 0; vertical-align: baseline; } + .styles td:nth-child(2) { color: var(--accent); word-break: break-all; } + .styles td:nth-child(2):not(:empty)::after { content: "원"; color: var(--muted); } + .styles td:nth-child(3) { white-space: nowrap; } + @media (max-width: 480px) { .styles td:nth-child(3) { display: none; } } pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 0.9rem 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; @@ -142,6 +148,18 @@

Korean amount reading / 한글 금액 병기 — 은행 UI toKorean('927483041001') → 9,274억 8,304만 1,001.

+ + + + + + +
hangul말하듯이 — 안내 문구
formal모든 자리에 일 — 계약서·영수증
hanja갖은자 — 수표·어음·입금표
+

+ Markup-only: <input data-numkey data-numkey-korean data-numkey-korean-style="formal">. + The "금 … 원정" frame around the number is yours to add — it differs per document. +

+

@@ -230,6 +248,18 @@

No build step / JSP·PHP·정적 페이지 — 스크립트 syncLoc() }) + // spelled-out readings: one input, three styles side by side + var styledEl = document.getElementById('styled') + var syncStyles = function () { + var v = numkey.getValue(styledEl) + ;['hangul', 'formal', 'hanja'].forEach(function (style) { + document.getElementById('style-' + style).textContent = numkey.toKorean(v, { style: style }) + }) + } + styledEl.addEventListener('input', syncStyles) + styledEl.addEventListener('blur', syncStyles) + syncStyles() + // show what the data-numkey-name hidden input will POST — computed via // getValue so it doesn't depend on listener ordering vs numkey's own sync var postedEl = document.getElementById('posted') diff --git a/src/dom.test.ts b/src/dom.test.ts index e677f65..e8c3dca 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -429,3 +429,35 @@ describe('observe — auto-init', () => { stop() }) }) + +describe('data-numkey-korean-style — reading style for the hint', () => { + it('defaults to the mixed reading when the attribute is absent', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean': '', value: '1500000' }) + bind(el) + expect(el.nextElementSibling?.textContent).toBe('150만') + }) + + it('renders the chosen style and follows typing', () => { + const el = makeInput({ + 'data-numkey': '', + 'data-numkey-korean': '', + 'data-numkey-korean-style': 'formal', + value: '1500000' + }) + bind(el) + const span = el.nextElementSibling as HTMLElement + expect(span.textContent).toBe('일백오십만') + el.value = '15000' + el.dispatchEvent(new Event('input')) + expect(span.textContent).toBe('일만오천') + }) + + it('hangul and hanja styles reach the hint too', () => { + const a = makeInput({ 'data-numkey': '', 'data-numkey-korean': '', 'data-numkey-korean-style': 'hangul', value: '15000' }) + bind(a) + expect(a.nextElementSibling?.textContent).toBe('만 오천') + const b = makeInput({ 'data-numkey': '', 'data-numkey-korean': '', 'data-numkey-korean-style': 'hanja', value: '15000' }) + bind(b) + expect(b.nextElementSibling?.textContent).toBe('壹萬伍仟') + }) +}) diff --git a/src/dom.ts b/src/dom.ts index 9e5c8de..c687dcf 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -8,6 +8,9 @@ * — live "150만" reading in a * generated after the input (or give the * attribute a CSS selector to use an existing element) + * + * — spell the reading out: hangul "백오십만" / formal "일백오십만" / + * hanja "壹佰伍拾萬" (default mixed "150만") * — keep a generated hidden * input named "amount" in sync with the CANONICAL value, so a classic * form POST submits "1234567" while the field shows "1,234,567" @@ -36,7 +39,7 @@ import { resolveOptions, type NumkeyOptions } from './core' -import { fromKorean, KOREAN_NUMBER_CHARS, toKorean } from './korean' +import { fromKorean, KOREAN_NUMBER_CHARS, toKorean, type KoreanReadingStyle } from './korean' const SELECTOR = 'input[data-numkey]' @@ -261,6 +264,10 @@ export function bind( // data-numkey-korean: live "150만" reading. Empty value → generate a span // right after the input; a value is a selector for an existing element. const koreanAttr = el.getAttribute('data-numkey-korean') + // data-numkey-korean-style: mixed (default) | hangul | formal | hanja. + // Unknown values fall back to mixed inside toKorean — a typo degrades, never throws. + const koreanStyle = (el.getAttribute('data-numkey-korean-style') ?? + undefined) as KoreanReadingStyle | undefined let koreanTarget: Element | null = null let koreanCreated = false if (koreanAttr !== null) { @@ -288,7 +295,7 @@ export function bind( const syncExtras = (o: NumkeyOptions): void => { if (!koreanTarget && !hidden) return const canonical = canonicalOf(el.value, o) - if (koreanTarget) koreanTarget.textContent = toKorean(canonical) + if (koreanTarget) koreanTarget.textContent = toKorean(canonical, { style: koreanStyle }) if (hidden) hidden.value = canonical } diff --git a/src/index.ts b/src/index.ts index a077af1..2eb226a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ export { resolveOptions, type NumkeyOptions } from './core' -export { fromKorean, toKorean, type ToKoreanOptions } from './korean' +export { fromKorean, toKorean, type KoreanReadingStyle, type ToKoreanOptions } from './korean' export { adjustDeleteCaret, applyToInput, diff --git a/src/korean.test.ts b/src/korean.test.ts index 6442959..2571b65 100644 --- a/src/korean.test.ts +++ b/src/korean.test.ts @@ -96,3 +96,100 @@ describe('fromKorean — shorthand entry → canonical', () => { expect(fromKorean('3만5천')).toBe('35000') }) }) + +describe('toKorean — style: "hangul" (spoken reading, leading 일 dropped)', () => { + it('reads every digit and position in Hangul', () => { + expect(toKorean('1500000', { style: 'hangul' })).toBe('백오십만') + expect(toKorean('927483041001', { style: 'hangul' })).toBe( + '구천이백칠십사억 팔천삼백사만 천일' + ) + expect(toKorean('123456789', { style: 'hangul' })).toBe('일억 이천삼백사십오만 육천칠백팔십구') + }) + + it('drops 일 before 십/백/천 and before a bare 만, keeps it before 억 and above', () => { + expect(toKorean('1234', { style: 'hangul' })).toBe('천이백삼십사') + expect(toKorean('10000', { style: 'hangul' })).toBe('만') + expect(toKorean('15000', { style: 'hangul' })).toBe('만 오천') + expect(toKorean('100000000', { style: 'hangul' })).toBe('일억') + expect(toKorean('1000000000000', { style: 'hangul' })).toBe('일조') + expect(toKorean('110000', { style: 'hangul' })).toBe('십일만') + }) + + it('handles single digits, zero, sign, fraction, and zero groups', () => { + expect(toKorean('1', { style: 'hangul' })).toBe('일') + expect(toKorean('0', { style: 'hangul' })).toBe('영') + expect(toKorean('', { style: 'hangul' })).toBe('') + expect(toKorean('-15000', { style: 'hangul' })).toBe('-만 오천') + expect(toKorean('1500000.75', { style: 'hangul' })).toBe('백오십만') + expect(toKorean('100000001', { style: 'hangul' })).toBe('일억 일') + }) + + it('spacing: false joins the groups', () => { + expect(toKorean('1505000', { style: 'hangul', spacing: false })).toBe('백오십만오천') + }) + + it('round-trips through fromKorean', () => { + for (const v of ['1500000', '927483041001', '15000', '110000', '100000001']) { + expect(fromKorean(toKorean(v, { style: 'hangul' }))).toBe(v) + } + }) +}) + +describe('toKorean — style: "formal" (contracts/receipts, every 일 written, no spaces)', () => { + it('writes 일 at every position so nothing can be inserted', () => { + expect(toKorean('1500000', { style: 'formal' })).toBe('일백오십만') + expect(toKorean('15000', { style: 'formal' })).toBe('일만오천') + expect(toKorean('1234', { style: 'formal' })).toBe('일천이백삼십사') + expect(toKorean('110000', { style: 'formal' })).toBe('일십일만') + expect(toKorean('100000001', { style: 'formal' })).toBe('일억일') + expect(toKorean('927483041001', { style: 'formal' })).toBe( + '구천이백칠십사억팔천삼백사만일천일' + ) + }) + + it('spacing: true opts back into group spacing', () => { + expect(toKorean('15000', { style: 'formal', spacing: true })).toBe('일만 오천') + }) + + it('handles zero and sign', () => { + expect(toKorean('0', { style: 'formal' })).toBe('영') + expect(toKorean('-1500000', { style: 'formal' })).toBe('-일백오십만') + }) + + it('round-trips through fromKorean', () => { + for (const v of ['1500000', '927483041001', '15000', '110000']) { + expect(fromKorean(toKorean(v, { style: 'formal' }))).toBe(v) + } + }) +}) + +describe('toKorean — style: "hanja" (갖은자 anti-forgery numerals)', () => { + it('uses 갖은자 for digits and positions, every 壹 written, no spaces', () => { + expect(toKorean('1500000', { style: 'hanja' })).toBe('壹佰伍拾萬') + expect(toKorean('15000', { style: 'hanja' })).toBe('壹萬伍仟') + expect(toKorean('1234', { style: 'hanja' })).toBe('壹仟貳佰參拾肆') + expect(toKorean('927483041001', { style: 'hanja' })).toBe( + '玖仟貳佰柒拾肆億捌仟參佰肆萬壹仟壹' + ) + expect(toKorean('6780', { style: 'hanja' })).toBe('陸仟柒佰捌拾') + }) + + it('handles zero, sign, and spacing opt-in', () => { + expect(toKorean('0', { style: 'hanja' })).toBe('零') + expect(toKorean('-10000', { style: 'hanja' })).toBe('-壹萬') + expect(toKorean('15000', { style: 'hanja', spacing: true })).toBe('壹萬 伍仟') + }) +}) + +describe('toKorean — style fallbacks', () => { + it('"mixed" and an unknown style both give the existing reading', () => { + expect(toKorean('1500000', { style: 'mixed' })).toBe('150만') + // @ts-expect-error — unknown styles fall back rather than throw (DOM attr typos) + expect(toKorean('1500000', { style: 'nope' })).toBe('150만') + }) + + it('falls back to plain grouping beyond the known units in every style', () => { + const huge = '1' + '0'.repeat(32) // 10^32 — past 양 + expect(toKorean(huge, { style: 'hangul' })).toBe(toKorean(huge)) + }) +}) diff --git a/src/korean.ts b/src/korean.ts index 2d615a2..9426a14 100644 --- a/src/korean.ts +++ b/src/korean.ts @@ -1,14 +1,23 @@ /** * Korean amount reading — the "150만" hint that banking/fintech UIs render - * next to amount inputs, in the mixed digits-plus-units style: + * next to amount inputs, plus the fully spelled-out readings that contracts, + * receipts, and bank paper use. One function, four `style`s: * - * toKorean('1500000') // "150만" - * toKorean('927483041001') // "9,274억 8,304만 1,001" + * toKorean('1500000') // "150만" mixed (default) + * toKorean('1500000', { style: 'hangul' }) // "백오십만" spoken reading + * toKorean('1500000', { style: 'formal' }) // "일백오십만" 계약서·영수증 + * toKorean('1500000', { style: 'hanja' }) // "壹佰伍拾萬" 갖은자 (수표·어음) + * toKorean('927483041001') // "9,274억 8,304만 1,001" * - * Digits are grouped by 만 (10^4); zero groups are omitted; each group keeps - * its own 3-digit comma grouping. The reading covers the INTEGER part only - * (a fraction, rare on 금액 fields, is ignored). Append "원" (or style it in - * via CSS ::after) yourself — the unit of account is the caller's business. + * Digits are grouped by 만 (10^4); zero groups are omitted. `mixed` keeps + * each group as comma-grouped digits; the other styles spell every position + * (천·백·십) and digit. `hangul` drops the leading 일 the way people speak + * (천오백, 만 오천 — but 일억, 일조); `formal` and `hanja` write every 일/壹 + * because their job is to leave no gap a forger could fill, and for the + * same reason they default to no spaces between groups. The reading covers + * the INTEGER part only (a fraction, rare on 금액 fields, is ignored). + * Append "원" / "금 … 원정" yourself — the unit of account and the document + * frame are the caller's business. */ import { finalize, format } from './core' @@ -45,21 +54,100 @@ function parseSmallUnits(group: string): number { return result } +/** + * How the reading is spelled. + * + * - `mixed` — digits + units, "150만" (default; what banking UIs show) + * - `hangul` — spoken Hangul, "백오십만" (leading 일 dropped as in speech) + * - `formal` — document Hangul, "일백오십만" (every 일 written; 계약서·영수증) + * - `hanja` — 갖은자, "壹佰伍拾萬" (anti-forgery numerals; 수표·어음·입금표) + */ +export type KoreanReadingStyle = 'mixed' | 'hangul' | 'formal' | 'hanja' + export interface ToKoreanOptions { - /** Space between unit groups ("150만 5,000" vs "150만5,000"). Default true. */ + /** Reading style. Default `'mixed'`; unknown values fall back to it. */ + style?: KoreanReadingStyle + /** + * Space between unit groups ("150만 5,000" vs "150만5,000"). Default true + * for `mixed` and `hangul`, false for `formal` and `hanja` (document styles + * are written as one unbroken token). + */ spacing?: boolean } -/** Canonical numeric string → mixed Korean unit reading. */ +/** Spelling tables for the spelled-out styles: digits 1–9, positions 십백천, big units. */ +interface Spelling { + digits: readonly string[] // index 1..9 + small: readonly string[] // ['', 십, 백, 천] + big: readonly string[] // ['', 만, 억, …] + zero: string + /** Drop the digit 1 before 십/백/천 and before a bare 만 (spoken reading). */ + dropOne: boolean + /** Default group spacing when the caller does not say. */ + spacing: boolean +} + +const HANGUL_DIGITS = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'] +const HANGUL_SMALL = ['', '십', '백', '천'] + +const SPELLINGS: Record, Spelling> = { + hangul: { digits: HANGUL_DIGITS, small: HANGUL_SMALL, big: UNITS, zero: '영', dropOne: true, spacing: true }, + formal: { digits: HANGUL_DIGITS, small: HANGUL_SMALL, big: UNITS, zero: '영', dropOne: false, spacing: false }, + hanja: { + digits: ['', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖'], + small: ['', '拾', '佰', '仟'], + big: ['', '萬', '億', '兆', '京', '垓', '秭', '穰'], + zero: '零', + dropOne: false, + spacing: false + } +} + +/** + * Spell one 만-group (up to 4 digits, value > 0) position by position. + * `dropOne` omits the 일 before 십/백/천 ("천오백" not "일천오백"); the lone + * 일 in the ones position is always written ("십일", "일"). + */ +function spellGroup(group: string, sp: Spelling): string { + let out = '' + const n = group.length + for (let i = 0; i < n; i++) { + const d = group.charCodeAt(i) - 48 + if (d === 0) continue + const pos = n - 1 - i // 0 = ones, 1 = 십, 2 = 백, 3 = 천 + if (!(d === 1 && pos > 0 && sp.dropOne)) out += sp.digits[d] + out += sp.small[pos] + } + return out +} + +/** Spelled-out reading for the hangul/formal/hanja styles. */ +function spellOut(groups: string[], neg: boolean, sp: Spelling, spacing: boolean): string { + const parts: string[] = [] + for (let i = 0; i < groups.length; i++) { + const group = groups[i] as string + if (/^0+$/.test(group)) continue + const unitIndex = groups.length - 1 - i + // spoken reading says "만" for exactly 1만, but "일억", "일조" above it + const bareMan = sp.dropOne && unitIndex === 1 && parseInt(group, 10) === 1 + parts.push((bareMan ? '' : spellGroup(group, sp)) + sp.big[unitIndex]) + } + return (neg ? '-' : '') + parts.join(spacing ? ' ' : '') +} + +/** Canonical numeric string → Korean amount reading in the chosen style. */ export function toKorean(canonical: string, opts?: ToKoreanOptions): string { const settled = finalize(canonical) if (settled === '') return '' + const style = opts?.style + const spelling = style !== undefined && style !== 'mixed' ? SPELLINGS[style] : undefined + const neg = settled.startsWith('-') let int = neg ? settled.slice(1) : settled const point = int.indexOf('.') if (point !== -1) int = int.slice(0, point) - if (int === '' || /^0*$/.test(int)) return '0' + if (int === '' || /^0*$/.test(int)) return spelling ? spelling.zero : '0' // split from the right into 만-groups of 4 digits const groups: string[] = [] @@ -68,6 +156,10 @@ export function toKorean(canonical: string, opts?: ToKoreanOptions): string { } if (groups.length > UNITS.length) return format(settled) // beyond 양 (10^32) + if (spelling) { + return spellOut(groups, neg, spelling, opts?.spacing ?? spelling.spacing) + } + const parts: string[] = [] for (let i = 0; i < groups.length; i++) { const value = parseInt(groups[i] as string, 10)