Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 27 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 시에만 적용 (입력 중 간섭 없음) |
Expand Down Expand Up @@ -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
<input data-numkey data-numkey-korean data-numkey-korean-style="formal">
<!-- 1500000 입력 시: <span class="numkey-korean">일백오십만</span> -->
```

`formal`·`hanja`는 문서 금액이 한 덩어리여야 하므로 기본적으로 그룹 사이를
띄우지 않습니다(`spacing: true`로 되돌릴 수 있고, `hangul`·혼합식은 기본
띄움). "금 … 원정", "일금 … 원整" 같은 앞뒤 틀은 서식마다 달라 호출자가
붙입니다. `fromKorean`은 `hangul`·`formal` 출력을 다시 읽을 수 있고, 갖은자
입력은 범위 밖입니다.

역방향도 됩니다 — 부동산·주식 앱에서 실제로 입력하는 축약형을 순수 함수로,
또는 필드에서 직접:

Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
<input data-numkey data-numkey-korean data-numkey-korean-style="formal">
<!-- typing 1500000 renders: <span class="numkey-korean">일백오십만</span> -->
```

`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:

Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,6 +148,18 @@ <h2>Korean amount reading <span class="ko">/ 한글 금액 병기 — 은행 UI
<code>toKorean('927483041001')</code> → <code>9,274억 8,304만 1,001</code>.
</p>

<label>Spelled-out styles — the same amount in every <code>data-numkey-korean-style</code> <span class="ko">/ 계약서·영수증·수표용 완전 표기</span></label>
<input type="text" id="styled" data-numkey value="1500000">
<table class="styles">
<tr><td><code>hangul</code></td><td id="style-hangul"></td><td class="ko">말하듯이 — 안내 문구</td></tr>
<tr><td><code>formal</code></td><td id="style-formal"></td><td class="ko">모든 자리에 일 — 계약서·영수증</td></tr>
<tr><td><code>hanja</code></td><td id="style-hanja"></td><td class="ko">갖은자 — 수표·어음·입금표</td></tr>
</table>
<p style="font-size:0.9rem; margin-bottom:0">
Markup-only: <code>&lt;input data-numkey data-numkey-korean data-numkey-korean-style="formal"&gt;</code>.
The "금 … 원정" frame around the number is yours to add — it differs per document.
</p>

<label>Shorthand entry — try typing <code>3만5천</code> or <code>1.5억</code>, then leave the field · <code>data-numkey-korean-entry</code></label>
<input type="text" data-numkey data-numkey-korean-entry data-numkey-korean placeholder="3만5천">
<p style="font-size:0.9rem; margin-bottom:0">
Expand Down Expand Up @@ -230,6 +248,18 @@ <h2>No build step <span class="ko">/ 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')
Expand Down
32 changes: 32 additions & 0 deletions src/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('壹萬伍仟')
})
})
11 changes: 9 additions & 2 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* <input data-numkey data-numkey-korean> — live "150만" reading in a
* generated <span class="numkey-korean"> after the input (or give the
* attribute a CSS selector to use an existing element)
* <input data-numkey data-numkey-korean data-numkey-korean-style="formal">
* — spell the reading out: hangul "백오십만" / formal "일백오십만" /
* hanja "壹佰伍拾萬" (default mixed "150만")
* <input data-numkey data-numkey-name="amount"> — 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"
Expand Down Expand Up @@ -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]'

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions src/korean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
})
Loading
Loading