hangul-toolkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/index.cjs +480 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +434 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hangul-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# hangul-toolkit
|
|
2
|
+
|
|
3
|
+
한국어 텍스트 처리에 필요한 것들을 한 곳에 모은 유틸리티 라이브러리.
|
|
4
|
+
A toolkit of Korean (Hangul) text utilities for JavaScript/TypeScript.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/hangul-toolkit)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## 왜 hangul-toolkit인가 / Why
|
|
10
|
+
|
|
11
|
+
한국어 서비스를 만들다 본면 매번 필요한데, 매번 직접 구현하게 되는 것들:
|
|
12
|
+
|
|
13
|
+
- **조사 선택** — "사과를" vs "사과을"? 받침 유무에 따라 은/는, 이/가, 을/를을 자동 선택
|
|
14
|
+
- **자소 분해·조합** — 검색어 자동완성, 초성 검색의 기반
|
|
15
|
+
- **숫자 → 한글** — 금액 표기, 문서 생성
|
|
16
|
+
- **개인정보 마스킹** — **LLM API에 프롬프트를 별내기 전** 주민등록번호·전화번호·카드번호·이메일 자동 마스킹
|
|
17
|
+
- **번호 유효성 검증** — 주민등록번호, 사업자등록번호 체크섬 검증
|
|
18
|
+
|
|
19
|
+
Common Korean-text problems that developers keep re-implementing: particle (josa)
|
|
20
|
+
selection, syllable disassembly for search/autocomplete, number-to-Korean conversion,
|
|
21
|
+
**PII masking before sending prompts to LLM APIs**, and checksum validation for
|
|
22
|
+
Korean identification numbers.
|
|
23
|
+
|
|
24
|
+
## 설치 / Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install hangul-toolkit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Node.js 18+ / ESM·CommonJS·TypeScript 모두 지원합니다.
|
|
31
|
+
|
|
32
|
+
## 빠른 시작 / Quick Start
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { josa, disassemble, assemble, numberToKorean, formatWon, maskPII, detectPII } from 'hangul-toolkit';
|
|
36
|
+
|
|
37
|
+
// 조사 자동 선택
|
|
38
|
+
josa('사과', '을/를'); // '사과를'
|
|
39
|
+
josa('책', '을/를'); // '책을'
|
|
40
|
+
josa('서울', '으로/로'); // '서울로' (ㄹ 받침 특수 규칙)
|
|
41
|
+
josa('부산', '으로/로'); // '부산으로'
|
|
42
|
+
josa('3', '이/가'); // '3이' (숫자는 발음 기준)
|
|
43
|
+
|
|
44
|
+
// 자소 분해 / 조합 (초성 검색의 기반)
|
|
45
|
+
disassemble('한글'); // 'ㅎㅏㄴㄱㅡㄹ'
|
|
46
|
+
assemble('ㄱㅏㅂㅅ'); // '값'
|
|
47
|
+
assemble(disassemble('안녕하세요')); // '안녕하세요' (왕복 변환 보장)
|
|
48
|
+
|
|
49
|
+
// 숫자 → 한글
|
|
50
|
+
numberToKorean(123456789); // '일억이천삼백사십오만육천칠백팔십구'
|
|
51
|
+
formatWon(50000); // '오만원'
|
|
52
|
+
|
|
53
|
+
// 개인정보 마스킹 (LLM 프롬프트 전처리)
|
|
54
|
+
maskPII('홍길동 010-1234-5678 hong@test.com');
|
|
55
|
+
// '홍길동 ***-****-**** ****@****.***'
|
|
56
|
+
maskPII('카드 1234-5678-9012-3456', { keepLast: 4 });
|
|
57
|
+
// '카드 ****-****-****-3456'
|
|
58
|
+
detectPII('연락처 010-1234-5678');
|
|
59
|
+
// [{ type: 'phone', value: '010-1234-5678', start: 4, end: 17 }]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API 요약 / API
|
|
63
|
+
|
|
64
|
+
### 조사 / Particles
|
|
65
|
+
| 함수 | 설명 |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `josa(word, '은/는')` | 단어에 올바른 조사를 붙여 반환 |
|
|
68
|
+
| `pickJosa(word, '은/는')` | 조사만 반환 |
|
|
69
|
+
| `supportedJosaPairs()` | 지원 조사 쌍 목록 (은/는, 이/가, 을/를, 과/와, 으로/로, 이나/나, 이랑/랑, 아/야, 이에요/예요 등 16종) |
|
|
70
|
+
|
|
71
|
+
### 음절 / Syllables
|
|
72
|
+
| 함수 | 설명 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `decompose('한')` | `{ choseong, jungseong, jongseong }` |
|
|
75
|
+
| `compose('ㅎ','ㅏ','ㄴ')` | `'한'` |
|
|
76
|
+
| `disassemble(text)` | 문자열을 자모 나열로 |
|
|
77
|
+
| `assemble(jamo)` | 자모 나열을 문자열로 |
|
|
78
|
+
| `hasBatchim(char)` | 받침 여부 |
|
|
79
|
+
| `isHangulSyllable(char)` | 한글 음절 여부 |
|
|
80
|
+
|
|
81
|
+
### 숫자 / Numbers
|
|
82
|
+
| 함수 | 설명 |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `numberToKorean(n)` | `123456789` → `'일억이천삼백사십오만육천칠백팔십구'` |
|
|
85
|
+
| `formatWon(n)` | `50000` → `'오만원'` |
|
|
86
|
+
|
|
87
|
+
### 개인정보 / PII
|
|
88
|
+
| 함수 | 설명 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `detectPII(text, types?)` | PII 탐지 (유형·위치 반환) |
|
|
91
|
+
| `maskPII(text, { types?, maskChar?, keepLast? })` | PII 마스킹 (구분자 `-`, 공백 유지) |
|
|
92
|
+
| `maskName('김철수')` | `'김*수'` |
|
|
93
|
+
|
|
94
|
+
기본 탐지 유형: 주민등록번호(`rrn`), 전화번호(`phone`), 이메일(`email`), 신용카드(`card`), 운전면허번호(`driverLicense`). 계좌번호(`account`)는 오탐 가능성이 있어 기본 비활성이며 `types` 옵션으로 켤 수 있습니다.
|
|
95
|
+
|
|
96
|
+
### 검증 / Validation
|
|
97
|
+
| 함수 | 설명 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `isValidRRN(input)` | 주민등록번호 체크섬 검증 |
|
|
100
|
+
| `isValidBusinessNumber(input)` | 사업자등록번호 체크섬 검증 |
|
|
101
|
+
| `isValidPhoneNumber(input)` | 한국 전화번호 형식 검증 |
|
|
102
|
+
|
|
103
|
+
## 사용 사례: LLM 프롬프트 전처리 / Use case: LLM prompt preprocessing
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { maskPII } from 'hangul-toolkit';
|
|
107
|
+
|
|
108
|
+
const userPrompt = '내 번호가 010-1234-5678인데 요금제 문의할게';
|
|
109
|
+
const safe = maskPII(userPrompt, { keepLast: 4 });
|
|
110
|
+
// '내 번호가 ***-****-5678인데 요금제 문의할게'
|
|
111
|
+
await llm.chat(safe);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 기여 / Contributing
|
|
115
|
+
|
|
116
|
+
이슈와 PR을 환영합니다. 큰 변경은 먼저 이슈로 논의해 주세요.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm install
|
|
120
|
+
npm test # vitest
|
|
121
|
+
npm run build # tsup (ESM + CJS + d.ts)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
아이디어: 한국 공휴일, 초성 검색 헬퍼, 한글→로마자 표기, 존칭/반말 변환 등 — 언제든 제안해 주세요.
|
|
125
|
+
|
|
126
|
+
## 라이선스 / License
|
|
127
|
+
|
|
128
|
+
[MIT](LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CHOSEONG_LIST: () => CHOSEONG_LIST,
|
|
24
|
+
JONGSEONG_LIST: () => JONGSEONG_LIST,
|
|
25
|
+
JUNGSEONG_LIST: () => JUNGSEONG_LIST,
|
|
26
|
+
assemble: () => assemble,
|
|
27
|
+
compose: () => compose,
|
|
28
|
+
decompose: () => decompose,
|
|
29
|
+
detectPII: () => detectPII,
|
|
30
|
+
disassemble: () => disassemble,
|
|
31
|
+
formatWon: () => formatWon,
|
|
32
|
+
hasBatchim: () => hasBatchim,
|
|
33
|
+
isHangulSyllable: () => isHangulSyllable,
|
|
34
|
+
isValidBusinessNumber: () => isValidBusinessNumber,
|
|
35
|
+
isValidPhoneNumber: () => isValidPhoneNumber,
|
|
36
|
+
isValidRRN: () => isValidRRN,
|
|
37
|
+
josa: () => josa,
|
|
38
|
+
maskName: () => maskName,
|
|
39
|
+
maskPII: () => maskPII,
|
|
40
|
+
numberToKorean: () => numberToKorean,
|
|
41
|
+
pickJosa: () => pickJosa,
|
|
42
|
+
supportedJosaPairs: () => supportedJosaPairs
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(index_exports);
|
|
45
|
+
|
|
46
|
+
// src/syllable.ts
|
|
47
|
+
var HANGUL_BASE = 44032;
|
|
48
|
+
var CHOSEONG_COUNT = 19;
|
|
49
|
+
var JUNGSEONG_COUNT = 21;
|
|
50
|
+
var JONGSEONG_COUNT = 28;
|
|
51
|
+
var CHOSEONG_LIST = [
|
|
52
|
+
"\u3131",
|
|
53
|
+
"\u3132",
|
|
54
|
+
"\u3134",
|
|
55
|
+
"\u3137",
|
|
56
|
+
"\u3138",
|
|
57
|
+
"\u3139",
|
|
58
|
+
"\u3141",
|
|
59
|
+
"\u3142",
|
|
60
|
+
"\u3143",
|
|
61
|
+
"\u3145",
|
|
62
|
+
"\u3146",
|
|
63
|
+
"\u3147",
|
|
64
|
+
"\u3148",
|
|
65
|
+
"\u3149",
|
|
66
|
+
"\u314A",
|
|
67
|
+
"\u314B",
|
|
68
|
+
"\u314C",
|
|
69
|
+
"\u314D",
|
|
70
|
+
"\u314E"
|
|
71
|
+
];
|
|
72
|
+
var JUNGSEONG_LIST = [
|
|
73
|
+
"\u314F",
|
|
74
|
+
"\u3150",
|
|
75
|
+
"\u3151",
|
|
76
|
+
"\u3152",
|
|
77
|
+
"\u3153",
|
|
78
|
+
"\u3154",
|
|
79
|
+
"\u3155",
|
|
80
|
+
"\u3156",
|
|
81
|
+
"\u3157",
|
|
82
|
+
"\u3158",
|
|
83
|
+
"\u3159",
|
|
84
|
+
"\u315A",
|
|
85
|
+
"\u315B",
|
|
86
|
+
"\u315C",
|
|
87
|
+
"\u315D",
|
|
88
|
+
"\u315E",
|
|
89
|
+
"\u315F",
|
|
90
|
+
"\u3160",
|
|
91
|
+
"\u3161",
|
|
92
|
+
"\u3162",
|
|
93
|
+
"\u3163"
|
|
94
|
+
];
|
|
95
|
+
var JONGSEONG_LIST = [
|
|
96
|
+
"",
|
|
97
|
+
"\u3131",
|
|
98
|
+
"\u3132",
|
|
99
|
+
"\u3133",
|
|
100
|
+
"\u3134",
|
|
101
|
+
"\u3135",
|
|
102
|
+
"\u3136",
|
|
103
|
+
"\u3137",
|
|
104
|
+
"\u3139",
|
|
105
|
+
"\u313A",
|
|
106
|
+
"\u313B",
|
|
107
|
+
"\u313C",
|
|
108
|
+
"\u313D",
|
|
109
|
+
"\u313E",
|
|
110
|
+
"\u313F",
|
|
111
|
+
"\u3140",
|
|
112
|
+
"\u3141",
|
|
113
|
+
"\u3142",
|
|
114
|
+
"\u3144",
|
|
115
|
+
"\u3145",
|
|
116
|
+
"\u3146",
|
|
117
|
+
"\u3147",
|
|
118
|
+
"\u3148",
|
|
119
|
+
"\u314A",
|
|
120
|
+
"\u314B",
|
|
121
|
+
"\u314C",
|
|
122
|
+
"\u314D",
|
|
123
|
+
"\u314E"
|
|
124
|
+
];
|
|
125
|
+
var CHO_SET = new Set(CHOSEONG_LIST);
|
|
126
|
+
var JUNG_SET = new Set(JUNGSEONG_LIST);
|
|
127
|
+
var JONG_SET = new Set(JONGSEONG_LIST.filter((j) => j !== ""));
|
|
128
|
+
var COMPLEX_JUNGSEONG = {
|
|
129
|
+
"\u3158": "\u3157\u314F",
|
|
130
|
+
"\u3159": "\u3157\u3150",
|
|
131
|
+
"\u315A": "\u3157\u3163",
|
|
132
|
+
"\u315D": "\u315C\u3153",
|
|
133
|
+
"\u315E": "\u315C\u3154",
|
|
134
|
+
"\u315F": "\u315C\u3163",
|
|
135
|
+
"\u3162": "\u3161\u3163"
|
|
136
|
+
};
|
|
137
|
+
var COMPLEX_JONGSEONG = {
|
|
138
|
+
"\u3133": "\u3131\u3145",
|
|
139
|
+
"\u3135": "\u3134\u3148",
|
|
140
|
+
"\u3136": "\u3134\u314E",
|
|
141
|
+
"\u313A": "\u3139\u3131",
|
|
142
|
+
"\u313B": "\u3139\u3141",
|
|
143
|
+
"\u313C": "\u3139\u3142",
|
|
144
|
+
"\u313D": "\u3139\u3145",
|
|
145
|
+
"\u313E": "\u3139\u314C",
|
|
146
|
+
"\u313F": "\u3139\u314D",
|
|
147
|
+
"\u3140": "\u3139\u314E",
|
|
148
|
+
"\u3144": "\u3142\u3145"
|
|
149
|
+
};
|
|
150
|
+
var REVERSE_JUNGSEONG = Object.fromEntries(
|
|
151
|
+
Object.entries(COMPLEX_JUNGSEONG).map(([k, v]) => [v, k])
|
|
152
|
+
);
|
|
153
|
+
var REVERSE_JONGSEONG = Object.fromEntries(
|
|
154
|
+
Object.entries(COMPLEX_JONGSEONG).map(([k, v]) => [v, k])
|
|
155
|
+
);
|
|
156
|
+
function isHangulSyllable(char) {
|
|
157
|
+
if (!char || char.length !== 1) return false;
|
|
158
|
+
const code = char.charCodeAt(0);
|
|
159
|
+
return code >= HANGUL_BASE && code < HANGUL_BASE + CHOSEONG_COUNT * JUNGSEONG_COUNT * JONGSEONG_COUNT;
|
|
160
|
+
}
|
|
161
|
+
function decompose(char) {
|
|
162
|
+
if (!isHangulSyllable(char)) {
|
|
163
|
+
throw new Error(`decompose: '${char}' is not a Hangul syllable`);
|
|
164
|
+
}
|
|
165
|
+
const offset = char.charCodeAt(0) - HANGUL_BASE;
|
|
166
|
+
const choseongIndex = Math.floor(offset / (JUNGSEONG_COUNT * JONGSEONG_COUNT));
|
|
167
|
+
const jungseongIndex = Math.floor(offset % (JUNGSEONG_COUNT * JONGSEONG_COUNT) / JONGSEONG_COUNT);
|
|
168
|
+
const jongseongIndex = offset % JONGSEONG_COUNT;
|
|
169
|
+
return {
|
|
170
|
+
choseong: CHOSEONG_LIST[choseongIndex],
|
|
171
|
+
jungseong: JUNGSEONG_LIST[jungseongIndex],
|
|
172
|
+
jongseong: jongseongIndex === 0 ? null : JONGSEONG_LIST[jongseongIndex]
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function compose(choseong, jungseong, jongseong) {
|
|
176
|
+
const choIndex = CHOSEONG_LIST.indexOf(choseong);
|
|
177
|
+
const jungIndex = JUNGSEONG_LIST.indexOf(jungseong);
|
|
178
|
+
const jongIndex = jongseong ? JONGSEONG_LIST.indexOf(jongseong) : 0;
|
|
179
|
+
if (choIndex < 0 || jungIndex < 0 || jongIndex < 0) {
|
|
180
|
+
throw new Error(`compose: invalid jamo combination (${choseong}, ${jungseong}, ${jongseong ?? ""})`);
|
|
181
|
+
}
|
|
182
|
+
return String.fromCharCode(HANGUL_BASE + (choIndex * JUNGSEONG_COUNT + jungIndex) * JONGSEONG_COUNT + jongIndex);
|
|
183
|
+
}
|
|
184
|
+
function hasBatchim(char) {
|
|
185
|
+
if (!char) return false;
|
|
186
|
+
const last = char[char.length - 1];
|
|
187
|
+
if (!isHangulSyllable(last)) return false;
|
|
188
|
+
return (last.charCodeAt(0) - HANGUL_BASE) % JONGSEONG_COUNT !== 0;
|
|
189
|
+
}
|
|
190
|
+
function disassemble(text) {
|
|
191
|
+
let result = "";
|
|
192
|
+
for (const char of text) {
|
|
193
|
+
if (!isHangulSyllable(char)) {
|
|
194
|
+
result += char;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { choseong, jungseong, jongseong } = decompose(char);
|
|
198
|
+
result += choseong;
|
|
199
|
+
result += COMPLEX_JUNGSEONG[jungseong] ?? jungseong;
|
|
200
|
+
if (jongseong) {
|
|
201
|
+
result += COMPLEX_JONGSEONG[jongseong] ?? jongseong;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
function assemble(jamo) {
|
|
207
|
+
const chars = [...jamo];
|
|
208
|
+
let result = "";
|
|
209
|
+
let i = 0;
|
|
210
|
+
while (i < chars.length) {
|
|
211
|
+
const cho = chars[i];
|
|
212
|
+
if (!CHO_SET.has(cho)) {
|
|
213
|
+
result += cho;
|
|
214
|
+
i += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let jung = "";
|
|
218
|
+
let jungLen = 0;
|
|
219
|
+
const v1 = chars[i + 1] ?? "";
|
|
220
|
+
const v2 = chars[i + 2] ?? "";
|
|
221
|
+
if (v1 && v2 && REVERSE_JUNGSEONG[v1 + v2]) {
|
|
222
|
+
jung = REVERSE_JUNGSEONG[v1 + v2];
|
|
223
|
+
jungLen = 2;
|
|
224
|
+
} else if (v1 && JUNG_SET.has(v1)) {
|
|
225
|
+
jung = v1;
|
|
226
|
+
jungLen = 1;
|
|
227
|
+
}
|
|
228
|
+
if (!jung) {
|
|
229
|
+
result += cho;
|
|
230
|
+
i += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
let jong;
|
|
234
|
+
let jongLen = 0;
|
|
235
|
+
const c1 = chars[i + 1 + jungLen] ?? "";
|
|
236
|
+
const c2 = chars[i + 2 + jungLen] ?? "";
|
|
237
|
+
const afterCompound = chars[i + 3 + jungLen] ?? "";
|
|
238
|
+
if (c1 && c2 && REVERSE_JONGSEONG[c1 + c2] && !JUNG_SET.has(afterCompound)) {
|
|
239
|
+
jong = REVERSE_JONGSEONG[c1 + c2];
|
|
240
|
+
jongLen = 2;
|
|
241
|
+
} else if (c1 && JONG_SET.has(c1) && !JUNG_SET.has(c2)) {
|
|
242
|
+
jong = c1;
|
|
243
|
+
jongLen = 1;
|
|
244
|
+
}
|
|
245
|
+
result += compose(cho, jung, jong);
|
|
246
|
+
i += 1 + jungLen + jongLen;
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/josa.ts
|
|
252
|
+
var JOSA_PAIRS = {
|
|
253
|
+
"\uC740/\uB294": ["\uC740", "\uB294"],
|
|
254
|
+
"\uC774/\uAC00": ["\uC774", "\uAC00"],
|
|
255
|
+
"\uC744/\uB97C": ["\uC744", "\uB97C"],
|
|
256
|
+
"\uACFC/\uC640": ["\uACFC", "\uC640"],
|
|
257
|
+
"\uC73C\uB85C/\uB85C": ["\uC73C\uB85C", "\uB85C"],
|
|
258
|
+
"\uC774\uB098/\uB098": ["\uC774\uB098", "\uB098"],
|
|
259
|
+
"\uC774\uB791/\uB791": ["\uC774\uB791", "\uB791"],
|
|
260
|
+
"\uC544/\uC57C": ["\uC544", "\uC57C"],
|
|
261
|
+
"\uC774\uC5EC/\uC5EC": ["\uC774\uC5EC", "\uC5EC"],
|
|
262
|
+
"\uC774\uC5D0\uC694/\uC608\uC694": ["\uC774\uC5D0\uC694", "\uC608\uC694"],
|
|
263
|
+
"\uC774\uC5C8/\uC600": ["\uC774\uC5C8", "\uC600"],
|
|
264
|
+
"\uC774\uACE0/\uACE0": ["\uC774\uACE0", "\uACE0"],
|
|
265
|
+
"\uC774\uB4E0/\uB4E0": ["\uC774\uB4E0", "\uB4E0"],
|
|
266
|
+
"\uC774\uC57C\uB9D0\uB85C/\uC57C\uB9D0\uB85C": ["\uC774\uC57C\uB9D0\uB85C", "\uC57C\uB9D0\uB85C"],
|
|
267
|
+
"\uC774\uB098\uB9C8/\uB098\uB9C8": ["\uC774\uB098\uB9C8", "\uB098\uB9C8"],
|
|
268
|
+
"\uC774\uC57C/\uC57C": ["\uC774\uC57C", "\uC57C"]
|
|
269
|
+
};
|
|
270
|
+
var DIGIT_HAS_BATCHIM = [true, true, false, true, false, false, true, true, true, false];
|
|
271
|
+
var DIGIT_ENDS_WITH_RIEUL = [false, true, false, false, false, false, false, true, true, false];
|
|
272
|
+
function lastCharInfo(word) {
|
|
273
|
+
const last = word[word.length - 1];
|
|
274
|
+
if (last >= "0" && last <= "9") {
|
|
275
|
+
const d = last.charCodeAt(0) - 48;
|
|
276
|
+
return { hasBatchim: DIGIT_HAS_BATCHIM[d], endsWithRieul: DIGIT_ENDS_WITH_RIEUL[d] };
|
|
277
|
+
}
|
|
278
|
+
if (isHangulSyllable(last)) {
|
|
279
|
+
const jongIndex = (last.charCodeAt(0) - 44032) % 28;
|
|
280
|
+
return {
|
|
281
|
+
hasBatchim: jongIndex !== 0,
|
|
282
|
+
endsWithRieul: jongIndex === 8
|
|
283
|
+
// 'ㄹ'
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { hasBatchim: false, endsWithRieul: false };
|
|
287
|
+
}
|
|
288
|
+
function pickJosa(word, pair) {
|
|
289
|
+
const particles = JOSA_PAIRS[pair];
|
|
290
|
+
if (!particles) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`pickJosa: unsupported particle pair '${pair}'. Supported: ${Object.keys(JOSA_PAIRS).join(", ")}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!word) return particles[1];
|
|
296
|
+
const { hasBatchim: bat, endsWithRieul } = lastCharInfo(word);
|
|
297
|
+
if (pair === "\uC73C\uB85C/\uB85C") {
|
|
298
|
+
return !bat || endsWithRieul ? "\uB85C" : "\uC73C\uB85C";
|
|
299
|
+
}
|
|
300
|
+
return bat ? particles[0] : particles[1];
|
|
301
|
+
}
|
|
302
|
+
function josa(word, pair) {
|
|
303
|
+
const text = String(word);
|
|
304
|
+
return text + pickJosa(text, pair);
|
|
305
|
+
}
|
|
306
|
+
function supportedJosaPairs() {
|
|
307
|
+
return Object.keys(JOSA_PAIRS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/number.ts
|
|
311
|
+
var DIGITS = ["", "\uC77C", "\uC774", "\uC0BC", "\uC0AC", "\uC624", "\uC721", "\uCE60", "\uD314", "\uAD6C"];
|
|
312
|
+
var SMALL_UNITS = ["", "\uC2ED", "\uBC31", "\uCC9C"];
|
|
313
|
+
var BIG_UNITS = ["", "\uB9CC", "\uC5B5", "\uC870", "\uACBD", "\uD574"];
|
|
314
|
+
function fourDigitsToKorean(n) {
|
|
315
|
+
const str = String(n);
|
|
316
|
+
let result = "";
|
|
317
|
+
for (let i = 0; i < str.length; i++) {
|
|
318
|
+
const digit = Number(str[i]);
|
|
319
|
+
if (digit === 0) continue;
|
|
320
|
+
const unit = SMALL_UNITS[str.length - 1 - i];
|
|
321
|
+
if (digit === 1 && unit !== "") {
|
|
322
|
+
result += unit;
|
|
323
|
+
} else {
|
|
324
|
+
result += DIGITS[digit] + unit;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
function numberToKorean(num) {
|
|
330
|
+
if (!Number.isSafeInteger(num)) {
|
|
331
|
+
throw new RangeError("numberToKorean: value must be a safe integer");
|
|
332
|
+
}
|
|
333
|
+
if (num === 0) return "\uC601";
|
|
334
|
+
let prefix = "";
|
|
335
|
+
let n = num;
|
|
336
|
+
if (n < 0) {
|
|
337
|
+
prefix = "\uB9C8\uC774\uB108\uC2A4 ";
|
|
338
|
+
n = -n;
|
|
339
|
+
}
|
|
340
|
+
const groups = [];
|
|
341
|
+
while (n > 0) {
|
|
342
|
+
groups.push(n % 1e4);
|
|
343
|
+
n = Math.floor(n / 1e4);
|
|
344
|
+
}
|
|
345
|
+
if (groups.length > BIG_UNITS.length) {
|
|
346
|
+
throw new RangeError(`numberToKorean: numbers up to ${BIG_UNITS.length - 1} * 10^4 scale are supported`);
|
|
347
|
+
}
|
|
348
|
+
let result = "";
|
|
349
|
+
for (let i = groups.length - 1; i >= 0; i--) {
|
|
350
|
+
const group = groups[i];
|
|
351
|
+
if (group === 0) continue;
|
|
352
|
+
if (group === 1 && i > 0) {
|
|
353
|
+
result += "\uC77C" + BIG_UNITS[i];
|
|
354
|
+
} else {
|
|
355
|
+
result += fourDigitsToKorean(group) + BIG_UNITS[i];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return prefix + result;
|
|
359
|
+
}
|
|
360
|
+
function formatWon(amount) {
|
|
361
|
+
return numberToKorean(amount) + "\uC6D0";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/pii.ts
|
|
365
|
+
var DEFAULT_TYPES = ["rrn", "phone", "email", "card", "driverLicense"];
|
|
366
|
+
var PATTERN_PRIORITY = ["rrn", "driverLicense", "card", "phone", "email", "account"];
|
|
367
|
+
var PATTERNS = {
|
|
368
|
+
// 주민등록번호 / 외국인등록번호
|
|
369
|
+
rrn: /\b\d{6}-?[1-4]\d{6}\b/g,
|
|
370
|
+
// 운전면허번호
|
|
371
|
+
driverLicense: /\b\d{2}-\d{2}-\d{6}-\d{2}\b/g,
|
|
372
|
+
// 신용카드 번호
|
|
373
|
+
card: /\b\d{4}[- ]\d{4}[- ]\d{4}[- ]\d{4}\b/g,
|
|
374
|
+
// 휴대폰(010~019) + 일반전화(02, 031~064)
|
|
375
|
+
phone: /\b0(?:1[016789]|2|[3-6]\d)-?\d{3,4}-?\d{4}\b/g,
|
|
376
|
+
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
377
|
+
// 계좌번호 (오탐 가능성이 있어 기본 비활성)
|
|
378
|
+
account: /\b\d{3,6}-\d{2,6}-\d{2,7}\b/g
|
|
379
|
+
};
|
|
380
|
+
function collectMatches(text, types) {
|
|
381
|
+
const matches = [];
|
|
382
|
+
for (const type of PATTERN_PRIORITY) {
|
|
383
|
+
if (!types.includes(type)) continue;
|
|
384
|
+
const pattern = new RegExp(PATTERNS[type].source, PATTERNS[type].flags);
|
|
385
|
+
let m;
|
|
386
|
+
while ((m = pattern.exec(text)) !== null) {
|
|
387
|
+
matches.push({ type, value: m[0], start: m.index, end: m.index + m[0].length });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const accepted = [];
|
|
391
|
+
const sorted = matches.sort(
|
|
392
|
+
(a, b) => PATTERN_PRIORITY.indexOf(a.type) - PATTERN_PRIORITY.indexOf(b.type) || a.start - b.start
|
|
393
|
+
);
|
|
394
|
+
for (const match of sorted) {
|
|
395
|
+
const overlaps = accepted.some((a) => match.start < a.end && a.start < match.end);
|
|
396
|
+
if (!overlaps) accepted.push(match);
|
|
397
|
+
}
|
|
398
|
+
return accepted.sort((a, b) => a.start - b.start);
|
|
399
|
+
}
|
|
400
|
+
function detectPII(text, types = [...PATTERN_PRIORITY]) {
|
|
401
|
+
return collectMatches(text, types);
|
|
402
|
+
}
|
|
403
|
+
function maskPII(text, options = {}) {
|
|
404
|
+
const { types = DEFAULT_TYPES, maskChar = "*", keepLast = 0 } = options;
|
|
405
|
+
const matches = collectMatches(text, types);
|
|
406
|
+
let result = text;
|
|
407
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
408
|
+
const { start, end, value } = matches[i];
|
|
409
|
+
const alnumCount = (value.match(/[A-Za-z0-9]/g) ?? []).length;
|
|
410
|
+
const keep = Math.max(0, Math.min(keepLast, alnumCount));
|
|
411
|
+
let seen = 0;
|
|
412
|
+
const masked = [...value].map((ch) => {
|
|
413
|
+
if (!/[A-Za-z0-9]/.test(ch)) return ch;
|
|
414
|
+
seen += 1;
|
|
415
|
+
return seen > alnumCount - keep ? ch : maskChar;
|
|
416
|
+
}).join("");
|
|
417
|
+
result = result.slice(0, start) + masked + result.slice(end);
|
|
418
|
+
}
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
function maskName(name, maskChar = "*") {
|
|
422
|
+
const chars = [...name.trim()];
|
|
423
|
+
if (chars.length <= 1) return name;
|
|
424
|
+
if (chars.length === 2) return chars[0] + maskChar;
|
|
425
|
+
return chars[0] + maskChar.repeat(chars.length - 2) + chars[chars.length - 1];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/validate.ts
|
|
429
|
+
function isValidRRN(input) {
|
|
430
|
+
const digits = input.replace(/-/g, "");
|
|
431
|
+
if (!/^\d{13}$/.test(digits)) return false;
|
|
432
|
+
const month = Number(digits.slice(2, 4));
|
|
433
|
+
const day = Number(digits.slice(4, 6));
|
|
434
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
|
435
|
+
const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
|
|
436
|
+
let sum = 0;
|
|
437
|
+
for (let i = 0; i < 12; i++) {
|
|
438
|
+
sum += Number(digits[i]) * weights[i];
|
|
439
|
+
}
|
|
440
|
+
const check = (11 - sum % 11) % 10;
|
|
441
|
+
return check === Number(digits[12]);
|
|
442
|
+
}
|
|
443
|
+
function isValidBusinessNumber(input) {
|
|
444
|
+
const digits = input.replace(/-/g, "");
|
|
445
|
+
if (!/^\d{10}$/.test(digits)) return false;
|
|
446
|
+
const weights = [1, 3, 7, 1, 3, 7, 1, 3];
|
|
447
|
+
let sum = 0;
|
|
448
|
+
for (let i = 0; i < 8; i++) {
|
|
449
|
+
sum += Number(digits[i]) * weights[i];
|
|
450
|
+
}
|
|
451
|
+
sum += Math.floor(Number(digits[8]) * 5 / 10);
|
|
452
|
+
const check = (10 - sum % 10) % 10;
|
|
453
|
+
return check === Number(digits[9]);
|
|
454
|
+
}
|
|
455
|
+
function isValidPhoneNumber(input) {
|
|
456
|
+
return /^0(?:1[016789]|2|[3-6]\d)-?\d{3,4}-?\d{4}$/.test(input);
|
|
457
|
+
}
|
|
458
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
459
|
+
0 && (module.exports = {
|
|
460
|
+
CHOSEONG_LIST,
|
|
461
|
+
JONGSEONG_LIST,
|
|
462
|
+
JUNGSEONG_LIST,
|
|
463
|
+
assemble,
|
|
464
|
+
compose,
|
|
465
|
+
decompose,
|
|
466
|
+
detectPII,
|
|
467
|
+
disassemble,
|
|
468
|
+
formatWon,
|
|
469
|
+
hasBatchim,
|
|
470
|
+
isHangulSyllable,
|
|
471
|
+
isValidBusinessNumber,
|
|
472
|
+
isValidPhoneNumber,
|
|
473
|
+
isValidRRN,
|
|
474
|
+
josa,
|
|
475
|
+
maskName,
|
|
476
|
+
maskPII,
|
|
477
|
+
numberToKorean,
|
|
478
|
+
pickJosa,
|
|
479
|
+
supportedJosaPairs
|
|
480
|
+
});
|