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