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)