braille-codec 0.0.1-rc1

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.
Files changed (36) hide show
  1. package/.github/workflows/ci.yml +80 -0
  2. package/LICENSE +201 -0
  3. package/README.md +10 -0
  4. package/bin/braille-decode +66 -0
  5. package/dist/decoder/constants/char_english.d.ts +1 -0
  6. package/dist/decoder/constants/char_shortcut.d.ts +1 -0
  7. package/dist/decoder/constants/choseong.d.ts +1 -0
  8. package/dist/decoder/constants/index.d.ts +10 -0
  9. package/dist/decoder/constants/indicators.d.ts +6 -0
  10. package/dist/decoder/constants/jongseong.d.ts +1 -0
  11. package/dist/decoder/constants/jungsong.d.ts +1 -0
  12. package/dist/decoder/constants/number.d.ts +1 -0
  13. package/dist/decoder/constants/symbol.d.ts +1 -0
  14. package/dist/decoder/constants/utils.d.ts +3 -0
  15. package/dist/decoder/index.d.ts +51 -0
  16. package/dist/decoder/index.test.d.ts +1 -0
  17. package/dist/index.cjs +770 -0
  18. package/dist/index.d.ts +53 -0
  19. package/dist/index.mjs +768 -0
  20. package/package.json +38 -0
  21. package/rollup.config.js +18 -0
  22. package/src/decoder/constants/char_english.ts +30 -0
  23. package/src/decoder/constants/char_shortcut.ts +33 -0
  24. package/src/decoder/constants/choseong.ts +18 -0
  25. package/src/decoder/constants/index.ts +110 -0
  26. package/src/decoder/constants/indicators.ts +8 -0
  27. package/src/decoder/constants/jongseong.ts +19 -0
  28. package/src/decoder/constants/jungsong.ts +25 -0
  29. package/src/decoder/constants/number.ts +14 -0
  30. package/src/decoder/constants/symbol.ts +34 -0
  31. package/src/decoder/constants/utils.ts +14 -0
  32. package/src/decoder/index.test.ts +77 -0
  33. package/src/decoder/index.ts +527 -0
  34. package/src/index.ts +3 -0
  35. package/tsconfig.json +16 -0
  36. package/vitest.config.ts +8 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,768 @@
