hangul-readout 0.0.0-stage → 0.1.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LeeGyeongPil
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.en.md ADDED
@@ -0,0 +1,470 @@
1
+ # hangul-readout
2
+
3
+ [한국어](README.md) | **English**
4
+
5
+ A zero-dependency TypeScript library and CLI that rewrites Korean text the way a person reads it aloud, right before it goes to a TTS engine: `사과 3개` becomes `사과 세 개`, `3.5km` becomes `삼 점 오 킬로미터`, `010-1234-5678` becomes `공일공, 일이삼사, 오육칠팔`.
6
+ It reports which spans changed (offsets in the original text and in the output), can emit SSML `<sub alias>`, and ships a sentence chunker so streaming LLM tokens such as `3` / `.` / `5km` are never misread. Install: `npm install github:LeeGyeongPil/hangul-readout` (Node >= 22.12, ESM only).
7
+
8
+ ## 1. Introduction
9
+
10
+ A library for people building Korean voice apps, voice agents or narration automation in JS/TS. Right **before** a string is handed to TTS, it turns numbers, units and dates into "spoken words". It has no runtime dependencies and runs offline, with no keys, network or GPU.
11
+
12
+ ## 2. What it does
13
+
14
+ TTS engines read numbers and units differently from one another. One reads `3개` as "삼 개" (sam gae), another reads `100kg` as "백 케이지" (baek ke-i-ji). This library rewrites the text before it reaches the engine. It is like a proofreader penciling "read this as ..." onto a script for the narrator. The script (the subtitles) stays untouched; only the reading is written down separately.
15
+
16
+ | Original | Reading by this library | Why |
17
+ |---|---|---|
18
+ | 사과 3개 | 사과 세 개 | The counter `개` takes a native Korean numeral |
19
+ | 3년 | 삼 년 | The counter `년` takes a Sino-Korean numeral |
20
+ | 3.5km | 삼 점 오 킬로미터 | Decimal number with a unit |
21
+ | 2.5배 | 이 점 오 배 | Decimal number with a counter |
22
+ | -3℃ | 영하 삼 도 | Negative temperature |
23
+ | 010-1234-5678 | 공일공, 일이삼사, 오육칠팔 | Phone numbers are read digit by digit, with 0 as "공" |
24
+ | 2026-11-11 | 이천이십육 년 십일 월 십일 일 | Date |
25
+ | 3번 출구 / 3번 눌러 | 삼 번 출구 / 세 번 눌러 | `번` depends on whether it means a number or a count of times |
26
+
27
+ ![Table mode output: one row each for the original, the reading, the rule name and the position](docs/images/01-table.png)
28
+
29
+ *Output of `node dist/cli.js --table examples/sample.txt`. It shows only the spots that changed in the sample file, as a table.*
30
+
31
+ ## 3. Quick start
32
+
33
+ Install it. Node 22.12 or later is required (to install the prerequisites, for example: macOS `brew install node`, Debian/Ubuntu `sudo apt-get install -y nodejs npm git`).
34
+
35
+ ```bash
36
+ npm install github:LeeGyeongPil/hangul-readout
37
+ ```
38
+
39
+ Publishing to the npm registry is prepared but not done yet (once published: `npm install hangul-readout`). The `prepare` script builds `dist/` during installation, so a git install can be imported right away. On npm 11 and later you may see a warning that the `prepare` script is not yet in `allowScripts`. This is not an install failure; the build still runs and the package can be imported immediately.
40
+
41
+ ```js
42
+ import { normalize } from "hangul-readout";
43
+
44
+ const { text, spans } = normalize("사과 3개를 사서 3.5km를 걸었어요. 문의는 010-1234-5678");
45
+ console.log(text);
46
+ ```
47
+
48
+ ```text
49
+ 사과 세 개를 사서 삼 점 오 킬로미터를 걸었어요. 문의는 공일공, 일이삼사, 오육칠팔
50
+ ```
51
+
52
+ Pass `text` to TTS, and keep using the original string for the subtitles shown on screen. You can also use it from the command line inside a project where it is installed.
53
+
54
+ ```bash
55
+ echo "사과 3개" | npx hangul-readout
56
+ ```
57
+
58
+ ```text
59
+ 사과 세 개
60
+ ```
61
+
62
+ To clone the repository and try it yourself, run the following (from install to the table output takes a few minutes).
63
+
64
+ ```bash
65
+ git clone https://github.com/LeeGyeongPil/hangul-readout.git
66
+ cd hangul-readout
67
+ npm ci
68
+ npm start
69
+ ```
70
+
71
+ `npm start` builds the project and prints the change table for `examples/sample.txt`. The screenshot above is that result.
72
+
73
+ ## 4. Usage
74
+
75
+ ### 4.1 `normalize` and spans
76
+
77
+ `normalize(text, opts?)` returns `{ text, spans }`. `spans` is a list of "what changed where in the original".
78
+
79
+ ```js
80
+ import { normalize } from "hangul-readout";
81
+
82
+ const r = normalize("사과 3개와 배 20개");
83
+ console.log(r.text);
84
+ console.log(JSON.stringify(r.spans[0]));
85
+ ```
86
+
87
+ ```text
88
+ 사과 세 개와 배 스무 개
89
+ {"start":3,"end":5,"outStart":3,"outEnd":6,"source":"3개","reading":"세 개","rule":"native-counter"}
90
+ ```
91
+
92
+ - `start`/`end`: position in the original text (UTF-16 offsets, the same unit as `String.prototype.slice`).
93
+ - `outStart`/`outEnd`: position in the resulting `text`.
94
+ - `source`/`reading`: the text before the change and its reading. `rule`: which rule made the choice.
95
+
96
+ It never throws for any string (empty string, emoji, very long numbers). Passing a non-string throws a `HangulReadoutError` (`normalize(null)` → `normalize(): expected a string, got null.`).
97
+
98
+ ### 4.2 `toSSML`
99
+
100
+ For cloud TTS services that accept SSML (XML for speech synthesis). It wraps each changed spot in `<sub alias="reading">original</sub>`. `& < > " '` are escaped.
101
+
102
+ ```js
103
+ import { toSSML } from "hangul-readout";
104
+ console.log(toSSML("사과 3개"));
105
+ ```
106
+
107
+ ```text
108
+ <speak>사과 <sub alias="세 개">3개</sub></speak>
109
+ ```
110
+
111
+ ![SSML mode output: 3개 is wrapped in sub alias and A&B becomes A&amp;B](docs/images/03-ssml.png)
112
+
113
+ *The original is kept and only the sound changes. `&` becomes `&amp;` as XML requires.*
114
+
115
+ ### 4.3 `sentences` and `normalizeStream` (LLM streams)
116
+
117
+ An LLM sends its answer as token fragments. A split can land in the middle of a number, such as `3` / `.` / `5km`. `sentences` collects fragments and emits them **only after a sentence is confirmed**, and `normalizeStream` adds `normalize` on top of that. The input is an `AsyncIterable<string>` or an `Iterable<string>`.
118
+
119
+ ```js
120
+ import { sentences, normalizeStream } from "hangul-readout";
121
+
122
+ const tokens = ["거리는 3", ".", "5km 입니다", ". 다음은 ", "사과 3", "개예요. 끝"];
123
+
124
+ for await (const s of sentences(tokens)) console.log(s);
125
+ for await (const s of normalizeStream(tokens)) console.log(s.text);
126
+ ```
127
+
128
+ ```text
129
+ 거리는 3.5km 입니다.
130
+ 다음은 사과 3개예요.
131
+ 끝
132
+ 거리는 삼 점 오 킬로미터 입니다.
133
+ 다음은 사과 세 개예요.
134
+ 끝
135
+ ```
136
+
137
+ `normalizeStream` yields `{ sentence, text, spans }`. It uses no time-based flush (timeout), so the same input always produces the same result. How it works is explained in section 8.
138
+
139
+ ### 4.4 `createNormalizer` and options
140
+
141
+ Build the options and rules once and reuse them many times. Each call uses only local state, so running several streams at the same time does not mix them up.
142
+
143
+ ```js
144
+ import { createNormalizer } from "hangul-readout";
145
+
146
+ const n = createNormalizer({
147
+ counters: { native: ["포기"] },
148
+ abbreviations: { RAG: "래그" },
149
+ units: { "㎡": "제곱미터" },
150
+ });
151
+ console.log(n.normalize("배추 3포기, RAG 30㎡").text);
152
+ ```
153
+
154
+ ```text
155
+ 배추 세 포기, 래그 삼십 제곱미터
156
+ ```
157
+
158
+ | Option | Default | Meaning |
159
+ |---|---|---|
160
+ | `over99` | `"mixed"` | How native-Korean counters read numbers of 100 and above (section 5) |
161
+ | `counters` | built-in table | `{ native?: string[]; sino?: string[] }` adds counters |
162
+ | `units` | built-in table | `{ "㎡": "제곱미터" }` unit symbol → reading |
163
+ | `abbreviations` | built-in table | `{ RAG: "래그" }` English abbreviation → reading |
164
+ | `beonNumberHints` | built-in list | Words that make a preceding `번` read as a number (Sino-Korean) |
165
+ | `disable` | none | Array of rule names to turn off |
166
+
167
+ The chunk options `minChars` (default 8) and `maxChars` (default 160) are used by `sentences` and `normalizeStream`. Long text with no whitespace at all is cut by force at `max(maxChars*8, 2048)` characters.
168
+
169
+ ### 4.5 CLI
170
+
171
+ ```text
172
+ hangul-readout [options] [file | -]
173
+ ```
174
+
175
+ With no file, or with `-`, it reads standard input.
176
+
177
+ | Option | What it does |
178
+ |---|---|
179
+ | (none) | Prints the text rewritten as readings |
180
+ | `--json` | Prints `{ text, spans }` as JSON |
181
+ | `--table` | Prints a table of the changed spots (original, reading, rule, position) |
182
+ | `--ssml` | Prints `<sub alias>` SSML |
183
+ | `--srt` | Treats the input as SRT: keeps the index and timing lines and rewrites only the dialogue |
184
+ | `--over99 <m>` | `mixed` (default) or `sino` |
185
+ | `--debug` | Prints a stack trace on error |
186
+ | `--help`, `--version` | Help, version |
187
+
188
+ | Exit code | Meaning |
189
+ |---|---|
190
+ | 0 | Success (empty input is also success) |
191
+ | 2 | Every error: unknown option, missing file, invalid `--over99` value, malformed SRT, and so on |
192
+
193
+ Errors appear only as a single line on stderr (`hangul-readout: error: …`); a stack trace appears only with `--debug`.
194
+
195
+ ```bash
196
+ node dist/cli.js --nope 2>&1; echo "exit code: $?"
197
+ ```
198
+
199
+ ```text
200
+ hangul-readout: error: unknown option --nope
201
+ exit code: 2
202
+ ```
203
+
204
+ ![An unknown option produces a one-line error and exit code 2](docs/images/06-error-exit.png)
205
+
206
+ *An error case. Only one line is printed, with no traceback, and the exit code is 2.*
207
+
208
+ For SRT subtitle files, only the dialogue is rewritten as readings.
209
+
210
+ ```bash
211
+ node dist/cli.js --srt examples/sample.srt
212
+ ```
213
+
214
+ ```text
215
+ 1
216
+ 00:00:01,000 --> 00:00:03,500
217
+ 오늘은 사과 세 개와 배 스무 개를 샀어요.
218
+
219
+ 2
220
+ 00:00:03,500 --> 00:00:06,000
221
+ 집에서 마트까지 삼 점 오 킬로미터 걸었고
222
+ 기온은 영하 삼 도 였어요.
223
+
224
+ 3
225
+ 00:00:06,000 --> 00:00:09,000
226
+ 문의는 공일공, 일이삼사, 오육칠팔 로 주세요.
227
+ ```
228
+
229
+ ![SRT mode output: index and timing lines stay as they are, only the dialogue becomes readings](docs/images/05-srt.png)
230
+
231
+ *To save the result to a file: `npx hangul-readout --srt narration.srt > narration.read.srt`.*
232
+
233
+ ## 5. Reading rules
234
+
235
+ The scanner sweeps the text from left to right, and when it meets a number it tries the rules below **from the top**. The first matching rule decides the reading, and if a rule declines ("this is not mine") the next rule is tried. So the order is how ambiguity gets resolved (phone numbers come before plain numbers).
236
+
237
+ | Order | Rule | Example → reading |
238
+ |---|---|---|
239
+ | 1 | `phone` | 010-1234-5678 → 공일공, 일이삼사, 오육칠팔 (also main numbers like 1588-1234, `(02) 123-4567`, `+82-10-…`, and the emergency number 119) |
240
+ | 2 | `date` | 2026-11-11 → 이천이십육 년 십일 월 십일 일, 11/11 → 십일 월 십일 일 |
241
+ | 3 | `protect` | 192.168.0.1, 1.2.3, `https://…` → left exactly as they are |
242
+ | 4 | `clock` | 3:30 → 세 시 삼십 분 |
243
+ | 5 | `ratio` | 16:9 → 십육 대 구, 1:1 → 일 대 일 |
244
+ | 6 | `fraction` | 1/3 → 삼분의 일 |
245
+ | 7 | `range` | 2~3명 → 두세 명, 5~6시 → 다섯 시에서 여섯 시, 30~40% → 삼십에서 사십 퍼센트 |
246
+ | 8 | `unit` | 3.5km → 삼 점 오 킬로미터, -3℃ → 영하 삼 도, 30°C → 삼십 도 |
247
+ | 9 | `currency` | $5 → 오 달러, ₩5000 → 오천 원, ₩5,000원 → 오천 원 |
248
+ | 10 | `month` | 6월 → 유월, 11월 → 십일 월 |
249
+ | 11 | `hour` | 10시 → 열 시 (`시간` is excluded) |
250
+ | 12 | `ordinal` | 1번째 → 첫 번째, 2번째 → 두 번째 |
251
+ | 13 | `beon` | 3번 출구 → 삼 번 출구 / 3번 눌러 → 세 번 눌러 |
252
+ | 14 | `native-counter` | 3개 → 세 개 |
253
+ | 15 | `sino-counter` | 3년 → 삼 년, 20세 → 이십 세, 제1장 → 제일 장, 30대 → 삼십 대. Decimals are read in Sino-Korean even before native counters: 1.5배 → 일 점 오 배, 2.5시간 → 이 점 오 시간 |
254
+ | 16 | `digits` | Digit strings of 17 or more digits, or starting with 0 → one digit at a time |
255
+ | 17 | `number` | 12,345 → 만 이천삼백사십오 |
256
+ | - | `abbreviation` | AI → 에이아이, 5G → 파이브지 |
257
+
258
+ **Whether a number is native or Sino-Korean is decided by the counter that follows, not by the number.** `3개` is "세 개" and `3년` is "삼 년". That is why a function that merely converts numbers cannot process a sentence; the counter after the number has to be looked at too.
259
+
260
+ **The over-99 option.** There is no single correct way to read 100 and above with native Korean numerals, so it is chosen by an option.
261
+
262
+ ```js
263
+ import { normalize } from "hangul-readout";
264
+ console.log(normalize("120명").text, normalize("120명", { over99: "sino" }).text);
265
+ ```
266
+
267
+ ```text
268
+ 백스무 명 백이십 명
269
+ ```
270
+
271
+ A few exceptions are built into the rules as well.
272
+
273
+ ```js
274
+ console.log(normalize("6월 10월 11월").text, normalize("3시간 3시").text);
275
+ console.log(normalize("A4 용지 3장 COVID-19").text);
276
+ ```
277
+
278
+ ```text
279
+ 유월 시월 십일 월 세 시간 세 시
280
+ A4 용지 세 장 COVID-19
281
+ ```
282
+
283
+ - `6월` and `10월` are read "유월" and "시월".
284
+ - The `3` in `3시간` is "세"; since `시간` is the unit, the counter rule handles it rather than the `hour` rule.
285
+ - Numbers attached to English letters (`A4`, `MP3`, `COVID-19`, `v2.0`) are left alone. For `5G`, `3D` and `4K`, where the number comes first, the whole token is read if it is in the abbreviation table (`5G` → 파이브지) and left whole as it is if not (`1st`, `1e5`). Half of a token is never converted.
286
+ - `제1장` → 제일 장, `20대` → 이십 대, `2대` (vehicles) → 두 대. `제` followed by a number is Sino-Korean, and a multiple of 10 followed by `대` is read as an age group.
287
+ - A range is read only when both sides share the same counter or unit (`5~6시` → 다섯 시에서 여섯 시); if it cannot be read, it is left whole as it is, like `1~2.5명`.
288
+
289
+ ```js
290
+ console.log(normalize("5~6시 30~40% 1~2만 원").text);
291
+ console.log(normalize("30°C 5G $5 11/11 16:9").text);
292
+ console.log(normalize("1588-1234 (02) 123-4567 +82-10-1234-5678").text);
293
+ ```
294
+
295
+ ```text
296
+ 다섯 시에서 여섯 시 삼십에서 사십 퍼센트 일에서 이만 원
297
+ 삼십 도 파이브지 오 달러 십일 월 십일 일 십육 대 구
298
+ 일오팔팔, 일이삼사 (공이), 일이삼, 사오육칠 플러스 팔이, 일공, 일이삼사, 오육칠팔
299
+ ```
300
+
301
+ `번` is decided by context. If a number-hint word follows it right away (출구, 버스, 선, 게이트, 트랙, 승강장, 홈, 문제, 채널, 방, 국도, 타자, 라인), or if it is preceded by `호선`, `번호는` or `정답은`, it is read as a number (Sino-Korean); otherwise it is read as a count of times (native Korean).
302
+
303
+ ![The same 번 is read as 삼 번 or 세 번 depending on context, and 1번째 becomes 첫 번째](docs/images/02-beon-context.png)
304
+
305
+ *`3번 출구` becomes "삼 번", `3번 돌아요` becomes "세 번", and `1번째` becomes "첫 번째".*
306
+
307
+ ## 6. Adding your own rules
308
+
309
+ Options passed to `normalize` and `createNormalizer` are added on top of the built-in tables.
310
+
311
+ ```js
312
+ import { normalize } from "hangul-readout";
313
+
314
+ console.log(normalize("3번 주차장").text);
315
+ console.log(normalize("3번 주차장", { beonNumberHints: ["주차장"] }).text);
316
+ console.log(normalize("AI 모델", { disable: ["abbreviation"] }).text);
317
+ ```
318
+
319
+ ```text
320
+ 세 번 주차장
321
+ 삼 번 주차장
322
+ AI 모델
323
+ ```
324
+
325
+ - `counters`: adds counters as native or Sino-Korean (`{ native: ["포기"] }`).
326
+ - `units`: adds unit symbols (`{ "㎡": "제곱미터" }`).
327
+ - `abbreviations`: adds abbreviations (`{ RAG: "래그" }`). Matching is case-sensitive.
328
+ - `beonNumberHints`: adds words that make `번` read as a number.
329
+ - `disable`: turns off a rule when given its name (`RuleName`). The names are `phone`, `date`, `protect`, `clock`, `ratio`, `fraction`, `range`, `unit`, `currency`, `month`, `hour`, `ordinal`, `beon`, `native-counter`, `sino-counter`, `digits`, `number` and `abbreviation`.
330
+ - Keys of `abbreviations` may start with a digit, as in `{ "6G": "식스지" }`.
331
+
332
+ If you find an ambiguous sentence, you can also pin it by adding one more line to `test/golden.json` (section 11).
333
+
334
+ ## 7. Aligning original and reading (spans) and subtitles
335
+
336
+ When you want to use the reading for audio and the original for the screen, spans connect the two strings. A span is a correspondence table: "this stretch of the original is that stretch of the reading", and the `<sub alias>` in `toSSML` expresses the same idea in XML.
337
+
338
+ Offsets are in UTF-16 code units. An emoji takes two units, yet `input.slice(start, end)` still gives exactly `source`.
339
+
340
+ ```js
341
+ import { normalize } from "hangul-readout";
342
+
343
+ const input = "👍 사과 3개";
344
+ const e = normalize(input);
345
+ const s = e.spans[0];
346
+ console.log(JSON.stringify(s));
347
+ console.log(input.slice(s.start, s.end), "->", e.text.slice(s.outStart, s.outEnd));
348
+ ```
349
+
350
+ ```text
351
+ {"start":6,"end":8,"outStart":6,"outEnd":9,"source":"3개","reading":"세 개","rule":"native-counter"}
352
+ 3개 -> 세 개
353
+ ```
354
+
355
+ Why `start` is 6: the emoji 👍 takes 2 units (two UTF-16 code units), then one space, `사과` takes 2, and one more space, so `3개` starts at position 6. Tests pin down that `slice` stays correct even with emoji, ZWJ sequences and unpaired surrogates mixed in.
356
+
357
+ Subtitle highlighting example: wrapping only the `[start, end)` stretch of the original in `<mark>` lets you show "where the reading changed" in the subtitles, and `outStart`/`outEnd` let you find the same spot in the string on the audio-timing side.
358
+
359
+ ## 8. Streaming: how chunk boundaries are respected
360
+
361
+ > **This is a practice fake LLM stream.** `examples/fake-llm-stream.mjs` is not a real model; it is a practice (mock) example that emits fixed token fragments split in the middle of numbers. The sentence splitting and reading conversion are the real library code, and the output is what actually came out of the library. No real LLM or TTS calls are included in this repository's examples or tests.
362
+
363
+ ```bash
364
+ node examples/fake-llm-stream.mjs
365
+ ```
366
+
367
+ ```text
368
+ [practice mock] fake token stream -> real sentence chunker + normalizer
369
+ token "연락처는 010-"
370
+ token "1234-5678 입니다. "
371
+ token "사과 3"
372
+ sentence 연락처는 010-1234-5678 입니다.
373
+ reading 연락처는 공일공, 일이삼사, 오육칠팔 입니다.
374
+ token "개예요. "
375
+ token "거리는 3"
376
+ sentence 사과 3개예요.
377
+ reading 사과 세 개예요.
378
+ token "."
379
+ token "5km 입니다."
380
+ sentence 거리는 3.5km 입니다.
381
+ reading 거리는 삼 점 오 킬로미터 입니다.
382
+ ```
383
+
384
+ ![While fake tokens arrive, sentence confirmation lags by one token, and 3 . 5km is read as one piece](docs/images/04-fake-stream.png)
385
+
386
+ *Even after `입니다. ` arrives, the sentence is not emitted immediately; the earlier sentence comes out only after the next token `사과 3` arrives.*
387
+
388
+ ```mermaid
389
+ flowchart LR
390
+ T["Token fragment"] --> B["Collect in buffer"]
391
+ B --> C{"Sentence-end candidate?"}
392
+ C -- No --> B
393
+ C -- "At end of buffer" --> W["Wait until the next character is seen"]
394
+ W --> B
395
+ C -- Confirmed --> S["Emit sentence"]
396
+ S --> N[normalize]
397
+ N --> O["To TTS"]
398
+ ```
399
+
400
+ - **lookahead**: if a sentence-end candidate (`.`, `?`, `!`, etc.) is at the very end of the buffer, confirmation is deferred until the next character is seen. If a `.` follows `3` and then a `5` comes after it, you can tell it was a decimal point.
401
+ - **Decimal points and list numbers**: a period between digits (`3.5`) and a list number (`1. `) are not treated as sentence ends. Ellipses are not treated as boundaries either. A line break is always a sentence end.
402
+ - **`maxChars`**: if one sentence is too long, it is split by force at a comma or whitespace, but never inside a number or symbol token such as `010-1234-5678`, `3.5km` or `2~3명`.
403
+
404
+ ```js
405
+ import { sentences } from "hangul-readout";
406
+
407
+ const text = "연락처는 010-1234-5678 이고, 거리는 3.5km, 인원은 2~3명입니다.";
408
+ const out = [];
409
+ for await (const s of sentences([text], { maxChars: 12 })) out.push(s);
410
+ console.log(JSON.stringify(out));
411
+ ```
412
+
413
+ ```text
414
+ ["연락처는","010-1234-5678","이고, 거리는","3.5km, 인원은","2~3명입니다."]
415
+ ```
416
+
417
+ However the tokens are split on the way in (even one character at a time), the same sentences come out. This property is pinned by tests. Normalizing only after a sentence is confirmed is the key to preventing misreadings at chunk boundaries.
418
+
419
+ ## 9. Limits and ambiguous readings
420
+
421
+ Some readings have no single correct answer. Defaults are chosen, and they can be changed with options and the golden table.
422
+
423
+ - **`번`**: the default is a count of times (native Korean). The words that make it read as a number grow only through the hint list and `beonNumberHints`. `3번 방법` is read as a count of times.
424
+ - **No particle agreement**: even when the reading changes, the following particle is not changed. `3개를` → `세 개를` is right, but where the final consonant changes (`1명이` → `한 명이`, etc.) the particle is not adjusted to fit the reading.
425
+ - **Range counters**: `2~3명` → `두세 명` and `5~6시` → `다섯 시에서 여섯 시` work, but `2~3일` is read as `이에서 삼 일` (not the natural `이삼 일`). Ranges that cannot be read (`1~2.5명`, `01~02월`) are left whole as they are, and `2-3` with no hyphen tail is not treated as a range.
426
+ - **`대`**: when the actual count is exactly a multiple of 10 (10 to 90), as in `20대의 서버`, it is read as an age group. `2대`, `5대` and `100대` are vehicles (native Korean).
427
+ - **`NN/NN` that looks like a date**: it is a date only when both sides have two digits, month ≤ 12 and day ≤ 31. `1/2` and `3/4` are fractions.
428
+ - **Currency**: the symbol, the number and a directly attached name of the same currency are read together (`$5`, `₩5000`, `₩5,000원`, and `$5달러` reads the name only once). With a space, as in `₩5,000 원`, only the symbol and number are read and the following name is read separately, so the name comes out twice (`오천 원 원`). A currency name that differs from the symbol (`$5원`) is not absorbed (`오 달러원`).
429
+ - **Markdown symbols are not removed.** As in `**3**개` → `**세** 개`, a `*`, `_` or `` ` `` between a number and its counter is skipped when reading, but the symbol stays in the output. TTS may read `**` as "asterisk", so strip the symbols on the calling side.
430
+ - **Very long words**: text with no whitespace that exceeds `max(maxChars*8, 2048)` characters (long URLs, base64, etc.) is cut in the middle.
431
+ - **개월, 석 달, 인분**: old forms such as 석 달 and 넉 장 are not supported.
432
+ - **Units and abbreviations not in the dictionary**: the built-in tables are a small set of defaults. Words not in the table (such as `GPT-4`) are left as they are; add them with `units` and `abbreviations`.
433
+ - **Whitespace and line breaks**: a single space before a unit is allowed (`5 km`), but two or more spaces or a line break are not joined.
434
+ - **Date notation and versions**: versions and IPs such as `1.2.3` and `192.168.0.1`, and URLs, are left whole as they are. A date with spaces in it, like `2026. 11. 11.`, may be cut at `2026.` in a stream.
435
+ - Some ambiguous readings (`3대 1` → `세 대 일`) are a limit of rule-based processing.
436
+ - It does no pronunciation conversion (liaison, tensification), no Hanja readings and no morphological analysis.
437
+ - The examples, tests (279) and typecheck passed on Node 22.12.0 and 26.13.0, and the CI matrix (22, 24, 26) keeps checking them.
438
+ - **Known limits (awaiting a code fix)**: some combinations of the `~` and `-` range symbols are not read as ranges or are read awkwardly. Ordinal reading may be over-applied to a number written with a space after "제", as in `제 3개`. The reading of some counters such as `4강`, and of a standalone `+82` country code, may sound unnatural.
439
+
440
+ ## 10. Comparison with existing alternatives
441
+
442
+ No feature table is made. Other tools' features change, so below only the date checked and the purpose are given. (All checked on 2026-11-11.)
443
+
444
+ - If you need a **function that converts a single number to Hangul**, [es-hangul](https://github.com/toss/es-hangul) (2.4.0) or [korean-number-count](https://socket.dev/npm/package/korean-number-count) (1.0.0) fit that purpose. This library handles what sits on top of them: "find the numbers inside a sentence and choose the reading from the counter that follows". For the numerals 1 to 99, tests compare that it gives the same values as es-hangul.
445
+ - For a **Python-side speech pipeline**, [NeMo-text-processing](https://github.com/NVIDIA/NeMo-text-processing) has Korean rules. It is based on Python and pynini, so it is hard to use in browsers, Node or at the edge.
446
+ - If you need **general multilingual normalization**, you can look at @revolab/revonorm (an npm package). The version checked, 1.1.4, raised an error on Korean (`ko`) calls.
447
+ - The **sentence splitters of speech frameworks** ([Pipecat](https://reference-server.pipecat.ai/en/latest/_modules/pipecat/utils/text/simple_text_aggregator.html), [LiveKit Agents](https://docs.livekit.io/reference/python/livekit/agents/tokenize/basic.html)) are general-purpose across languages and are separate from reading Korean numbers.
448
+
449
+ ## 11. Development
450
+
451
+ ```bash
452
+ npm ci && npm run typecheck && npm test
453
+ ```
454
+
455
+ `npm test` builds in `pretest` and then runs `vitest run`. All tests run offline and deterministically, and need no keys. The result confirmed in this repository is `Test Files 10 passed`, `Tests 279 passed`.
456
+
457
+ **Adding a golden row**: `test/golden.json` is an array of `{ "input", "expected", "options"?, "note"? }` (currently 147 rows). When you find a sentence that is read wrongly, add one line and check with `npx vitest run test/golden.test.ts`. Each row's test name is its input text.
458
+
459
+ ```json
460
+ { "input": "사과 3개", "expected": "사과 세 개", "note": "native counter takes the attributive form" }
461
+ ```
462
+
463
+ - Supported Node: `>=22.12`, ESM only. CI runs `npm ci` → `npm run typecheck` → `npm test` on Node 22, 24 and 26.
464
+ - The build is done only with `tsc -p tsconfig.build.json` (`npm run build`).
465
+ - Versions follow semantic versioning (SemVer), and during 0.x the API may change in minor versions. Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
466
+ - For reporting security issues, see [SECURITY.md](SECURITY.md).
467
+
468
+ ## 12. License
469
+
470
+ MIT. See [LICENSE](LICENSE). The tables of counters, units and exceptions, and the example sentences, were written directly with reference to publicly available descriptions of the rules.