From fc0bf9e701594ee16c416c8e8a1be071ee540f4f Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Fri, 17 Jul 2026 12:40:04 +0900 Subject: [PATCH] feat: Korean shorthand parsing (fromKorean / data-numkey-korean-entry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fromKorean(text): the inverse of toKorean, accepting what people actually type into 부동산/주식 apps — '3만5천' → 35000, '1.5억' → 150000000, '삼십오만' → 350000. Digits + big units (만억조경) + sub-units (천백십) + full Hangul digits; commas/spaces/원 are noise; bare unit means 1; plain numbers pass through. Number arithmetic — exact through the money range (~9,000조). data-numkey-korean-entry / koreanEntry option: shorthand entry on the field itself. A Korean draft is left alone while typing (live reformatting would fight the IME and eat units mid-word); blur converts and formats. getValue and the data-numkey-name hidden sync see the parsed value even mid-draft. Real-browser testing caught a money-losing bug the wholesale-value jsdom tests missed: typing '1.5억' char by char on an integer field had the live reformat strip the '.' before 억 arrived, silently reading 15억 — a 100x error. Fix: decimal drafts on integer fields get the same no-touch protection as Korean drafts, and fromKorean results are clamped to the field's decimals budget on settle (bare '1.5' on an integer field blurs to '1', not '15'). Regression tests type char-by-char now. Version 0.3.0, bilingual CHANGELOG/README, demo card. 75 tests. --- CHANGELOG.md | 14 +++++++++ README.ko.md | 24 ++++++++++++++- README.md | 25 ++++++++++++++-- package.json | 2 +- site/index.html | 8 +++++ src/core.ts | 10 ++++++- src/dom.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++ src/dom.ts | 63 ++++++++++++++++++++++++++++++++------- src/index.ts | 2 +- src/korean.test.ts | 53 ++++++++++++++++++++++++++++++++- src/korean.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 321 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d451ecc..d754bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.3.0 (2026-07-17) + +- **`fromKorean(text)`** — Korean-shorthand amount parsing, the inverse of + `toKorean`: `'3만5천'` → `"35000"`, `'1.5억'` → `"150000000"`, + `'삼십오만'` → `"350000"`. Digits + units (만/억/조/경, 천/백/십), full + Hangul digits, decimal units, commas/spaces/"원" ignored, bare unit = 1. + / 만/억 축약 파싱 — 부동산·주식 앱에서 실제로 입력하는 그대로. +- **`data-numkey-korean-entry`** / `koreanEntry` option — accept shorthand + in the field itself: a Korean draft is left alone while typing (no live + reformat fighting the IME) and converted on blur (`3만5천` → `35,000`). + `getValue` and the `data-numkey-name` hidden sync see the parsed value + even mid-draft. / 인풋에서 바로 축약 입력 — 조합 중에는 건드리지 않고 + blur에 변환, hidden 동기화는 초안 상태에서도 파싱된 값 유지. + ## 0.2.0 (2026-07-17) The Korean release. / 한국 특화 릴리스. diff --git a/README.ko.md b/README.ko.md index 319915e..f0a44f7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -78,6 +78,7 @@ numkey는 그 인풋을 한 번에 끝냅니다: | `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) | | `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 | | `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 | +| `data-numkey-korean-entry` | 만/억 축약 입력 허용 ("3만5천" → blur → "35,000") — 아래 참조 | | `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 | ### 로케일 표시 (옵트인) @@ -130,6 +131,26 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — 0인 그룹은 생략 ``` +역방향도 됩니다 — 부동산·주식 앱에서 실제로 입력하는 축약형을 순수 함수로, +또는 필드에서 직접: + +```ts +import { fromKorean } from '@devslab/numkey' + +fromKorean('3만5천') // "35000" +fromKorean('1.5억') // "150000000" +fromKorean('삼십오만') // "350000" +``` + +```html + + +``` + +`getValue`와 `data-numkey-name` hidden 동기화(아래)는 초안이 화면에 있는 +동안에도 파싱된 값을 봅니다. + ### 정식 값으로 전송하기 (`data-numkey-name`) 일반 폼 POST는 필드에 보이는 그대로 — `1,234,567` — 전송하고, 서버는 매번 @@ -218,6 +239,7 @@ const [amount, setAmount] = useState('') | `format(canonical, opts?)` | 정식 값 → 표시 값 `"1,234,567.89"` | | `finalize(canonical)` | 입력 중간 상태 정리 (`"1234."` → `"1234"`) | | `toKorean(canonical, opts?)` | 한글 금액 병기 (`"1500000"` → `"150만"`) | +| `fromKorean(text)` | 만/억 축약 → 정식 값 (`"3만5천"` → `"35000"`) | ### DOM @@ -237,7 +259,7 @@ const [amount, setAmount] = useState('') - 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는 다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다. 구분자 건너뛰기 삭제는 로드맵에 있습니다. -- 로드맵: 만/억 축약 파싱 (`3만5천` → `35000`), 구분자 건너뛰기 삭제. +- 로드맵: 구분자 건너뛰기 삭제. ## License diff --git a/README.md b/README.md index a3afbe8..5fd1231 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,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-entry` | accept Korean shorthand entry ("3만5천" → blur → "35,000") — see below | | `data-numkey-name="amount"` | hidden input posting the canonical value — see below | ### Locale-aware display (opt-in) @@ -133,6 +134,26 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — zero groups omitted ``` +And the inverse — accept the shorthand people actually type into +부동산/주식 apps, either as a pure function or on the field itself: + +```ts +import { fromKorean } from '@devslab/numkey' + +fromKorean('3만5천') // "35000" +fromKorean('1.5억') // "150000000" +fromKorean('삼십오만') // "350000" +``` + +```html + + +``` + +`getValue` and the `data-numkey-name` hidden sync (below) see the parsed +value even while the draft is still on screen. + ### Posting the canonical value (`data-numkey-name`) A plain form POST submits what the field displays — `1,234,567` — which every @@ -221,6 +242,7 @@ const [amount, setAmount] = useState('') | `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만"`) | +| `fromKorean(text)` | Korean shorthand → canonical (`"3만5천"` → `"35000"`) | ### DOM @@ -240,8 +262,7 @@ const [amount, setAmount] = useState('') - Backspacing directly over a separator moves the caret past it (the digit is deleted on the next backspace) — the same behavior as the major masking libraries. Smart separator-skipping deletion is on the roadmap. -- Roadmap: 만/억 shorthand parsing (`3만5천` → `35000`), smart - separator-skipping deletion. +- Roadmap: smart separator-skipping deletion. ## License diff --git a/package.json b/package.json index 6a27781..627f7ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, diff --git a/site/index.html b/site/index.html index 90bccd7..c5f63ed 100644 --- a/site/index.html +++ b/site/index.html @@ -102,6 +102,14 @@

Korean amount reading / 한글 금액 병기 — 은행 UI "원" here comes from CSS ::after. Also a pure function: toKorean('927483041001')9,274억 8,304만 1,001.

+ + + +

+ A Korean draft is left alone while typing (no fight with the IME); blur + converts it — 3만5천35,000. The reading and any + data-numkey-name hidden field see the parsed value even mid-draft. +

diff --git a/src/core.ts b/src/core.ts index 285ea95..e73d248 100644 --- a/src/core.ts +++ b/src/core.ts @@ -29,6 +29,13 @@ export interface NumkeyOptions { * `separator`/`decimalPoint` win over the locale. */ locale?: string + /** + * DOM layer only: accept Korean-shorthand entry ("3만5천"). While the + * value contains Korean number characters the field is left alone (no + * live reformat — that would fight the IME), and blur converts it via + * `fromKorean` ("3만5천" → "35,000"). Default false. + */ + koreanEntry?: boolean } type Resolved = Required @@ -82,7 +89,8 @@ export function resolveOptions(opts?: NumkeyOptions): Resolved { group: opts?.group ?? 3, separator: separator ?? ',', decimalPoint: decimalPoint ?? '.', - locale: opts?.locale ?? '' + locale: opts?.locale ?? '', + koreanEntry: opts?.koreanEntry ?? false } } diff --git a/src/dom.test.ts b/src/dom.test.ts index 4535253..db4e16c 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -223,6 +223,70 @@ describe('data-numkey-name — hidden canonical sync for form POSTs', () => { }) }) +describe('data-numkey-korean-entry — shorthand entry', () => { + it('leaves a Korean draft alone while typing, converts on blur', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '3만5천') + expect(el.value).toBe('3만5천') // untouched — no live reformat mid-word + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('35,000') + }) + + it('plain digits still format live', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '1234567') + expect(el.value).toBe('1,234,567') + }) + + it('getValue and the hidden sync see the parsed value mid-draft', () => { + const el = makeInput({ + 'data-numkey': '', + 'data-numkey-korean-entry': '', + 'data-numkey-name': 'amount' + }) + bind(el) + const hidden = el.nextElementSibling as HTMLInputElement + feed(el, '1.5억') + expect(el.value).toBe('1.5억') // display: still the draft + expect(getValue(el)).toBe('150000000') + expect(hidden.value).toBe('150000000') // posts correctly even pre-blur + }) + + it('without the attribute Korean characters are stripped as before', () => { + const el = makeInput({ 'data-numkey': '' }) + bind(el) + feed(el, '3만5천') + expect(el.value).toBe('35') + }) + + it('typing 1.5억 char by char keeps the decimal until the unit arrives', () => { + // regression: the live reformat used to strip the '.' on an integer + // field before 억 was typed, silently turning 1.5억 into 15억 + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + let v = '' + for (const ch of '1.5억') { + v += ch + feed(el, v) + v = el.value // simulate the browser: next keystroke appends to what's shown + } + expect(el.value).toBe('1.5억') + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('150,000,000') + }) + + it('a bare decimal draft on an integer field truncates on blur', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '1.5') // user stopped before typing the unit + expect(el.value).toBe('1.5') // protected while it could still become 1.5억 + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('1') // integer field: fraction truncated, not "15" + }) +}) + describe('observe — auto-init', () => { it('binds existing and later-added [data-numkey] inputs', async () => { const existing = makeInput({ 'data-numkey': '' }) diff --git a/src/dom.ts b/src/dom.ts index c347d82..90a086f 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -11,6 +11,9 @@ * — 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" + * — accept Korean shorthand: + * typing "3만5천" is left alone until blur, which converts it to + * "35,000" (fromKorean) * * Binding sets `inputmode` (numeric/decimal) and right-aligns the field * unless either is already set, formats any server-rendered value in place, @@ -31,7 +34,7 @@ import { parse, type NumkeyOptions } from './core' -import { toKorean } from './korean' +import { fromKorean, KOREAN_NUMBER_CHARS, toKorean } from './korean' const SELECTOR = 'input[data-numkey]' @@ -46,7 +49,8 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions { const locale = el.getAttribute('data-numkey-locale') const opts: NumkeyOptions = { decimals: main ? parseInt(main, 10) || 0 : 0, - negative: el.hasAttribute('data-numkey-negative') + negative: el.hasAttribute('data-numkey-negative'), + koreanEntry: el.hasAttribute('data-numkey-korean-entry') } if (group) opts.group = parseInt(group, 10) || 3 if (separator !== null) opts.separator = separator @@ -103,9 +107,41 @@ export function finalizeInput( return true } +/** + * Clamp a canonical value to the field's `decimals` budget ("1.5" → "1" on + * an integer field). Needed after `fromKorean`, which can yield fractions + * the field itself would never allow. + */ +function constrain(canonical: string, o: NumkeyOptions): string { + const point = canonical.indexOf('.') + if (point === -1) return canonical + const decimals = o.decimals ?? 0 + if (decimals <= 0) return canonical.slice(0, point) || '0' + const cut = canonical.slice(0, point + 1 + decimals) + return cut.endsWith('.') ? cut.slice(0, -1) : cut +} + +/** + * Whether a value is a Korean-shorthand draft that live reformatting must + * not touch: it contains Korean number chars, or it is a decimal draft on + * an integer field ("1." on the way to "1.5억" — stripping the point before + * the unit arrives would silently turn 1.5억 into 15억). + */ +function isKoreanDraftValue(value: string, o: NumkeyOptions): boolean { + if (!(o.koreanEntry ?? false)) return false + if (KOREAN_NUMBER_CHARS.test(value)) return true + return (o.decimals ?? 0) === 0 && /\d[..]\d*$/.test(value) +} + +/** Canonical value of a raw display value under the given options. */ +function canonicalOf(value: string, o: NumkeyOptions): string { + if (isKoreanDraftValue(value, o)) return constrain(fromKorean(value), o) + return finalize(parse(value, o)) +} + /** The canonical (unformatted, settled) value of a bound input. */ export function getValue(el: HTMLInputElement, opts?: NumkeyOptions): string { - return finalize(parse(el.value, opts ?? optionsFromElement(el))) + return canonicalOf(el.value, opts ?? optionsFromElement(el)) } /** @@ -176,19 +212,28 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { const syncExtras = (o: NumkeyOptions): void => { if (!koreanTarget && !hidden) return - const canonical = finalize(parse(el.value, o)) + const canonical = canonicalOf(el.value, o) if (koreanTarget) koreanTarget.textContent = toKorean(canonical) if (hidden) hidden.value = canonical } - applyToInput(el, initial) - syncExtras(initial) + const settle = (o: NumkeyOptions): void => { + if (isKoreanDraftValue(el.value, o)) { + el.value = format(constrain(fromKorean(el.value), o), o) + syncExtras(o) + } else { + finalizeInput(el, o) + syncExtras(o) + } + } + + settle(initial) // server-rendered value → formatted display let composing = false const run = (): void => { const o = resolve() - applyToInput(el, o) + if (!isKoreanDraftValue(el.value, o)) applyToInput(el, o) syncExtras(o) } @@ -203,9 +248,7 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { if (!composing) run() } const onBlur = (): void => { - const o = resolve() - finalizeInput(el, o) - syncExtras(o) + settle(resolve()) } el.addEventListener('compositionstart', onCompositionStart) diff --git a/src/index.ts b/src/index.ts index ce5a6d7..77987c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { resolveOptions, type NumkeyOptions } from './core' -export { toKorean, type ToKoreanOptions } from './korean' +export { fromKorean, toKorean, type ToKoreanOptions } from './korean' export { applyToInput, bind, diff --git a/src/korean.test.ts b/src/korean.test.ts index 90f797a..6442959 100644 --- a/src/korean.test.ts +++ b/src/korean.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toKorean } from './korean' +import { fromKorean, toKorean } from './korean' describe('toKorean — mixed digits-plus-units reading', () => { it('reads 만-scale amounts the way banking UIs show them', () => { @@ -45,3 +45,54 @@ describe('toKorean — mixed digits-plus-units reading', () => { expect(toKorean('10000000000000000')).toBe('1경') }) }) + +describe('fromKorean — shorthand entry → canonical', () => { + it('parses digits-plus-units shorthand', () => { + expect(fromKorean('3만5천')).toBe('35000') + expect(fromKorean('5천')).toBe('5000') + expect(fromKorean('3백5십')).toBe('350') + expect(fromKorean('1조2억')).toBe('1000200000000') + }) + + it('parses decimal units the way 부동산/주식 apps use them', () => { + expect(fromKorean('1.5억')).toBe('150000000') + expect(fromKorean('0.5만')).toBe('5000') + expect(fromKorean('2.35억')).toBe('235000000') + }) + + it('parses full Hangul digits', () => { + expect(fromKorean('삼만오천')).toBe('35000') + expect(fromKorean('삼십오만')).toBe('350000') + expect(fromKorean('천오백')).toBe('1500') + }) + + it('a bare unit means 1', () => { + expect(fromKorean('만')).toBe('10000') + expect(fromKorean('억')).toBe('100000000') + expect(fromKorean('천')).toBe('1000') + }) + + it('round-trips toKorean output', () => { + for (const v of ['35000', '927483041001', '100000001', '1000050000']) { + expect(fromKorean(toKorean(v))).toBe(v) + } + }) + + it('ignores commas, spaces, and 원; plain numbers pass through', () => { + expect(fromKorean('9,274억 8,304만 1,001')).toBe('927483041001') + expect(fromKorean('3만 5천 원')).toBe('35000') + expect(fromKorean('12,345')).toBe('12345') + }) + + it('keeps the sign and handles empty/zero', () => { + expect(fromKorean('-3만')).toBe('-30000') + expect(fromKorean('−1.5억')).toBe('-150000000') // U+2212 + expect(fromKorean('')).toBe('') + expect(fromKorean('0만')).toBe('0') + expect(fromKorean('영')).toBe('0') + }) + + it('normalizes full-width digits', () => { + expect(fromKorean('3만5천')).toBe('35000') + }) +}) diff --git a/src/korean.ts b/src/korean.ts index af3d078..2d615a2 100644 --- a/src/korean.ts +++ b/src/korean.ts @@ -14,6 +14,37 @@ import { finalize, format } from './core' const UNITS = ['', '만', '억', '조', '경', '해', '자', '양'] +const BIG: Record = { 만: 4, 억: 8, 조: 12, 경: 16 } +const SMALL: Record = { 천: 3, 백: 2, 십: 1 } +const HANGUL_DIGIT: Record = { + 영: '0', 공: '0', 일: '1', 이: '2', 삼: '3', 사: '4', + 오: '5', 육: '6', 칠: '7', 팔: '8', 구: '9' +} + +/** Chars that mark a value as Korean-shorthand entry (used by the DOM layer). */ +export const KOREAN_NUMBER_CHARS = /[만억조경천백십영공일이삼사오육칠팔구]/ + +/** Digits + optional small units within one 만-group: "3십5" → 35, "5천" → 5000. */ +function parseSmallUnits(group: string): number { + let result = 0 + let digits = '' + for (const ch of group) { + if ((ch >= '0' && ch <= '9') || ch === '.') { + digits += ch + } else if (ch in SMALL) { + const n = digits === '' ? 1 : parseFloat(digits) + if (!Number.isNaN(n)) result += n * 10 ** (SMALL[ch] as number) + digits = '' + } + // anything else is noise — dropped, same policy as parse() + } + if (digits !== '') { + const n = parseFloat(digits) + if (!Number.isNaN(n)) result += n + } + return result +} + export interface ToKoreanOptions { /** Space between unit groups ("150만 5,000" vs "150만5,000"). Default true. */ spacing?: boolean @@ -45,3 +76,45 @@ export function toKorean(canonical: string, opts?: ToKoreanOptions): string { } return (neg ? '-' : '') + parts.join(opts?.spacing === false ? '' : ' ') } + +/** + * Korean-shorthand amount → canonical numeric string. The inverse direction + * of `toKorean`, accepting what people actually type into 부동산/주식 apps: + * + * fromKorean('3만5천') // "35000" + * fromKorean('1.5억') // "150000000" + * fromKorean('삼십오만') // "350000" + * fromKorean('9,274억 8,304만 1,001') // "927483041001" + * + * Grammar: optional sign, then descending `[digits][.digits]?` × unit + * segments (만/억/조/경, sub-units 천/백/십, full Hangul digits 일이삼…), + * then optional trailing plain digits. Commas, spaces, and "원" are noise. + * A bare unit means 1 ("만" → 10000). Plain numbers pass through + * ("12,345" → "12345"). Computed with Number arithmetic — exact through the + * money range (안전 한도 ~9,000조); returns '' for empty/unparseable input. + */ +export function fromKorean(text: string): string { + let s = text + .replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48)) + .replace(/[\s,,원]/g, '') + .replace(/[영공일이삼사오육칠팔구]/g, (ch) => HANGUL_DIGIT[ch] as string) + const neg = /^[-−-﹣]/.test(s) + if (neg) s = s.slice(1) + if (s === '') return '' + + let total = 0 + let group = '' + for (const ch of s) { + if (ch in BIG) { + total += (group === '' ? 1 : parseSmallUnits(group)) * 10 ** (BIG[ch] as number) + group = '' + } else { + group += ch + } + } + if (group !== '') total += parseSmallUnits(group) + + if (!Number.isFinite(total)) return '' + if (total === 0) return '0' + return (neg ? '-' : '') + String(total) +}