1
+ const BRAILLE_UNICODE_START = 0x2800;
2
+ function decodeUnicode(char) {
3
+ const code = char.charCodeAt(0);
4
+ if (code >= BRAILLE_UNICODE_START && code <= BRAILLE_UNICODE_START + 0x3f) {
5
+ return code - BRAILLE_UNICODE_START;
6
+ }
7
+ return -1;
8
+ }
9
+
10
+ const KOREAN_CHOSEONG = {
11
+ [decodeUnicode('⠈')]: 'ㄱ',
12
+ [decodeUnicode('⠉')]: 'ㄴ',
13
+ [decodeUnicode('⠊')]: 'ㄷ',
14
+ [decodeUnicode('⠐')]: 'ㄹ',
15
+ [decodeUnicode('⠑')]: 'ㅁ',
16
+ [decodeUnicode('⠘')]: 'ㅂ',
17
+ [decodeUnicode('⠠')]: 'ㅅ',
18
+ // [decodeUnicode('')]: 'ㅇ', // skip ㅇ of choseong
19
+ [decodeUnicode('⠨')]: 'ㅈ',
20
+ [decodeUnicode('⠰')]: 'ㅊ',
21
+ [decodeUnicode('⠋')]: 'ㅋ',
22
+ [decodeUnicode('⠓')]: 'ㅌ',
23
+ [decodeUnicode('⠙')]: 'ㅍ',
24
+ [decodeUnicode('⠚')]: 'ㅎ',
25
+ };
26
+
27
+ const KOREAN_JUNGSEONG = {
28
+ [`${decodeUnicode('⠣')}`]: 'ㅏ',
29
+ [`${decodeUnicode('⠜')}`]: 'ㅑ',
30
+ [`${decodeUnicode('⠎')}`]: 'ㅓ',
31
+ [`${decodeUnicode('⠱')}`]: 'ㅕ',
32
+ [`${decodeUnicode('⠥')}`]: 'ㅗ',
33
+ [`${decodeUnicode('⠬')}`]: 'ㅛ',
34
+ [`${decodeUnicode('⠍')}`]: 'ㅜ',
35
+ [`${decodeUnicode('⠩')}`]: 'ㅠ',
36
+ [`${decodeUnicode('⠪')}`]: 'ㅡ',
37
+ [`${decodeUnicode('⠕')}`]: 'ㅣ',
38
+ [`${decodeUnicode('⠗')}`]: 'ㅐ',
39
+ [`${decodeUnicode('⠝')}`]: 'ㅔ',
40
+ [`${decodeUnicode('⠽')}`]: 'ㅚ',
41
+ [`${decodeUnicode('⠧')}`]: 'ㅘ',
42
+ [`${decodeUnicode('⠏')}`]: 'ㅝ',
43
+ [`${decodeUnicode('⠺')}`]: 'ㅢ',
44
+ [`${decodeUnicode('⠌')}`]: 'ㅖ',
45
+ [`${decodeUnicode('⠍')},${decodeUnicode('⠗')}`]: 'ㅟ',
46
+ [`${decodeUnicode('⠜')},${decodeUnicode('⠗')}`]: 'ㅒ',
47
+ [`${decodeUnicode('⠧')},${decodeUnicode('⠗')}`]: 'ㅙ',
48
+ [`${decodeUnicode('⠏')},${decodeUnicode('⠗')}`]: 'ㅞ',
49
+ };
50
+
51
+ const KOREAN_JONGSEONG = {
52
+ [decodeUnicode('⠁')]: 'ㄱ',
53
+ [decodeUnicode('⠒')]: 'ㄴ',
54
+ [decodeUnicode('⠔')]: 'ㄷ',
55
+ [decodeUnicode('⠂')]: 'ㄹ',
56
+ [decodeUnicode('⠢')]: 'ㅁ',
57
+ [decodeUnicode('⠃')]: 'ㅂ',
58
+ [decodeUnicode('⠄')]: 'ㅅ',
59
+ [decodeUnicode('⠌')]: 'ㅆ',
60
+ [decodeUnicode('⠶')]: 'ㅇ',
61
+ [decodeUnicode('⠅')]: 'ㅈ',
62
+ [decodeUnicode('⠆')]: 'ㅊ',
63
+ [decodeUnicode('⠖')]: 'ㅋ',
64
+ [decodeUnicode('⠦')]: 'ㅌ',
65
+ [decodeUnicode('⠲')]: 'ㅍ',
66
+ [decodeUnicode('⠴')]: 'ㅎ',
67
+ };
68
+
69
+ const KOREAN_SHORTCUTS = {
70
+ [`${decodeUnicode('⠫')}`]: '가',
71
+ [`${decodeUnicode('⠉')}`]: '나',
72
+ [`${decodeUnicode('⠊')}`]: '다',
73
+ [`${decodeUnicode('⠑')}`]: '마',
74
+ [`${decodeUnicode('⠘')}`]: '바',
75
+ [`${decodeUnicode('⠇')}`]: '사',
76
+ [`${decodeUnicode('⠨')}`]: '자',
77
+ [`${decodeUnicode('⠋')}`]: '카',
78
+ [`${decodeUnicode('⠓')}`]: '타',
79
+ [`${decodeUnicode('⠙')}`]: '파',
80
+ [`${decodeUnicode('⠚')}`]: '하',
81
+ [`${decodeUnicode('⠸')},${decodeUnicode('⠎')}`]: '것',
82
+ [`${decodeUnicode('⠹')}`]: '억',
83
+ [`${decodeUnicode('⠾')}`]: '언',
84
+ [`${decodeUnicode('⠞')}`]: '얼',
85
+ [`${decodeUnicode('⠡')}`]: '연',
86
+ [`${decodeUnicode('⠳')}`]: '열',
87
+ [`${decodeUnicode('⠻')}`]: '영',
88
+ [`${decodeUnicode('⠭')}`]: '옥',
89
+ [`${decodeUnicode('⠷')}`]: '온',
90
+ [`${decodeUnicode('⠿')}`]: '옹',
91
+ [`${decodeUnicode('⠛')}`]: '운',
92
+ [`${decodeUnicode('⠯')}`]: '울',
93
+ [`${decodeUnicode('⠵')}`]: '은',
94
+ [`${decodeUnicode('⠮')}`]: '을',
95
+ [`${decodeUnicode('⠟')}`]: '인',
96
+ [`${decodeUnicode('⠠')},${decodeUnicode('⠻')}`]: '성',
97
+ [`${decodeUnicode('⠨')},${decodeUnicode('⠻')}`]: '정',
98
+ [`${decodeUnicode('⠰')},${decodeUnicode('⠻')}`]: '청',
99
+ };
100
+
101
+ const ENGLISH_ALPHABET = {
102
+ [decodeUnicode('⠁')]: 'a',
103
+ [decodeUnicode('⠃')]: 'b',
104
+ [decodeUnicode('⠉')]: 'c',
105
+ [decodeUnicode('⠙')]: 'd',
106
+ [decodeUnicode('⠑')]: 'e',
107
+ [decodeUnicode('⠋')]: 'f',
108
+ [decodeUnicode('⠛')]: 'g',
109
+ [decodeUnicode('⠓')]: 'h',
110
+ [decodeUnicode('⠊')]: 'i',
111
+ [decodeUnicode('⠚')]: 'j',
112
+ [decodeUnicode('⠅')]: 'k',
113
+ [decodeUnicode('⠇')]: 'l',
114
+ [decodeUnicode('⠍')]: 'm',
115
+ [decodeUnicode('⠝')]: 'n',
116
+ [decodeUnicode('⠕')]: 'o',
117
+ [decodeUnicode('⠏')]: 'p',
118
+ [decodeUnicode('⠟')]: 'q',
119
+ [decodeUnicode('⠗')]: 'r',
120
+ [decodeUnicode('⠎')]: 's',
121
+ [decodeUnicode('⠞')]: 't',
122
+ [decodeUnicode('⠥')]: 'u',
123
+ [decodeUnicode('⠧')]: 'v',
124
+ [decodeUnicode('⠺')]: 'w',
125
+ [decodeUnicode('⠭')]: 'x',
126
+ [decodeUnicode('⠽')]: 'y',
127
+ [decodeUnicode('⠵')]: 'z',
128
+ };
129
+
130
+ const NUMBERS = {
131
+ [decodeUnicode('⠁')]: '1',
132
+ [decodeUnicode('⠃')]: '2',
133
+ [decodeUnicode('⠉')]: '3',
134
+ [decodeUnicode('⠙')]: '4',
135
+ [decodeUnicode('⠑')]: '5',
136
+ [decodeUnicode('⠋')]: '6',
137
+ [decodeUnicode('⠛')]: '7',
138
+ [decodeUnicode('⠓')]: '8',
139
+ [decodeUnicode('⠊')]: '9',
140
+ [decodeUnicode('⠚')]: '0',
141
+ };
142
+
143
+ const SYMBOLS = {
144
+ [`${decodeUnicode('⠖')}`]: '!',
145
+ [`${decodeUnicode('⠲')}`]: '.',
146
+ [`${decodeUnicode('⠐')}`]: ',',
147
+ [`${decodeUnicode('⠦')}`]: '?',
148
+ [`${decodeUnicode('⠐')},${decodeUnicode('⠂')}`]: ':',
149
+ [`${decodeUnicode('⠰')},${decodeUnicode('⠆')}`]: ';',
150
+ [`${decodeUnicode('⠤')}`]: '-',
151
+ [`${decodeUnicode('⠤')},${decodeUnicode('⠤')}`]: '―',
152
+ [`${decodeUnicode('⠦')}`]: '"', // opening
153
+ [`${decodeUnicode('⠴')}`]: '"', // closing
154
+ [`${decodeUnicode('⠠')},${decodeUnicode('⠦')}`]: "'",
155
+ [`${decodeUnicode('⠈')},${decodeUnicode('⠔')}`]: '~',
156
+ [`${decodeUnicode('⠲')},${decodeUnicode('⠲')},${decodeUnicode('⠲')}`]: '…',
157
+ [`${decodeUnicode('⠠')},${decodeUnicode('⠠')},${decodeUnicode('⠠')}`]: '⋯',
158
+ [`${decodeUnicode('⠦')},${decodeUnicode('⠄')}`]: '(',
159
+ [`${decodeUnicode('⠠')},${decodeUnicode('⠴')}`]: ')',
160
+ [`${decodeUnicode('⠦')},${decodeUnicode('⠂')}`]: '{',
161
+ [`${decodeUnicode('⠐')},${decodeUnicode('⠴')}`]: '}',
162
+ [`${decodeUnicode('⠦')},${decodeUnicode('⠆')}`]: '[',
163
+ [`${decodeUnicode('⠰')},${decodeUnicode('⠴')}`]: ']',
164
+ [`${decodeUnicode('⠐')},${decodeUnicode('⠆')}`]: '·',
165
+ [`${decodeUnicode('⠐')},${decodeUnicode('⠦')}`]: '「',
166
+ [`${decodeUnicode('⠴')},${decodeUnicode('⠂')}`]: '」',
167
+ [`${decodeUnicode('⠰')},${decodeUnicode('⠦')}`]: '『',
168
+ [`${decodeUnicode('⠴')},${decodeUnicode('⠆')}`]: '』',
169
+ [`${decodeUnicode('⠸')},${decodeUnicode('⠌')}`]: '/',
170
+ [`${decodeUnicode('⠐')},${decodeUnicode('⠶')}`]: '〈',
171
+ [`${decodeUnicode('⠶')},${decodeUnicode('⠂')}`]: '〉',
172
+ [`${decodeUnicode('⠰')},${decodeUnicode('⠶')}`]: '《',
173
+ [`${decodeUnicode('⠶')},${decodeUnicode('⠆')}`]: '》',
174
+ };
175
+
176
+ const NUMBER_INDICATOR = decodeUnicode('⠼');
177
+ const ENGLISH_INDICATOR = decodeUnicode('⠴');
178
+ const ENGLISH_TERMINATOR = decodeUnicode('⠲');
179
+ const UPPERCASE_INDICATOR = decodeUnicode('⠠');
180
+ const KOREAN_PART_INDICATOR = decodeUnicode('⠿');
181
+ const KOREAN_CONSONANT_INDICATOR = decodeUnicode('⠸');
182
+
183
+ // ASCII Braille to Unicode mapping (Standard North American Braille ASCII)
184
+ // Reference: https://en.wikipedia.org/wiki/Braille_ASCII
185
+ const ASCII_BRAILLE_MAP = {
186
+ ' ': decodeUnicode('⠀'),
187
+ '!': decodeUnicode('⠮'),
188
+ '"': decodeUnicode('⠐'),
189
+ '#': decodeUnicode('⠼'),
190
+ '$': decodeUnicode('⠫'),
191
+ '%': decodeUnicode('⠩'),
192
+ '&': decodeUnicode('⠯'),
193
+ '\'': decodeUnicode('⠄'),
194
+ '(': decodeUnicode('⠷'),
195
+ ')': decodeUnicode('⠾'),
196
+ '*': decodeUnicode('⠡'),
197
+ '+': decodeUnicode('⠬'),
198
+ ',': decodeUnicode('⠠'),
199
+ '-': decodeUnicode('⠤'),
200
+ '.': decodeUnicode('⠨'),
201
+ '/': decodeUnicode('⠌'),
202
+ '0': decodeUnicode('⠴'),
203
+ '1': decodeUnicode('⠂'),
204
+ '2': decodeUnicode('⠆'),
205
+ '3': decodeUnicode('⠒'),
206
+ '4': decodeUnicode('⠲'),
207
+ '5': decodeUnicode('⠢'),
208
+ '6': decodeUnicode('⠖'),
209
+ '7': decodeUnicode('⠶'),
210
+ '8': decodeUnicode('⠦'),
211
+ '9': decodeUnicode('⠔'),
212
+ ':': decodeUnicode('⠱'),
213
+ ';': decodeUnicode('⠰'),
214
+ '<': decodeUnicode('⠣'),
215
+ '=': decodeUnicode('⠿'),
216
+ '>': decodeUnicode('⠜'),
217
+ '?': decodeUnicode('⠹'),
218
+ '@': decodeUnicode('⠈'),
219
+ 'A': decodeUnicode('⠁'),
220
+ 'B': decodeUnicode('⠃'),
221
+ 'C': decodeUnicode('⠉'),
222
+ 'D': decodeUnicode('⠙'),
223
+ 'E': decodeUnicode('⠑'),
224
+ 'F': decodeUnicode('⠋'),
225
+ 'G': decodeUnicode('⠛'),
226
+ 'H': decodeUnicode('⠓'),
227
+ 'I': decodeUnicode('⠊'),
228
+ 'J': decodeUnicode('⠚'),
229
+ 'K': decodeUnicode('⠅'),
230
+ 'L': decodeUnicode('⠇'),
231
+ 'M': decodeUnicode('⠍'),
232
+ 'N': decodeUnicode('⠝'),
233
+ 'O': decodeUnicode('⠕'),
234
+ 'P': decodeUnicode('⠏'),
235
+ 'Q': decodeUnicode('⠟'),
236
+ 'R': decodeUnicode('⠗'),
237
+ 'S': decodeUnicode('⠎'),
238
+ 'T': decodeUnicode('⠞'),
239
+ 'U': decodeUnicode('⠥'),
240
+ 'V': decodeUnicode('⠧'),
241
+ 'W': decodeUnicode('⠺'),
242
+ 'X': decodeUnicode('⠭'),
243
+ 'Y': decodeUnicode('⠽'),
244
+ 'Z': decodeUnicode('⠵'),
245
+ '[': decodeUnicode('⠪'),
246
+ '\\': decodeUnicode('⠳'),
247
+ ']': decodeUnicode('⠻'),
248
+ '^': decodeUnicode('⠘'),
249
+ '_': decodeUnicode('⠸'),
250
+ a: decodeUnicode('⠁'),
251
+ b: decodeUnicode('⠃'),
252
+ c: decodeUnicode('⠉'),
253
+ d: decodeUnicode('⠙'),
254
+ e: decodeUnicode('⠑'),
255
+ f: decodeUnicode('⠋'),
256
+ g: decodeUnicode('⠛'),
257
+ h: decodeUnicode('⠓'),
258
+ i: decodeUnicode('⠊'),
259
+ j: decodeUnicode('⠚'),
260
+ k: decodeUnicode('⠅'),
261
+ l: decodeUnicode('⠇'),
262
+ m: decodeUnicode('⠍'),
263
+ n: decodeUnicode('⠝'),
264
+ o: decodeUnicode('⠕'),
265
+ p: decodeUnicode('⠏'),
266
+ q: decodeUnicode('⠟'),
267
+ r: decodeUnicode('⠗'),
268
+ s: decodeUnicode('⠎'),
269
+ t: decodeUnicode('⠞'),
270
+ u: decodeUnicode('⠥'),
271
+ v: decodeUnicode('⠧'),
272
+ w: decodeUnicode('⠺'),
273
+ x: decodeUnicode('⠭'),
274
+ y: decodeUnicode('⠽'),
275
+ z: decodeUnicode('⠵'),
276
+ '{': decodeUnicode('⠪'),
277
+ '|': decodeUnicode('⠳'),
278
+ '}': decodeUnicode('⠻'),
279
+ '~': decodeUnicode('⠘'),
280
+ };
281
+
282
+ /**
283
+ * Braille Decoder
284
+ * Supports English, Special Characters, and Korean.
285
+ * Reference: braillify/libs/braillify/src
286
+ */
287
+ class Decoder {
288
+ // Pre-computed lookup tables for efficient access
289
+ jungseongMap = new Map();
290
+ shortcutMap = new Map();
291
+ symbolMap = new Map();
292
+ // Korean composition maps
293
+ CHOSEONG = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
294
+ JUNGSEONG = 'ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ';
295
+ JONGSEONG = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
296
+ choMap = {};
297
+ jungMap = {};
298
+ jongMap = {};
299
+ constructor() {
300
+ this.initializeLookupTables();
301
+ this.CHOSEONG.split('').forEach((c, i) => this.choMap[c] = i);
302
+ this.JUNGSEONG.split('').forEach((c, i) => this.jungMap[c] = i);
303
+ this.JONGSEONG.forEach((c, i) => this.jongMap[c] = i);
304
+ }
305
+ /**
306
+ * Initialize lookup tables from constants for efficient decoding
307
+ */
308
+ initializeLookupTables() {
309
+ // Build jungseong lookup table
310
+ for (const [key, text] of Object.entries(KOREAN_JUNGSEONG)) {
311
+ const dots = key.split(',').map(Number);
312
+ const firstDot = dots[0].toString();
313
+ if (!this.jungseongMap.has(firstDot)) {
314
+ this.jungseongMap.set(firstDot, []);
315
+ }
316
+ this.jungseongMap.get(firstDot).push({ text, len: dots.length, key });
317
+ }
318
+ // Sort by length descending (try longer patterns first)
319
+ for (const entries of this.jungseongMap.values()) {
320
+ entries.sort((a, b) => b.len - a.len);
321
+ }
322
+ // Build shortcut lookup table
323
+ for (const [key, text] of Object.entries(KOREAN_SHORTCUTS)) {
324
+ const dots = key.split(',').map(Number);
325
+ const firstDot = dots[0].toString();
326
+ if (!this.shortcutMap.has(firstDot)) {
327
+ this.shortcutMap.set(firstDot, []);
328
+ }
329
+ this.shortcutMap.get(firstDot).push({ text, len: dots.length, key });
330
+ }
331
+ for (const entries of this.shortcutMap.values()) {
332
+ entries.sort((a, b) => b.len - a.len);
333
+ }
334
+ // Build symbol lookup table
335
+ for (const [key, text] of Object.entries(SYMBOLS)) {
336
+ const dots = key.split(',').map(Number);
337
+ const firstDot = dots[0].toString();
338
+ if (!this.symbolMap.has(firstDot)) {
339
+ this.symbolMap.set(firstDot, []);
340
+ }
341
+ this.symbolMap.get(firstDot).push({ text, len: dots.length, key });
342
+ }
343
+ for (const entries of this.symbolMap.values()) {
344
+ entries.sort((a, b) => b.len - a.len);
345
+ }
346
+ }
347
+ /**
348
+ * Converts ASCII Braille (BRL) to Unicode Braille.
349
+ * Example: asciiBrailleToUnicode('abcd') -> '⠁⠃⠉⠙'
350
+ */
351
+ asciiBrailleToUnicode(input) {
352
+ return input
353
+ .split('')
354
+ .map((char) => {
355
+ const dotPattern = ASCII_BRAILLE_MAP[char];
356
+ if (dotPattern === undefined)
357
+ return char;
358
+ return String.fromCharCode(BRAILLE_UNICODE_START + dotPattern);
359
+ })
360
+ .join('');
361
+ }
362
+ /**
363
+ * Translates Unicode Braille to Text.
364
+ * Supports Korean, English, Numbers, and Symbols.
365
+ */
366
+ translateToText(input) {
367
+ return input.split('\n').map(v => this.translateToTextOneLine(v)).join('\n');
368
+ }
369
+ /**
370
+ * Compose Korean syllable from jamo
371
+ */
372
+ composeKoreanSyllable(cho, jung, jong = '') {
373
+ const choIdx = this.choMap[cho];
374
+ const jungIdx = this.jungMap[jung];
375
+ const jongIdx = this.jongMap[jong] || 0;
376
+ if (choIdx !== undefined && jungIdx !== undefined) {
377
+ return String.fromCharCode(0xAC00 + (choIdx * 21 + jungIdx) * 28 + jongIdx);
378
+ }
379
+ return cho + jung + jong;
380
+ }
381
+ /**
382
+ * Decompose Korean syllable to jamo
383
+ */
384
+ decomposeSyllable(syllable) {
385
+ const code = syllable.charCodeAt(0);
386
+ if (code >= 0xAC00 && code <= 0xD7A3) {
387
+ const offset = code - 0xAC00;
388
+ const choIdx = Math.floor(offset / (21 * 28));
389
+ const jungIdx = Math.floor((offset % (21 * 28)) / 28);
390
+ const jongIdx = offset % 28;
391
+ return [this.CHOSEONG[choIdx], this.JUNGSEONG[jungIdx], this.JONGSEONG[jongIdx]];
392
+ }
393
+ return null;
394
+ }
395
+ /**
396
+ * Add jongseong to existing syllable
397
+ */
398
+ addJongseongToSyllable(syllable, jong) {
399
+ const decomposed = this.decomposeSyllable(syllable);
400
+ if (decomposed) {
401
+ const [cho, jung, _] = decomposed;
402
+ return this.composeKoreanSyllable(cho, jung, jong);
403
+ }
404
+ return syllable + jong;
405
+ }
406
+ /**
407
+ * Translates Unicode Braille to Text (single line).
408
+ * Supports Korean, English, Numbers, and Symbols.
409
+ */
410
+ translateToTextOneLine(input) {
411
+ const dots = input.split('').map((char) => {
412
+ const code = char.charCodeAt(0);
413
+ if (code >= BRAILLE_UNICODE_START && code <= BRAILLE_UNICODE_START + 0x3f) {
414
+ return code - BRAILLE_UNICODE_START;
415
+ }
416
+ if (char === '\n')
417
+ return 255;
418
+ return -1; // Not a braille character
419
+ });
420
+ let result = '';
421
+ let i = 0;
422
+ let isEnglishMode = false;
423
+ let isNumberMode = false;
424
+ let pendingKoreanCho = ''; // Pending Korean choseong
425
+ let pendingKoreanJung = ''; // Pending Korean jungseong
426
+ const flushPendingKorean = () => {
427
+ if (pendingKoreanCho && pendingKoreanJung) {
428
+ result += this.composeKoreanSyllable(pendingKoreanCho, pendingKoreanJung);
429
+ pendingKoreanCho = '';
430
+ pendingKoreanJung = '';
431
+ }
432
+ else if (pendingKoreanCho) {
433
+ result += pendingKoreanCho;
434
+ pendingKoreanCho = '';
435
+ }
436
+ else if (pendingKoreanJung) {
437
+ result += this.composeKoreanSyllable('ㅇ', pendingKoreanJung);
438
+ pendingKoreanJung = '';
439
+ }
440
+ };
441
+ while (i < dots.length) {
442
+ const dot = dots[i];
443
+ if (dot === -1) {
444
+ flushPendingKorean();
445
+ result += input[i];
446
+ isNumberMode = false;
447
+ i++;
448
+ continue;
449
+ }
450
+ if (dot === 255) {
451
+ flushPendingKorean();
452
+ result += '\n';
453
+ isNumberMode = false;
454
+ i++;
455
+ continue;
456
+ }
457
+ // Indicators
458
+ if (dot === NUMBER_INDICATOR) {
459
+ flushPendingKorean();
460
+ isNumberMode = true;
461
+ i++;
462
+ continue;
463
+ }
464
+ if (dot === ENGLISH_INDICATOR) {
465
+ flushPendingKorean();
466
+ isEnglishMode = true;
467
+ i++;
468
+ continue;
469
+ }
470
+ if (dot === ENGLISH_TERMINATOR && isEnglishMode) {
471
+ isEnglishMode = false;
472
+ i++;
473
+ continue;
474
+ }
475
+ if (dot === 0) {
476
+ // Space
477
+ flushPendingKorean();
478
+ // Check if we should skip space after number mode before Korean text (not English)
479
+ if (isNumberMode && i + 1 < dots.length) {
480
+ const nextDot = dots[i + 1];
481
+ // Check if next is Korean (shortcut, choseong, jungseong, or jongseong)
482
+ // But NOT English indicator
483
+ const isNextKorean = (this.matchShortcut(dots, i + 1) !== null ||
484
+ KOREAN_CHOSEONG[nextDot] !== undefined ||
485
+ this.matchJungseong(dots, i + 1) !== null ||
486
+ KOREAN_JONGSEONG[nextDot] !== undefined) &&
487
+ nextDot !== ENGLISH_INDICATOR;
488
+ if (isNextKorean) {
489
+ // Skip space between number and Korean
490
+ isNumberMode = false;
491
+ i++;
492
+ continue;
493
+ }
494
+ }
495
+ result += ' ';
496
+ isNumberMode = false;
497
+ i++;
498
+ continue;
499
+ }
500
+ // Number Mode
501
+ if (isNumberMode) {
502
+ if (NUMBERS[dot]) {
503
+ result += NUMBERS[dot];
504
+ i++;
505
+ continue;
506
+ }
507
+ else if (dot === 2) { // Comma in number mode
508
+ result += ',';
509
+ i++;
510
+ continue;
511
+ }
512
+ else if (dot === 50) { // Dot in number mode
513
+ result += '.';
514
+ i++;
515
+ continue;
516
+ }
517
+ else if (dot === 36) { // Hyphen in number mode
518
+ result += '‐'; // U+2010
519
+ i++;
520
+ continue;
521
+ }
522
+ else if (dot === KOREAN_CONSONANT_INDICATOR) {
523
+ // ⠸ in number mode starts asterisk sequence
524
+ i++;
525
+ // All following ⠢ are asterisks
526
+ while (i < dots.length && dots[i] === 34) { // ⠢
527
+ result += '∗'; // U+2217
528
+ i++;
529
+ }
530
+ continue;
531
+ }
532
+ else if (dot === 7) { // ⠇ in number mode (end marker?)
533
+ // Skip or handle as needed
534
+ i++;
535
+ continue;
536
+ }
537
+ else {
538
+ isNumberMode = false;
539
+ // Fall through
540
+ }
541
+ }
542
+ // English Mode
543
+ if (isEnglishMode) {
544
+ let isUpper = false;
545
+ if (dot === UPPERCASE_INDICATOR) {
546
+ isUpper = true;
547
+ i++;
548
+ // Check for double uppercase (word)
549
+ if (i < dots.length && dots[i] === UPPERCASE_INDICATOR) {
550
+ i++;
551
+ // Word uppercase
552
+ while (i < dots.length && dots[i] !== 0 && dots[i] !== ENGLISH_TERMINATOR) {
553
+ const d = dots[i];
554
+ const char = ENGLISH_ALPHABET[d];
555
+ if (char) {
556
+ result += char.toUpperCase();
557
+ }
558
+ else {
559
+ // Try symbols in English mode
560
+ const sym = this.matchSymbol(dots, i);
561
+ if (sym) {
562
+ result += sym.text;
563
+ i += sym.len - 1;
564
+ }
565
+ }
566
+ i++;
567
+ }
568
+ continue;
569
+ }
570
+ }
571
+ const charDot = isUpper ? dots[i] : dot;
572
+ const char = ENGLISH_ALPHABET[charDot];
573
+ if (char) {
574
+ result += isUpper ? char.toUpperCase() : char;
575
+ i++;
576
+ continue;
577
+ }
578
+ }
579
+ // Symbols (Check multi-dot symbols first)
580
+ // But check if this could be Korean choseong followed by jungseong or shortcut
581
+ const sym = this.matchSymbol(dots, i);
582
+ if (sym) {
583
+ // Check if this dot is also a choseong and followed by jungseong or shortcut
584
+ if (KOREAN_CHOSEONG[dot] && i + 1 < dots.length) {
585
+ const nextJung = this.matchJungseong(dots, i + 1);
586
+ const nextShortcut = this.matchShortcut(dots, i + 1);
587
+ if (nextJung || nextShortcut) {
588
+ // This is choseong, not symbol
589
+ flushPendingKorean();
590
+ pendingKoreanCho = KOREAN_CHOSEONG[dot];
591
+ i++;
592
+ continue;
593
+ }
594
+ }
595
+ flushPendingKorean();
596
+ result += sym.text;
597
+ i += sym.len;
598
+ continue;
599
+ }
600
+ // Korean Mode
601
+ // 1. Shortcuts (Check multi-dot shortcuts first)
602
+ const shortcut = this.matchShortcut(dots, i);
603
+ if (shortcut) {
604
+ // Check if there's a pending choseong (e.g., ㅅ + 옥 = 속, ㄴ + 영 = 녕)
605
+ if (pendingKoreanCho) {
606
+ // Decompose shortcut and combine with pending choseong
607
+ const decomposed = this.decomposeSyllable(shortcut.text);
608
+ if (decomposed && decomposed[0] === 'ㅇ') {
609
+ // Replace ㅇ with pending choseong
610
+ result += this.composeKoreanSyllable(pendingKoreanCho, decomposed[1], decomposed[2]);
611
+ pendingKoreanCho = '';
612
+ pendingKoreanJung = '';
613
+ i += shortcut.len;
614
+ continue;
615
+ }
616
+ }
617
+ // Check if this is a single-char shortcut that could be choseong
618
+ const singleCharShortcuts = ['가', '나', '다', '마', '바', '사', '자', '카', '타', '파', '하'];
619
+ if (shortcut.len === 1 && singleCharShortcuts.includes(shortcut.text)) {
620
+ // Check if next is a shortcut or jungseong (NOT jongseong)
621
+ if (i + 1 < dots.length) {
622
+ const nextShortcut = this.matchShortcut(dots, i + 1);
623
+ const nextJung = this.matchJungseong(dots, i + 1);
624
+ // If followed by shortcut or jungseong, interpret as choseong
625
+ if (nextShortcut || nextJung) {
626
+ if (KOREAN_CHOSEONG[dot]) {
627
+ flushPendingKorean();
628
+ pendingKoreanCho = KOREAN_CHOSEONG[dot];
629
+ i++;
630
+ continue;
631
+ }
632
+ }
633
+ }
634
+ }
635
+ // Shortcut is a complete syllable
636
+ // Check if next is jongseong
637
+ if (i + 1 < dots.length && KOREAN_JONGSEONG[dots[i + 1]]) {
638
+ // Add jongseong to shortcut
639
+ flushPendingKorean();
640
+ result += this.addJongseongToSyllable(shortcut.text, KOREAN_JONGSEONG[dots[i + 1]]);
641
+ i += shortcut.len + 1;
642
+ continue;
643
+ }
644
+ flushPendingKorean();
645
+ result += shortcut.text;
646
+ i += shortcut.len;
647
+ continue;
648
+ }
649
+ // 2. Choseong
650
+ if (KOREAN_CHOSEONG[dot]) {
651
+ flushPendingKorean();
652
+ pendingKoreanCho = KOREAN_CHOSEONG[dot];
653
+ i++;
654
+ continue;
655
+ }
656
+ // 3. Jungseong
657
+ const jung = this.matchJungseong(dots, i);
658
+ if (jung) {
659
+ if (pendingKoreanCho && !pendingKoreanJung) {
660
+ // Choseong + Jungseong
661
+ pendingKoreanJung = jung.text;
662
+ }
663
+ else if (pendingKoreanCho && pendingKoreanJung) {
664
+ // Already have cho + jung, flush and start new syllable
665
+ flushPendingKorean();
666
+ pendingKoreanCho = 'ㅇ';
667
+ pendingKoreanJung = jung.text;
668
+ }
669
+ else {
670
+ // Jungseong alone (implicit ㅇ)
671
+ flushPendingKorean();
672
+ pendingKoreanCho = 'ㅇ';
673
+ pendingKoreanJung = jung.text;
674
+ }
675
+ i += jung.len;
676
+ continue;
677
+ }
678
+ // 4. Jongseong
679
+ if (KOREAN_JONGSEONG[dot]) {
680
+ if (pendingKoreanCho && pendingKoreanJung) {
681
+ // Complete syllable with jongseong
682
+ result += this.composeKoreanSyllable(pendingKoreanCho, pendingKoreanJung, KOREAN_JONGSEONG[dot]);
683
+ pendingKoreanCho = '';
684
+ pendingKoreanJung = '';
685
+ }
686
+ else {
687
+ // Jongseong without pending syllable
688
+ flushPendingKorean();
689
+ result += KOREAN_JONGSEONG[dot];
690
+ }
691
+ i++;
692
+ continue;
693
+ }
694
+ // 5. Korean Part/Consonant Indicators
695
+ if (dot === KOREAN_PART_INDICATOR || dot === KOREAN_CONSONANT_INDICATOR) {
696
+ flushPendingKorean();
697
+ i++;
698
+ if (i < dots.length) {
699
+ const nextDot = dots[i];
700
+ // Try to find in choseong or jongseong maps
701
+ const char = KOREAN_CHOSEONG[nextDot] || KOREAN_JONGSEONG[nextDot];
702
+ if (char) {
703
+ result += char;
704
+ i++;
705
+ continue;
706
+ }
707
+ }
708
+ continue;
709
+ }
710
+ // Unknown pattern
711
+ flushPendingKorean();
712
+ i++;
713
+ }
714
+ flushPendingKorean();
715
+ return result;
716
+ }
717
+ matchJungseong(dots, index) {
718
+ const firstDot = dots[index].toString();
719
+ const candidates = this.jungseongMap.get(firstDot);
720
+ if (!candidates)
721
+ return null;
722
+ // Try patterns from longest to shortest
723
+ for (const candidate of candidates) {
724
+ if (index + candidate.len <= dots.length) {
725
+ // Build the key to match
726
+ const key = dots.slice(index, index + candidate.len).join(',');
727
+ if (candidate.key === key) {
728
+ return { text: candidate.text, len: candidate.len };
729
+ }
730
+ }
731
+ }
732
+ return null;
733
+ }
734
+ matchShortcut(dots, index) {
735
+ const firstDot = dots[index].toString();
736
+ const candidates = this.shortcutMap.get(firstDot);
737
+ if (!candidates)
738
+ return null;
739
+ // Try patterns from longest to shortest
740
+ for (const candidate of candidates) {
741
+ if (index + candidate.len <= dots.length) {
742
+ const key = dots.slice(index, index + candidate.len).join(',');
743
+ if (candidate.key === key) {
744
+ return { text: candidate.text, len: candidate.len };
745
+ }
746
+ }
747
+ }
748
+ return null;
749
+ }
750
+ matchSymbol(dots, index) {
751
+ const firstDot = dots[index].toString();
752
+ const candidates = this.symbolMap.get(firstDot);
753
+ if (!candidates)
754
+ return null;
755
+ // Try patterns from longest to shortest
756
+ for (const candidate of candidates) {
757
+ if (index + candidate.len <= dots.length) {
758
+ const key = dots.slice(index, index + candidate.len).join(',');
759
+ if (candidate.key === key) {
760
+ return { text: candidate.text, len: candidate.len };
761
+ }
762
+ }
763
+ }
764
+ return null;
765
+ }
766
+ }
767
+
768
+ export { Decoder };