@mlightcad/mtext-parser 1.3.3 → 1.4.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.
@@ -0,0 +1,1866 @@
1
+ import {
2
+ MTextParser,
3
+ MTextContext,
4
+ TokenType,
5
+ MTextLineAlignment,
6
+ MTextParagraphAlignment,
7
+ rgb2int,
8
+ int2rgb,
9
+ escapeDxfLineEndings,
10
+ hasInlineFormattingCodes,
11
+ TextScanner,
12
+ getFonts,
13
+ MTextColor,
14
+ } from './parser';
15
+
16
+ describe('Utility Functions', () => {
17
+ describe('rgb2int', () => {
18
+ it('converts RGB tuple to integer', () => {
19
+ expect(rgb2int([255, 0, 0])).toBe(0xff0000);
20
+ expect(rgb2int([0, 255, 0])).toBe(0x00ff00);
21
+ expect(rgb2int([0, 0, 255])).toBe(0x0000ff);
22
+ });
23
+ });
24
+
25
+ describe('int2rgb', () => {
26
+ it('converts integer to RGB tuple', () => {
27
+ expect(int2rgb(0xff0000)).toEqual([255, 0, 0]);
28
+ expect(int2rgb(0x00ff00)).toEqual([0, 255, 0]);
29
+ expect(int2rgb(0x0000ff)).toEqual([0, 0, 255]);
30
+ });
31
+ });
32
+
33
+ describe('escapeDxfLineEndings', () => {
34
+ it('escapes line endings', () => {
35
+ expect(escapeDxfLineEndings('line1\r\nline2')).toBe('line1\\Pline2');
36
+ expect(escapeDxfLineEndings('line1\nline2')).toBe('line1\\Pline2');
37
+ expect(escapeDxfLineEndings('line1\rline2')).toBe('line1\\Pline2');
38
+ });
39
+ });
40
+
41
+ describe('hasInlineFormattingCodes', () => {
42
+ it('detects inline formatting codes', () => {
43
+ expect(hasInlineFormattingCodes('\\L')).toBe(true);
44
+ expect(hasInlineFormattingCodes('\\P')).toBe(false);
45
+ expect(hasInlineFormattingCodes('\\~')).toBe(false);
46
+ expect(hasInlineFormattingCodes('normal text')).toBe(false);
47
+ });
48
+ });
49
+ });
50
+
51
+ describe('MTextContext', () => {
52
+ let ctx: MTextContext;
53
+
54
+ beforeEach(() => {
55
+ ctx = new MTextContext();
56
+ });
57
+
58
+ it('initializes with default values', () => {
59
+ expect(ctx.aci).toBe(256);
60
+ expect(ctx.rgb).toBeNull();
61
+ expect(ctx.align).toBe(MTextLineAlignment.BOTTOM);
62
+ expect(ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
63
+ expect(ctx.capHeight).toEqual({ value: 1.0, isRelative: false });
64
+ expect(ctx.widthFactor).toEqual({ value: 1.0, isRelative: false });
65
+ expect(ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false });
66
+ expect(ctx.oblique).toBe(0.0);
67
+ expect(ctx.paragraph).toEqual({
68
+ indent: 0,
69
+ left: 0,
70
+ right: 0,
71
+ align: MTextParagraphAlignment.DEFAULT,
72
+ tabs: [],
73
+ });
74
+ expect(ctx.bold).toBe(false);
75
+ expect(ctx.italic).toBe(false);
76
+ });
77
+
78
+ describe('italic and bold properties', () => {
79
+ it('should default to italic = false and bold = false', () => {
80
+ expect(ctx.italic).toBe(false);
81
+ expect(ctx.bold).toBe(false);
82
+ });
83
+
84
+ it('should set and get italic property', () => {
85
+ ctx.italic = true;
86
+ expect(ctx.italic).toBe(true);
87
+ expect(ctx.fontFace.style).toBe('Italic');
88
+ ctx.italic = false;
89
+ expect(ctx.italic).toBe(false);
90
+ expect(ctx.fontFace.style).toBe('Regular');
91
+ });
92
+
93
+ it('should set and get bold property', () => {
94
+ ctx.bold = true;
95
+ expect(ctx.bold).toBe(true);
96
+ expect(ctx.fontFace.weight).toBe(700);
97
+ ctx.bold = false;
98
+ expect(ctx.bold).toBe(false);
99
+ expect(ctx.fontFace.weight).toBe(400);
100
+ });
101
+
102
+ it('should reflect changes to fontFace.style and fontFace.weight', () => {
103
+ ctx.fontFace.style = 'Italic';
104
+ expect(ctx.italic).toBe(true);
105
+ ctx.fontFace.style = 'Regular';
106
+ expect(ctx.italic).toBe(false);
107
+ ctx.fontFace.weight = 700;
108
+ expect(ctx.bold).toBe(true);
109
+ ctx.fontFace.weight = 400;
110
+ expect(ctx.bold).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('stroke properties', () => {
115
+ it('handles underline', () => {
116
+ ctx.underline = true;
117
+ expect(ctx.underline).toBe(true);
118
+ expect(ctx.hasAnyStroke).toBe(true);
119
+ ctx.underline = false;
120
+ expect(ctx.underline).toBe(false);
121
+ expect(ctx.hasAnyStroke).toBe(false);
122
+ });
123
+
124
+ it('handles overline', () => {
125
+ ctx.overline = true;
126
+ expect(ctx.overline).toBe(true);
127
+ expect(ctx.hasAnyStroke).toBe(true);
128
+ ctx.overline = false;
129
+ expect(ctx.overline).toBe(false);
130
+ expect(ctx.hasAnyStroke).toBe(false);
131
+ });
132
+
133
+ it('handles strike-through', () => {
134
+ ctx.strikeThrough = true;
135
+ expect(ctx.strikeThrough).toBe(true);
136
+ expect(ctx.hasAnyStroke).toBe(true);
137
+ ctx.strikeThrough = false;
138
+ expect(ctx.strikeThrough).toBe(false);
139
+ expect(ctx.hasAnyStroke).toBe(false);
140
+ });
141
+
142
+ it('handles multiple strokes', () => {
143
+ ctx.underline = true;
144
+ ctx.overline = true;
145
+ expect(ctx.hasAnyStroke).toBe(true);
146
+ ctx.underline = false;
147
+ expect(ctx.hasAnyStroke).toBe(true);
148
+ ctx.overline = false;
149
+ expect(ctx.hasAnyStroke).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe('color properties', () => {
154
+ it('handles ACI color', () => {
155
+ ctx.aci = 1;
156
+ expect(ctx.aci).toBe(1);
157
+ expect(ctx.rgb).toBeNull();
158
+ expect(ctx.color.aci).toBe(1);
159
+ expect(ctx.color.rgb).toBeNull();
160
+ expect(ctx.color.rgbValue).toBeNull();
161
+ expect(() => (ctx.aci = 257)).toThrow('ACI not in range [0, 256]');
162
+ });
163
+
164
+ it('handles RGB color', () => {
165
+ ctx.rgb = [255, 0, 0];
166
+ expect(ctx.rgb).toEqual([255, 0, 0]);
167
+ expect(ctx.color.rgb).toEqual([255, 0, 0]);
168
+ expect(ctx.color.aci).toBeNull();
169
+ expect(ctx.color.rgbValue).toBe(0xff0000);
170
+ });
171
+
172
+ it('switches from RGB to ACI', () => {
173
+ ctx.rgb = [255, 0, 0];
174
+ ctx.aci = 2;
175
+ expect(ctx.rgb).toBeNull();
176
+ expect(ctx.aci).toBe(2);
177
+ expect(ctx.color.rgb).toBeNull();
178
+ expect(ctx.color.aci).toBe(2);
179
+ expect(ctx.color.rgbValue).toBeNull();
180
+ });
181
+
182
+ it('handles RGB value set directly', () => {
183
+ ctx.color.rgbValue = 0x00ff00;
184
+ expect(ctx.rgb).toEqual([0, 255, 0]);
185
+ expect(ctx.color.rgb).toEqual([0, 255, 0]);
186
+ expect(ctx.color.rgbValue).toBe(0x00ff00);
187
+ expect(ctx.color.aci).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe('copy', () => {
192
+ it('creates a deep copy', () => {
193
+ ctx.underline = true;
194
+ ctx.rgb = [255, 0, 0];
195
+ const copy = ctx.copy();
196
+ expect(copy).not.toBe(ctx);
197
+ expect(copy.underline).toBe(ctx.underline);
198
+ expect(copy.rgb).toEqual(ctx.rgb);
199
+ expect(copy.color.rgb).toEqual(ctx.color.rgb);
200
+ expect(copy.color.aci).toBe(ctx.color.aci);
201
+ expect(copy.color.rgbValue).toBe(ctx.color.rgbValue);
202
+ expect(copy.fontFace).toEqual(ctx.fontFace);
203
+ expect(copy.paragraph).toEqual(ctx.paragraph);
204
+ // Changing the copy's color should not affect the original
205
+ copy.rgb = [0, 255, 0];
206
+ expect(ctx.rgb).toEqual([255, 0, 0]);
207
+ expect(copy.rgb).toEqual([0, 255, 0]);
208
+ expect(copy.color.rgbValue).toBe(0x00ff00);
209
+ expect(ctx.color.rgbValue).toBe(0xff0000);
210
+ });
211
+ });
212
+
213
+ describe('factor properties', () => {
214
+ it('handles charTrackingFactor absolute values', () => {
215
+ ctx.charTrackingFactor = { value: 2.0, isRelative: false };
216
+ expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false });
217
+
218
+ ctx.charTrackingFactor = { value: 0.5, isRelative: false };
219
+ expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: false });
220
+ });
221
+
222
+ it('handles charTrackingFactor relative values', () => {
223
+ ctx.charTrackingFactor = { value: 2.0, isRelative: true };
224
+ expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: true });
225
+
226
+ ctx.charTrackingFactor = { value: 0.5, isRelative: true };
227
+ expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
228
+ });
229
+
230
+ it('converts negative values to positive for charTrackingFactor', () => {
231
+ ctx.charTrackingFactor = { value: -2.0, isRelative: false };
232
+ expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false });
233
+
234
+ ctx.charTrackingFactor = { value: -0.5, isRelative: true };
235
+ expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
236
+ });
237
+ });
238
+ });
239
+
240
+ describe('MTextParser', () => {
241
+ describe('basic parsing', () => {
242
+ it('parses plain text', () => {
243
+ const parser = new MTextParser('Hello World');
244
+ const tokens = Array.from(parser.parse());
245
+ expect(tokens).toHaveLength(3);
246
+ expect(tokens[0].type).toBe(TokenType.WORD);
247
+ expect(tokens[0].data).toBe('Hello');
248
+ expect(tokens[1].type).toBe(TokenType.SPACE);
249
+ expect(tokens[2].type).toBe(TokenType.WORD);
250
+ expect(tokens[2].data).toBe('World');
251
+ });
252
+
253
+ it('parses spaces', () => {
254
+ const parser = new MTextParser('Hello World');
255
+ const tokens = Array.from(parser.parse());
256
+ expect(tokens).toHaveLength(3);
257
+ expect(tokens[0].type).toBe(TokenType.WORD);
258
+ expect(tokens[0].data).toBe('Hello');
259
+ expect(tokens[1].type).toBe(TokenType.SPACE);
260
+ expect(tokens[2].type).toBe(TokenType.WORD);
261
+ expect(tokens[2].data).toBe('World');
262
+ });
263
+
264
+ it('parses text starting with control characters', () => {
265
+ // Test with newline
266
+ let parser = new MTextParser('\nHello World');
267
+ let tokens = Array.from(parser.parse());
268
+ expect(tokens).toHaveLength(4);
269
+ expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH);
270
+ expect(tokens[1].type).toBe(TokenType.WORD);
271
+ expect(tokens[1].data).toBe('Hello');
272
+ expect(tokens[2].type).toBe(TokenType.SPACE);
273
+ expect(tokens[3].type).toBe(TokenType.WORD);
274
+ expect(tokens[3].data).toBe('World');
275
+
276
+ // Test with tab
277
+ parser = new MTextParser('\tHello World');
278
+ tokens = Array.from(parser.parse());
279
+ expect(tokens).toHaveLength(4);
280
+ expect(tokens[0].type).toBe(TokenType.TABULATOR);
281
+ expect(tokens[1].type).toBe(TokenType.WORD);
282
+ expect(tokens[1].data).toBe('Hello');
283
+ expect(tokens[2].type).toBe(TokenType.SPACE);
284
+ expect(tokens[3].type).toBe(TokenType.WORD);
285
+ expect(tokens[3].data).toBe('World');
286
+
287
+ // Test with multiple control characters
288
+ parser = new MTextParser('\n\tHello World');
289
+ tokens = Array.from(parser.parse());
290
+ expect(tokens).toHaveLength(5);
291
+ expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH);
292
+ expect(tokens[1].type).toBe(TokenType.TABULATOR);
293
+ expect(tokens[2].type).toBe(TokenType.WORD);
294
+ expect(tokens[2].data).toBe('Hello');
295
+ expect(tokens[3].type).toBe(TokenType.SPACE);
296
+ expect(tokens[4].type).toBe(TokenType.WORD);
297
+ expect(tokens[4].data).toBe('World');
298
+ });
299
+
300
+ it('parses new paragraphs', () => {
301
+ const parser = new MTextParser('Line 1\\PLine 2');
302
+ const tokens = Array.from(parser.parse());
303
+ expect(tokens).toHaveLength(7);
304
+ expect(tokens[0].type).toBe(TokenType.WORD);
305
+ expect(tokens[0].data).toBe('Line');
306
+ expect(tokens[1].type).toBe(TokenType.SPACE);
307
+ expect(tokens[2].type).toBe(TokenType.WORD);
308
+ expect(tokens[2].data).toBe('1');
309
+ expect(tokens[3].type).toBe(TokenType.NEW_PARAGRAPH);
310
+ expect(tokens[4].type).toBe(TokenType.WORD);
311
+ expect(tokens[4].data).toBe('Line');
312
+ expect(tokens[5].type).toBe(TokenType.SPACE);
313
+ expect(tokens[6].type).toBe(TokenType.WORD);
314
+ expect(tokens[6].data).toBe('2');
315
+ });
316
+ });
317
+
318
+ describe('formatting', () => {
319
+ it('parses underline', () => {
320
+ const parser = new MTextParser('\\LUnderlined\\l');
321
+ const tokens = Array.from(parser.parse());
322
+ expect(tokens[0].type).toBe(TokenType.WORD);
323
+ expect(tokens[0].data).toBe('Underlined');
324
+ expect(tokens[0].ctx.underline).toBe(true);
325
+ });
326
+
327
+ it('parses color', () => {
328
+ const parser = new MTextParser('\\C1Red Text');
329
+ const tokens = Array.from(parser.parse());
330
+ expect(tokens[0].type).toBe(TokenType.WORD);
331
+ expect(tokens[0].data).toBe('Red');
332
+ expect(tokens[0].ctx.aci).toBe(1);
333
+ });
334
+
335
+ it('parses font properties', () => {
336
+ const parser = new MTextParser('\\FArial|b1|i1;Bold Italic');
337
+ const tokens = Array.from(parser.parse());
338
+ expect(tokens[0].type).toBe(TokenType.WORD);
339
+ expect(tokens[0].data).toBe('Bold');
340
+ expect(tokens[0].ctx.fontFace).toEqual({
341
+ family: 'Arial',
342
+ style: 'Italic',
343
+ weight: 700,
344
+ });
345
+ });
346
+
347
+ describe('height command', () => {
348
+ it('parses absolute height values', () => {
349
+ const parser = new MTextParser('\\H2.5;Text');
350
+ const tokens = Array.from(parser.parse());
351
+ expect(tokens[0].type).toBe(TokenType.WORD);
352
+ expect(tokens[0].data).toBe('Text');
353
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
354
+ });
355
+
356
+ it('parses relative height values with x suffix', () => {
357
+ const parser = new MTextParser('\\H2.5x;Text');
358
+ const tokens = Array.from(parser.parse());
359
+ expect(tokens[0].type).toBe(TokenType.WORD);
360
+ expect(tokens[0].data).toBe('Text');
361
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: true });
362
+ });
363
+
364
+ it('handles optional terminator', () => {
365
+ const parser = new MTextParser('\\H2.5Text');
366
+ const tokens = Array.from(parser.parse());
367
+ expect(tokens[0].type).toBe(TokenType.WORD);
368
+ expect(tokens[0].data).toBe('Text');
369
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
370
+ });
371
+
372
+ it('handles leading signs', () => {
373
+ let parser = new MTextParser('\\H-2.5;Text');
374
+ let tokens = Array.from(parser.parse());
375
+ expect(tokens[0].type).toBe(TokenType.WORD);
376
+ expect(tokens[0].data).toBe('Text');
377
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
378
+
379
+ parser = new MTextParser('\\H+2.5;Text');
380
+ tokens = Array.from(parser.parse());
381
+ expect(tokens[0].type).toBe(TokenType.WORD);
382
+ expect(tokens[0].data).toBe('Text');
383
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
384
+ });
385
+
386
+ it('handles decimal values without leading zero', () => {
387
+ let parser = new MTextParser('\\H.5x;Text');
388
+ let tokens = Array.from(parser.parse());
389
+ expect(tokens[0].type).toBe(TokenType.WORD);
390
+ expect(tokens[0].data).toBe('Text');
391
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
392
+
393
+ parser = new MTextParser('\\H-.5x;Text');
394
+ tokens = Array.from(parser.parse());
395
+ expect(tokens[0].type).toBe(TokenType.WORD);
396
+ expect(tokens[0].data).toBe('Text');
397
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
398
+
399
+ parser = new MTextParser('\\H+.5x;Text');
400
+ tokens = Array.from(parser.parse());
401
+ expect(tokens[0].type).toBe(TokenType.WORD);
402
+ expect(tokens[0].data).toBe('Text');
403
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
404
+ });
405
+
406
+ it('handles exponential notation', () => {
407
+ let parser = new MTextParser('\\H1e2;Text');
408
+ let tokens = Array.from(parser.parse());
409
+ expect(tokens[0].type).toBe(TokenType.WORD);
410
+ expect(tokens[0].data).toBe('Text');
411
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 100, isRelative: false });
412
+
413
+ parser = new MTextParser('\\H1e-2;Text');
414
+ tokens = Array.from(parser.parse());
415
+ expect(tokens[0].type).toBe(TokenType.WORD);
416
+ expect(tokens[0].data).toBe('Text');
417
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.01, isRelative: false });
418
+
419
+ parser = new MTextParser('\\H.5e2;Text');
420
+ tokens = Array.from(parser.parse());
421
+ expect(tokens[0].type).toBe(TokenType.WORD);
422
+ expect(tokens[0].data).toBe('Text');
423
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: false });
424
+
425
+ parser = new MTextParser('\\H.5e-2;Text');
426
+ tokens = Array.from(parser.parse());
427
+ expect(tokens[0].type).toBe(TokenType.WORD);
428
+ expect(tokens[0].data).toBe('Text');
429
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.005, isRelative: false });
430
+ });
431
+
432
+ it('handles invalid floating point values', () => {
433
+ let parser = new MTextParser('\\H1..5;Text');
434
+ let tokens = Array.from(parser.parse());
435
+ expect(tokens[0].type).toBe(TokenType.WORD);
436
+ expect(tokens[0].data).toBe('.5;Text');
437
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
438
+
439
+ parser = new MTextParser('\\H1e;Text');
440
+ tokens = Array.from(parser.parse());
441
+ expect(tokens[0].type).toBe(TokenType.WORD);
442
+ expect(tokens[0].data).toBe('e;Text');
443
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
444
+
445
+ parser = new MTextParser('\\H1e+;Text');
446
+ tokens = Array.from(parser.parse());
447
+ expect(tokens[0].type).toBe(TokenType.WORD);
448
+ expect(tokens[0].data).toBe('e+;Text');
449
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
450
+ });
451
+
452
+ it('handles complex height expressions', () => {
453
+ let parser = new MTextParser('\\H+1.5e-1x;Text');
454
+ let tokens = Array.from(parser.parse());
455
+ expect(tokens[0].type).toBe(TokenType.WORD);
456
+ expect(tokens[0].data).toBe('Text');
457
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.15, isRelative: true });
458
+
459
+ parser = new MTextParser('\\H-.5e+2x;Text');
460
+ tokens = Array.from(parser.parse());
461
+ expect(tokens[0].type).toBe(TokenType.WORD);
462
+ expect(tokens[0].data).toBe('Text');
463
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: true }); // Negative values are ignored
464
+ });
465
+
466
+ it('handles multiple height commands', () => {
467
+ const parser = new MTextParser('\\H2.5;First\\H.5x;Second');
468
+ const tokens = Array.from(parser.parse());
469
+ expect(tokens[0].type).toBe(TokenType.WORD);
470
+ expect(tokens[0].data).toBe('First');
471
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
472
+ expect(tokens[1].type).toBe(TokenType.WORD);
473
+ expect(tokens[1].data).toBe('Second');
474
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
475
+ });
476
+
477
+ it('handles height command with no value', () => {
478
+ const parser = new MTextParser('\\H;Text');
479
+ const tokens = Array.from(parser.parse());
480
+ expect(tokens[0].type).toBe(TokenType.WORD);
481
+ expect(tokens[0].data).toBe('Text');
482
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
483
+ });
484
+ });
485
+
486
+ describe('width command', () => {
487
+ it('parses absolute width values', () => {
488
+ const parser = new MTextParser('\\W2.5;Text');
489
+ const tokens = Array.from(parser.parse());
490
+ expect(tokens[0].type).toBe(TokenType.WORD);
491
+ expect(tokens[0].data).toBe('Text');
492
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
493
+ });
494
+
495
+ it('parses relative width values with x suffix', () => {
496
+ const parser = new MTextParser('\\W2.5x;Text');
497
+ const tokens = Array.from(parser.parse());
498
+ expect(tokens[0].type).toBe(TokenType.WORD);
499
+ expect(tokens[0].data).toBe('Text');
500
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: true });
501
+ });
502
+
503
+ it('handles optional terminator', () => {
504
+ const parser = new MTextParser('\\W2.5Text');
505
+ const tokens = Array.from(parser.parse());
506
+ expect(tokens[0].type).toBe(TokenType.WORD);
507
+ expect(tokens[0].data).toBe('Text');
508
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
509
+ });
510
+
511
+ it('handles leading signs', () => {
512
+ let parser = new MTextParser('\\W-2.5;Text');
513
+ let tokens = Array.from(parser.parse());
514
+ expect(tokens[0].type).toBe(TokenType.WORD);
515
+ expect(tokens[0].data).toBe('Text');
516
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
517
+
518
+ parser = new MTextParser('\\W+2.5;Text');
519
+ tokens = Array.from(parser.parse());
520
+ expect(tokens[0].type).toBe(TokenType.WORD);
521
+ expect(tokens[0].data).toBe('Text');
522
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
523
+ });
524
+
525
+ it('handles decimal values without leading zero', () => {
526
+ let parser = new MTextParser('\\W.5x;Text');
527
+ let tokens = Array.from(parser.parse());
528
+ expect(tokens[0].type).toBe(TokenType.WORD);
529
+ expect(tokens[0].data).toBe('Text');
530
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true });
531
+
532
+ parser = new MTextParser('\\W-.5x;Text');
533
+ tokens = Array.from(parser.parse());
534
+ expect(tokens[0].type).toBe(TokenType.WORD);
535
+ expect(tokens[0].data).toBe('Text');
536
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
537
+ });
538
+
539
+ it('handles multiple width commands', () => {
540
+ const parser = new MTextParser('\\W2.5;First\\W.5x;Second');
541
+ const tokens = Array.from(parser.parse());
542
+ expect(tokens[0].type).toBe(TokenType.WORD);
543
+ expect(tokens[0].data).toBe('First');
544
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
545
+ expect(tokens[1].type).toBe(TokenType.WORD);
546
+ expect(tokens[1].data).toBe('Second');
547
+ expect(tokens[1].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true });
548
+ });
549
+ });
550
+
551
+ describe('character tracking command', () => {
552
+ it('parses absolute tracking values', () => {
553
+ const parser = new MTextParser('\\T2.5;Text');
554
+ const tokens = Array.from(parser.parse());
555
+ expect(tokens[0].type).toBe(TokenType.WORD);
556
+ expect(tokens[0].data).toBe('Text');
557
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
558
+ });
559
+
560
+ it('parses relative tracking values with x suffix', () => {
561
+ const parser = new MTextParser('\\T2.5x;Text');
562
+ const tokens = Array.from(parser.parse());
563
+ expect(tokens[0].type).toBe(TokenType.WORD);
564
+ expect(tokens[0].data).toBe('Text');
565
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: true });
566
+ });
567
+
568
+ it('handles optional terminator', () => {
569
+ const parser = new MTextParser('\\T2.5Text');
570
+ const tokens = Array.from(parser.parse());
571
+ expect(tokens[0].type).toBe(TokenType.WORD);
572
+ expect(tokens[0].data).toBe('Text');
573
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
574
+ });
575
+
576
+ it('handles leading signs', () => {
577
+ let parser = new MTextParser('\\T-2.5;Text');
578
+ let tokens = Array.from(parser.parse());
579
+ expect(tokens[0].type).toBe(TokenType.WORD);
580
+ expect(tokens[0].data).toBe('Text');
581
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
582
+
583
+ parser = new MTextParser('\\T+2.5;Text');
584
+ tokens = Array.from(parser.parse());
585
+ expect(tokens[0].type).toBe(TokenType.WORD);
586
+ expect(tokens[0].data).toBe('Text');
587
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
588
+ });
589
+
590
+ it('handles decimal values without leading zero', () => {
591
+ let parser = new MTextParser('\\T.5x;Text');
592
+ let tokens = Array.from(parser.parse());
593
+ expect(tokens[0].type).toBe(TokenType.WORD);
594
+ expect(tokens[0].data).toBe('Text');
595
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
596
+
597
+ parser = new MTextParser('\\T-.5x;Text');
598
+ tokens = Array.from(parser.parse());
599
+ expect(tokens[0].type).toBe(TokenType.WORD);
600
+ expect(tokens[0].data).toBe('Text');
601
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
602
+ });
603
+
604
+ it('handles multiple tracking commands', () => {
605
+ const parser = new MTextParser('\\T2.5;First\\T.5x;Second');
606
+ const tokens = Array.from(parser.parse());
607
+ expect(tokens[0].type).toBe(TokenType.WORD);
608
+ expect(tokens[0].data).toBe('First');
609
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
610
+ expect(tokens[1].type).toBe(TokenType.WORD);
611
+ expect(tokens[1].data).toBe('Second');
612
+ expect(tokens[1].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
613
+ });
614
+
615
+ it('handles tracking command with no value', () => {
616
+ const parser = new MTextParser('\\T;Text');
617
+ const tokens = Array.from(parser.parse());
618
+ expect(tokens[0].type).toBe(TokenType.WORD);
619
+ expect(tokens[0].data).toBe('Text');
620
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); // Default value
621
+ });
622
+ });
623
+
624
+ describe('oblique command', () => {
625
+ it('parses positive oblique angle', () => {
626
+ const parser = new MTextParser('\\Q15;Text');
627
+ const tokens = Array.from(parser.parse());
628
+ expect(tokens[0].type).toBe(TokenType.WORD);
629
+ expect(tokens[0].data).toBe('Text');
630
+ expect(tokens[0].ctx.oblique).toBe(15);
631
+ });
632
+
633
+ it('parses negative oblique angle', () => {
634
+ const parser = new MTextParser('\\Q-15;Text');
635
+ const tokens = Array.from(parser.parse());
636
+ expect(tokens[0].type).toBe(TokenType.WORD);
637
+ expect(tokens[0].data).toBe('Text');
638
+ expect(tokens[0].ctx.oblique).toBe(-15);
639
+ });
640
+
641
+ it('handles optional terminator', () => {
642
+ const parser = new MTextParser('\\Q15Text');
643
+ const tokens = Array.from(parser.parse());
644
+ expect(tokens[0].type).toBe(TokenType.WORD);
645
+ expect(tokens[0].data).toBe('Text');
646
+ expect(tokens[0].ctx.oblique).toBe(15);
647
+ });
648
+
649
+ it('handles decimal values', () => {
650
+ const parser = new MTextParser('\\Q15.5;Text');
651
+ const tokens = Array.from(parser.parse());
652
+ expect(tokens[0].type).toBe(TokenType.WORD);
653
+ expect(tokens[0].data).toBe('Text');
654
+ expect(tokens[0].ctx.oblique).toBe(15.5);
655
+ });
656
+
657
+ it('handles multiple oblique commands', () => {
658
+ const parser = new MTextParser('\\Q15;First\\Q-30;Second');
659
+ const tokens = Array.from(parser.parse());
660
+ expect(tokens[0].type).toBe(TokenType.WORD);
661
+ expect(tokens[0].data).toBe('First');
662
+ expect(tokens[0].ctx.oblique).toBe(15);
663
+ expect(tokens[1].type).toBe(TokenType.WORD);
664
+ expect(tokens[1].data).toBe('Second');
665
+ expect(tokens[1].ctx.oblique).toBe(-30);
666
+ });
667
+ });
668
+
669
+ describe('special encoded characters', () => {
670
+ it('renders diameter symbol (%%c)', () => {
671
+ let parser = new MTextParser('%%cText');
672
+ let tokens = Array.from(parser.parse());
673
+ expect(tokens[0].type).toBe(TokenType.WORD);
674
+ expect(tokens[0].data).toBe('ØText');
675
+
676
+ parser = new MTextParser('%%CText');
677
+ tokens = Array.from(parser.parse());
678
+ expect(tokens[0].type).toBe(TokenType.WORD);
679
+ expect(tokens[0].data).toBe('ØText');
680
+ });
681
+
682
+ it('renders degree symbol (%%d)', () => {
683
+ let parser = new MTextParser('%%dText');
684
+ let tokens = Array.from(parser.parse());
685
+ expect(tokens[0].type).toBe(TokenType.WORD);
686
+ expect(tokens[0].data).toBe('°Text');
687
+
688
+ parser = new MTextParser('%%DText');
689
+ tokens = Array.from(parser.parse());
690
+ expect(tokens[0].type).toBe(TokenType.WORD);
691
+ expect(tokens[0].data).toBe('°Text');
692
+ });
693
+
694
+ it('renders plus-minus symbol (%%p)', () => {
695
+ let parser = new MTextParser('%%pText');
696
+ let tokens = Array.from(parser.parse());
697
+ expect(tokens[0].type).toBe(TokenType.WORD);
698
+ expect(tokens[0].data).toBe('±Text');
699
+
700
+ parser = new MTextParser('%%PText');
701
+ tokens = Array.from(parser.parse());
702
+ expect(tokens[0].type).toBe(TokenType.WORD);
703
+ expect(tokens[0].data).toBe('±Text');
704
+ });
705
+
706
+ it('handles multiple special characters in sequence', () => {
707
+ const parser = new MTextParser('%%c%%d%%pText');
708
+ const tokens = Array.from(parser.parse());
709
+ expect(tokens[0].type).toBe(TokenType.WORD);
710
+ expect(tokens[0].data).toBe('ذ±Text');
711
+ });
712
+
713
+ it('handles special characters with spaces', () => {
714
+ const parser = new MTextParser('%%c %%d %%p Text');
715
+ const tokens = Array.from(parser.parse());
716
+ expect(tokens).toHaveLength(7);
717
+ expect(tokens[0].type).toBe(TokenType.WORD);
718
+ expect(tokens[0].data).toBe('Ø');
719
+ expect(tokens[1].type).toBe(TokenType.SPACE);
720
+ expect(tokens[2].type).toBe(TokenType.WORD);
721
+ expect(tokens[2].data).toBe('°');
722
+ expect(tokens[3].type).toBe(TokenType.SPACE);
723
+ expect(tokens[4].type).toBe(TokenType.WORD);
724
+ expect(tokens[4].data).toBe('±');
725
+ expect(tokens[5].type).toBe(TokenType.SPACE);
726
+ expect(tokens[6].type).toBe(TokenType.WORD);
727
+ expect(tokens[6].data).toBe('Text');
728
+ });
729
+
730
+ it('handles special characters with formatting', () => {
731
+ const parser = new MTextParser('\\H2.5;%%c\\H.5x;%%d%%p');
732
+ const tokens = Array.from(parser.parse());
733
+ expect(tokens).toHaveLength(2);
734
+ expect(tokens[0].type).toBe(TokenType.WORD);
735
+ expect(tokens[0].data).toBe('Ø');
736
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
737
+ expect(tokens[1].type).toBe(TokenType.WORD);
738
+ expect(tokens[1].data).toBe('°±');
739
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
740
+ });
741
+
742
+ it('handles invalid special character codes', () => {
743
+ const parser = new MTextParser('%%x%%y%%zText');
744
+ const tokens = Array.from(parser.parse());
745
+ expect(tokens[0].type).toBe(TokenType.WORD);
746
+ expect(tokens[0].data).toBe('Text');
747
+ });
748
+ });
749
+ });
750
+
751
+ describe('GBK character encoding', () => {
752
+ it('decodes GBK hex codes', () => {
753
+ // Test "你" (C4E3 in GBK)
754
+ let parser = new MTextParser('\\M+C4E3', undefined, { yieldPropertyCommands: false });
755
+ let tokens = Array.from(parser.parse());
756
+ expect(tokens[0].type).toBe(TokenType.WORD);
757
+ expect(tokens[0].data).toBe('你');
758
+
759
+ // Test "好" (BAC3 in GBK)
760
+ parser = new MTextParser('\\M+BAC3', undefined, { yieldPropertyCommands: false });
761
+ tokens = Array.from(parser.parse());
762
+ expect(tokens[0].type).toBe(TokenType.WORD);
763
+ expect(tokens[0].data).toBe('好');
764
+
765
+ // Test multiple GBK characters
766
+ parser = new MTextParser('\\M+C4E3\\M+BAC3', undefined, { yieldPropertyCommands: false });
767
+ tokens = Array.from(parser.parse());
768
+ expect(tokens[0].type).toBe(TokenType.WORD);
769
+ expect(tokens[0].data).toBe('你');
770
+ expect(tokens[1].type).toBe(TokenType.WORD);
771
+ expect(tokens[1].data).toBe('好');
772
+ });
773
+
774
+ it('handles invalid GBK codes', () => {
775
+ // Test invalid hex code
776
+ let parser = new MTextParser('\\M+XXXX', undefined, { yieldPropertyCommands: false });
777
+ let tokens = Array.from(parser.parse());
778
+ expect(tokens[0].type).toBe(TokenType.WORD);
779
+ expect(tokens[0].data).toBe('\\M+XXXX');
780
+
781
+ // Test incomplete hex code
782
+ parser = new MTextParser('\\M+C4', undefined, { yieldPropertyCommands: false });
783
+ tokens = Array.from(parser.parse());
784
+ expect(tokens[0].type).toBe(TokenType.WORD);
785
+ expect(tokens[0].data).toBe('\\M+C4');
786
+
787
+ // Test missing plus sign
788
+ parser = new MTextParser('\\MC4E3', undefined, { yieldPropertyCommands: false });
789
+ tokens = Array.from(parser.parse());
790
+ expect(tokens[0].type).toBe(TokenType.WORD);
791
+ expect(tokens[0].data).toBe('\\MC4E3');
792
+ });
793
+
794
+ it('handles GBK characters with other formatting', () => {
795
+ // Test GBK characters with height command
796
+ const parser = new MTextParser('\\H2.5;\\M+C4E3\\H.5x;\\M+BAC3', undefined, {
797
+ yieldPropertyCommands: false,
798
+ });
799
+ const tokens = Array.from(parser.parse());
800
+ expect(tokens).toHaveLength(2);
801
+ expect(tokens[0].type).toBe(TokenType.WORD);
802
+ expect(tokens[0].data).toBe('你');
803
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
804
+ expect(tokens[1].type).toBe(TokenType.WORD);
805
+ expect(tokens[1].data).toBe('好');
806
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
807
+ });
808
+
809
+ it('handles GBK characters with font properties', () => {
810
+ const parser = new MTextParser('{\\fgbcbig.shx|b0|i0|c0|p0;\\M+C4E3\\M+BAC3}', undefined, {
811
+ yieldPropertyCommands: false,
812
+ });
813
+ const tokens = Array.from(parser.parse());
814
+ expect(tokens[0].type).toBe(TokenType.WORD);
815
+ expect(tokens[0].data).toBe('你');
816
+ expect(tokens[1].type).toBe(TokenType.WORD);
817
+ expect(tokens[1].data).toBe('好');
818
+ expect(tokens[0].ctx.fontFace).toEqual({
819
+ family: 'gbcbig.shx',
820
+ style: 'Regular',
821
+ weight: 400,
822
+ });
823
+ expect(tokens[1].ctx.fontFace).toEqual({
824
+ family: 'gbcbig.shx',
825
+ style: 'Regular',
826
+ weight: 400,
827
+ });
828
+ });
829
+ });
830
+
831
+ describe('MIF (Multibyte Interchange Format) with custom decoder', () => {
832
+ it('uses custom decoder when provided', () => {
833
+ const customDecoder = (hex: string) => {
834
+ // Custom decoder that reverses the hex and converts to char
835
+ const reversed = hex.split('').reverse().join('');
836
+ const codePoint = parseInt(reversed, 16);
837
+ return String.fromCodePoint(codePoint);
838
+ };
839
+
840
+ const parser = new MTextParser('\\M+C4E3', undefined, {
841
+ mifDecoder: customDecoder,
842
+ });
843
+ const tokens = Array.from(parser.parse());
844
+ expect(tokens[0].type).toBe(TokenType.WORD);
845
+ // The custom decoder will produce different output
846
+ expect(tokens[0].data).not.toBe('\\M+C4E3');
847
+ });
848
+
849
+ it('parses 5-digit MIF codes with auto-detect', () => {
850
+ // Use default decoder with auto-detect
851
+ const parser = new MTextParser('\\M+1A2B3', undefined, {
852
+ mifCodeLength: 'auto',
853
+ });
854
+ const tokens = Array.from(parser.parse());
855
+ expect(tokens[0].type).toBe(TokenType.WORD);
856
+ // Should successfully parse 5-digit code
857
+ expect(tokens[0].data).not.toBe('\\M+1A2B3');
858
+ });
859
+
860
+ it('parses 5-digit MIF codes when specified', () => {
861
+ const parser = new MTextParser('\\M+1A2B3', undefined, {
862
+ mifCodeLength: 5,
863
+ });
864
+ const tokens = Array.from(parser.parse());
865
+ expect(tokens[0].type).toBe(TokenType.WORD);
866
+ expect(tokens[0].data).not.toBe('\\M+1A2B3');
867
+ });
868
+
869
+ it('parses 4-digit MIF codes when specified', () => {
870
+ const parser = new MTextParser('\\M+C4E3', undefined, {
871
+ mifCodeLength: 4,
872
+ });
873
+ const tokens = Array.from(parser.parse());
874
+ expect(tokens[0].type).toBe(TokenType.WORD);
875
+ expect(tokens[0].data).toBe('你');
876
+ });
877
+
878
+ it('falls back to 4-digit when 5-digit not available', () => {
879
+ const parser = new MTextParser('\\M+C4E3', undefined, {
880
+ mifCodeLength: 'auto',
881
+ });
882
+ const tokens = Array.from(parser.parse());
883
+ expect(tokens[0].type).toBe(TokenType.WORD);
884
+ expect(tokens[0].data).toBe('你');
885
+ });
886
+
887
+ it('uses custom decoder with specific code length', () => {
888
+ const customDecoder = (hex: string) => `[DECODED:${hex}]`;
889
+ const parser = new MTextParser('\\M+C4E3', undefined, {
890
+ mifDecoder: customDecoder,
891
+ mifCodeLength: 4,
892
+ });
893
+ const tokens = Array.from(parser.parse());
894
+ expect(tokens[0].type).toBe(TokenType.WORD);
895
+ expect(tokens[0].data).toBe('[DECODED:C4E3]');
896
+ });
897
+
898
+ it('parses complex MText with 5-digit MIF codes and Unicode', () => {
899
+ // Test data: \M+1928D:\M+18DD1\M+197702.0t\U+9540\U+950C\M+194C2\M+190A7\M+18DEC\M+18142
900
+ // According to user, \M+19770 should be parsed as 5-digit code, leaving "2.0t" as separate characters
901
+ // But the parser's auto-detect logic tries 5 digits first, which consumes "19770"
902
+ // So "2" becomes part of the next sequence
903
+ const mtext =
904
+ '\\M+1928D:\\M+18DD1\\M+197702.0t\\U+9540\\U+950C\\M+194C2\\M+190A7\\M+18DEC\\M+18142';
905
+ const parser = new MTextParser(mtext, undefined, {
906
+ mifCodeLength: 'auto',
907
+ });
908
+ const tokens = Array.from(parser.parse());
909
+
910
+ // Should parse without errors and generate tokens
911
+ // The parser produces 11 tokens: each MIF code and Unicode becomes one token, and "2.0t" becomes a single token
912
+ expect(tokens.length).toBe(11);
913
+
914
+ // Verify the decoded characters are valid
915
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
916
+ expect(wordTokens.length).toBeGreaterThan(0);
917
+
918
+ // Note: 5-digit MIF codes return placeholder character '▯' as per decodeMultiByteChar implementation
919
+ // Only verify that Unicode characters (4-digit hex) are decoded properly
920
+ const unicodeTokens = wordTokens.filter(t => t.data && t.data !== '▯');
921
+ expect(unicodeTokens.length).toBeGreaterThan(0);
922
+ });
923
+
924
+ it('decodes specific 5-digit MIF codes correctly', () => {
925
+ // Test individual 5-digit MIF codes from the provided test data
926
+ const testCases = [
927
+ { code: '1928D', decoded: '注' },
928
+ { code: '18DD1', decoded: '采' },
929
+ { code: '19770', decoded: '用' },
930
+ { code: '194C2', decoded: '板' },
931
+ { code: '190A7', decoded: '制' },
932
+ { code: '18DEC', decoded: '作' },
933
+ { code: '18142', decoded: '。' },
934
+ ];
935
+
936
+ testCases.forEach(({ code, decoded }) => {
937
+ const parser = new MTextParser(`\\M+${code}`, undefined, {
938
+ mifCodeLength: 5,
939
+ });
940
+ const tokens = Array.from(parser.parse());
941
+ expect(tokens[0].type).toBe(TokenType.WORD);
942
+ // Note: decodeMultiByteChar returns '▯' for 5-digit codes
943
+ if (tokens[0].data && typeof tokens[0].data === 'string') {
944
+ expect(tokens[0].data).toBe(decoded);
945
+ }
946
+ });
947
+ });
948
+ });
949
+
950
+ describe('Unicode (\\U+XXXX) escape sequences', () => {
951
+ it('decodes Unicode BMP and supplementary plane code points', () => {
952
+ // Greek capital omega: \U+03A9
953
+ let parser = new MTextParser('Omega: \\U+03A9');
954
+ let tokens = Array.from(parser.parse());
955
+ expect(tokens[0].type).toBe(TokenType.WORD);
956
+ expect(tokens[0].data).toBe('Omega:');
957
+ expect(tokens[1].type).toBe(TokenType.SPACE);
958
+ expect(tokens[2].type).toBe(TokenType.WORD);
959
+ expect(tokens[2].data).toBe('Ω');
960
+
961
+ // Emoji: \U+1F600 (grinning face)
962
+ parser = new MTextParser('Smile: \\U+1F600');
963
+ tokens = Array.from(parser.parse());
964
+ expect(tokens[0].type).toBe(TokenType.WORD);
965
+ expect(tokens[0].data).toBe('Smile:');
966
+ expect(tokens[1].type).toBe(TokenType.SPACE);
967
+ expect(tokens[2].type).toBe(TokenType.WORD);
968
+ expect(tokens[2].data).toBe('😀');
969
+ });
970
+
971
+ it('handles invalid or incomplete Unicode escapes as literal text', () => {
972
+ // Not enough hex digits
973
+ let parser = new MTextParser('Test: \\U+12');
974
+ let tokens = Array.from(parser.parse());
975
+ expect(tokens[0].type).toBe(TokenType.WORD);
976
+ expect(tokens[0].data).toBe('Test:');
977
+ expect(tokens[1].type).toBe(TokenType.SPACE);
978
+ expect(tokens[2].type).toBe(TokenType.WORD);
979
+ expect(tokens[2].data).toBe('\\U+12');
980
+
981
+ // Invalid hex
982
+ parser = new MTextParser('Test: \\U+ZZZZ');
983
+ tokens = Array.from(parser.parse());
984
+ expect(tokens[0].type).toBe(TokenType.WORD);
985
+ expect(tokens[0].data).toBe('Test:');
986
+ expect(tokens[1].type).toBe(TokenType.SPACE);
987
+ expect(tokens[2].type).toBe(TokenType.WORD);
988
+ expect(tokens[2].data).toBe('\\U+ZZZZ');
989
+ });
990
+ });
991
+
992
+ describe('stacking', () => {
993
+ it('parses basic fractions with different dividers', () => {
994
+ let parser = new MTextParser('\\S1/2;');
995
+ let tokens = Array.from(parser.parse());
996
+ expect(tokens[0].type).toBe(TokenType.STACK);
997
+ expect(tokens[0].data).toEqual(['1', '2', '/']);
998
+
999
+ parser = new MTextParser('\\S1#2;');
1000
+ tokens = Array.from(parser.parse());
1001
+ expect(tokens[0].type).toBe(TokenType.STACK);
1002
+ expect(tokens[0].data).toEqual(['1', '2', '#']);
1003
+ });
1004
+
1005
+ it('handles caret for baseline alignment', () => {
1006
+ let parser = new MTextParser('\\S1^2;');
1007
+ let tokens = Array.from(parser.parse());
1008
+ expect(tokens[0].type).toBe(TokenType.STACK);
1009
+ expect(tokens[0].data).toEqual(['1', '2', '^']);
1010
+
1011
+ // Test with spaces
1012
+ parser = new MTextParser('\\S1 2^3 4;');
1013
+ tokens = Array.from(parser.parse());
1014
+ expect(tokens[0].type).toBe(TokenType.STACK);
1015
+ expect(tokens[0].data).toEqual(['1 2', '3 4', '^']);
1016
+
1017
+ // Test with escaped characters
1018
+ parser = new MTextParser('\\S1^2\\;;');
1019
+ tokens = Array.from(parser.parse());
1020
+ expect(tokens[0].type).toBe(TokenType.STACK);
1021
+ expect(tokens[0].data).toEqual(['1', '2;', '^']);
1022
+ });
1023
+
1024
+ it('handles spaces in numerator and denominator', () => {
1025
+ const parser = new MTextParser('\\S1 2/3 4;');
1026
+ const tokens = Array.from(parser.parse());
1027
+ expect(tokens[0].type).toBe(TokenType.STACK);
1028
+ expect(tokens[0].data).toEqual(['1 2', '3 4', '/']);
1029
+ });
1030
+
1031
+ it('handles spaces after / and # dividers', () => {
1032
+ let parser = new MTextParser('\\S1/ 2;');
1033
+ let tokens = Array.from(parser.parse());
1034
+ expect(tokens[0].type).toBe(TokenType.STACK);
1035
+ expect(tokens[0].data).toEqual(['1', ' 2', '/']);
1036
+
1037
+ parser = new MTextParser('\\S1# 2;');
1038
+ tokens = Array.from(parser.parse());
1039
+ expect(tokens[0].type).toBe(TokenType.STACK);
1040
+ expect(tokens[0].data).toEqual(['1', ' 2', '#']);
1041
+ });
1042
+
1043
+ it('handles escaped terminator', () => {
1044
+ const parser = new MTextParser('\\S1/2\\;;');
1045
+ const tokens = Array.from(parser.parse());
1046
+ expect(tokens[0].type).toBe(TokenType.STACK);
1047
+ expect(tokens[0].data).toEqual(['1', '2;', '/']);
1048
+ });
1049
+
1050
+ it('ignores backslashes except for escaped terminator', () => {
1051
+ const parser = new MTextParser('\\S\\N^ \\P;');
1052
+ const tokens = Array.from(parser.parse());
1053
+ expect(tokens[0].type).toBe(TokenType.STACK);
1054
+ expect(tokens[0].data).toEqual(['N', 'P', '^']);
1055
+ });
1056
+
1057
+ it('renders grouping chars as simple braces', () => {
1058
+ const parser = new MTextParser('\\S{1}/2;');
1059
+ const tokens = Array.from(parser.parse());
1060
+ expect(tokens[0].type).toBe(TokenType.STACK);
1061
+ expect(tokens[0].data).toEqual(['{1}', '2', '/']);
1062
+ });
1063
+
1064
+ it('treats carets in stack formatting as literal text', () => {
1065
+ let parser = new MTextParser('\\S^I/^J;');
1066
+ let tokens = Array.from(parser.parse());
1067
+ expect(tokens[0].type).toBe(TokenType.STACK);
1068
+ expect(tokens[0].data).toEqual([' ', ' ', '/']);
1069
+
1070
+ parser = new MTextParser('\\Sabc^def;');
1071
+ tokens = Array.from(parser.parse());
1072
+ expect(tokens[0].type).toBe(TokenType.STACK);
1073
+ expect(tokens[0].data).toEqual(['abc', 'def', '^']);
1074
+ });
1075
+
1076
+ it('handles subscript and superscript', () => {
1077
+ // Subscript
1078
+ let parser = new MTextParser('abc\\S^ 1;');
1079
+ let tokens = Array.from(parser.parse());
1080
+ expect(tokens[0].type).toBe(TokenType.WORD);
1081
+ expect(tokens[0].data).toEqual('abc');
1082
+ expect(tokens[1].type).toBe(TokenType.STACK);
1083
+ expect(tokens[1].data).toEqual(['', '1', '^']);
1084
+
1085
+ // Superscript
1086
+ parser = new MTextParser('abc\\S1^ ;');
1087
+ tokens = Array.from(parser.parse());
1088
+ expect(tokens[0].type).toBe(TokenType.WORD);
1089
+ expect(tokens[0].data).toEqual('abc');
1090
+ expect(tokens[1].type).toBe(TokenType.STACK);
1091
+ expect(tokens[1].data).toEqual(['1', '', '^']);
1092
+ });
1093
+
1094
+ it('handles multiple divider chars', () => {
1095
+ const parser = new MTextParser('\\S1/2/3;');
1096
+ const tokens = Array.from(parser.parse());
1097
+ expect(tokens[0].type).toBe(TokenType.STACK);
1098
+ expect(tokens[0].data).toEqual(['1', '2/3', '/']);
1099
+ });
1100
+
1101
+ it('requires terminator for command end', () => {
1102
+ const parser = new MTextParser('\\S1/2');
1103
+ const tokens = Array.from(parser.parse());
1104
+ expect(tokens[0].type).toBe(TokenType.STACK);
1105
+ expect(tokens[0].data).toEqual(['1', '2', '/']);
1106
+ });
1107
+
1108
+ it('handles complex fractions', () => {
1109
+ const parser = new MTextParser('\\S1 2/3 4^ 5 6;');
1110
+ const tokens = Array.from(parser.parse());
1111
+ expect(tokens[0].type).toBe(TokenType.STACK);
1112
+ expect(tokens[0].data).toEqual(['1 2', '3 4^ 5 6', '/']);
1113
+ });
1114
+ });
1115
+
1116
+ describe('paragraph properties', () => {
1117
+ it('parses indentation', () => {
1118
+ const parser = new MTextParser('\\pi2;Indented');
1119
+ const tokens = Array.from(parser.parse());
1120
+ expect(tokens[0].type).toBe(TokenType.WORD);
1121
+ expect(tokens[0].data).toBe('Indented');
1122
+ expect(tokens[0].ctx.paragraph.indent).toBe(2);
1123
+ });
1124
+
1125
+ it('parses alignment', () => {
1126
+ const parser = new MTextParser('\\pqc;Centered');
1127
+ const tokens = Array.from(parser.parse());
1128
+ expect(tokens[0].type).toBe(TokenType.WORD);
1129
+ expect(tokens[0].data).toBe('Centered');
1130
+ expect(tokens[0].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1131
+ });
1132
+
1133
+ it('switches alignment', () => {
1134
+ const mtext =
1135
+ 'Line1: {\\pql;Left aligned paragraph.}\\PLine2: {\\pqc;Center aligned paragraph.} Middle\\PLine3: {\\pql;Back to left.} {End}';
1136
+ const ctx = new MTextContext();
1137
+ ctx.fontFace.family = 'simkai';
1138
+ ctx.capHeight = { value: 0.1, isRelative: true };
1139
+ ctx.widthFactor = { value: 1.0, isRelative: true };
1140
+ ctx.align = MTextLineAlignment.BOTTOM;
1141
+ ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1142
+ const parser = new MTextParser(mtext, ctx, { yieldPropertyCommands: true });
1143
+ const tokens = Array.from(parser.parse());
1144
+ // Filter for word tokens
1145
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1146
+ const expected = [
1147
+ // Paragraph 1 (LEFT)
1148
+ { data: 'Line1:', align: MTextParagraphAlignment.LEFT },
1149
+ { data: 'Left', align: MTextParagraphAlignment.LEFT },
1150
+ { data: 'aligned', align: MTextParagraphAlignment.LEFT },
1151
+ { data: 'paragraph.', align: MTextParagraphAlignment.LEFT },
1152
+ // Paragraph 2 (CENTER)
1153
+ { data: 'Line2:', align: MTextParagraphAlignment.LEFT },
1154
+ { data: 'Center', align: MTextParagraphAlignment.CENTER },
1155
+ { data: 'aligned', align: MTextParagraphAlignment.CENTER },
1156
+ { data: 'paragraph.', align: MTextParagraphAlignment.CENTER },
1157
+ { data: 'Middle', align: MTextParagraphAlignment.CENTER },
1158
+ // Paragraph 3 (LEFT)
1159
+ { data: 'Line3:', align: MTextParagraphAlignment.CENTER },
1160
+ { data: 'Back', align: MTextParagraphAlignment.LEFT },
1161
+ { data: 'to', align: MTextParagraphAlignment.LEFT },
1162
+ { data: 'left.', align: MTextParagraphAlignment.LEFT },
1163
+ { data: 'End', align: MTextParagraphAlignment.LEFT },
1164
+ ];
1165
+ expect(wordTokens).toHaveLength(expected.length);
1166
+ for (let i = 0; i < expected.length; i++) {
1167
+ console.log(wordTokens[i]);
1168
+ expect(wordTokens[i].data).toBe(expected[i].data);
1169
+ expect(wordTokens[i].ctx.paragraph.align).toBe(expected[i].align);
1170
+ }
1171
+ });
1172
+ });
1173
+
1174
+ describe('property commands with yieldPropertyCommands', () => {
1175
+ it('yields property change tokens for formatting commands', () => {
1176
+ const parser = new MTextParser('\\LUnderlined\\l', undefined, {
1177
+ yieldPropertyCommands: true,
1178
+ });
1179
+ const tokens = Array.from(parser.parse());
1180
+ expect(tokens).toHaveLength(3);
1181
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1182
+ expect(tokens[0].data).toEqual({
1183
+ command: 'L',
1184
+ changes: {
1185
+ underline: true,
1186
+ },
1187
+ depth: 0,
1188
+ });
1189
+ expect(tokens[1].type).toBe(TokenType.WORD);
1190
+ expect(tokens[1].data).toBe('Underlined');
1191
+ expect(tokens[1].ctx.underline).toBe(true);
1192
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1193
+ expect(tokens[2].data).toEqual({
1194
+ command: 'l',
1195
+ changes: {
1196
+ underline: false,
1197
+ },
1198
+ depth: 0,
1199
+ });
1200
+ expect(tokens[2].ctx.underline).toBe(false);
1201
+ });
1202
+
1203
+ it('yields property change tokens for color commands', () => {
1204
+ const parser = new MTextParser('\\C1Red Text', undefined, { yieldPropertyCommands: true });
1205
+ const tokens = Array.from(parser.parse());
1206
+ expect(tokens).toHaveLength(4);
1207
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1208
+ expect(tokens[0].data).toEqual({
1209
+ command: 'C',
1210
+ changes: {
1211
+ aci: 1,
1212
+ },
1213
+ depth: 0,
1214
+ });
1215
+ expect(tokens[1].type).toBe(TokenType.WORD);
1216
+ expect(tokens[1].data).toBe('Red');
1217
+ expect(tokens[1].ctx.aci).toBe(1);
1218
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1219
+ expect(tokens[2].ctx.aci).toBe(1);
1220
+ expect(tokens[3].type).toBe(TokenType.WORD);
1221
+ expect(tokens[3].data).toBe('Text');
1222
+ expect(tokens[3].ctx.aci).toBe(1);
1223
+ });
1224
+
1225
+ it('yields property change tokens for font properties', () => {
1226
+ const parser = new MTextParser('\\FArial|b1|i1;Bold Italic', undefined, {
1227
+ yieldPropertyCommands: true,
1228
+ });
1229
+ const tokens = Array.from(parser.parse());
1230
+ expect(tokens).toHaveLength(4);
1231
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1232
+ expect(tokens[0].data).toEqual({
1233
+ command: 'F',
1234
+ changes: {
1235
+ fontFace: {
1236
+ family: 'Arial',
1237
+ style: 'Italic',
1238
+ weight: 700,
1239
+ },
1240
+ },
1241
+ depth: 0,
1242
+ });
1243
+ expect(tokens[1].type).toBe(TokenType.WORD);
1244
+ expect(tokens[1].data).toBe('Bold');
1245
+ expect(tokens[1].ctx.fontFace).toEqual({
1246
+ family: 'Arial',
1247
+ style: 'Italic',
1248
+ weight: 700,
1249
+ });
1250
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1251
+ expect(tokens[2].ctx.fontFace).toEqual({
1252
+ family: 'Arial',
1253
+ style: 'Italic',
1254
+ weight: 700,
1255
+ });
1256
+ expect(tokens[3].type).toBe(TokenType.WORD);
1257
+ expect(tokens[3].data).toBe('Italic');
1258
+ expect(tokens[3].ctx.fontFace).toEqual({
1259
+ family: 'Arial',
1260
+ style: 'Italic',
1261
+ weight: 700,
1262
+ });
1263
+ });
1264
+
1265
+ it('yields property change tokens for height command', () => {
1266
+ const parser = new MTextParser('\\H2.5;Text', undefined, { yieldPropertyCommands: true });
1267
+ const tokens = Array.from(parser.parse());
1268
+ expect(tokens).toHaveLength(2);
1269
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1270
+ expect(tokens[0].data).toEqual({
1271
+ command: 'H',
1272
+ changes: {
1273
+ capHeight: { value: 2.5, isRelative: false },
1274
+ },
1275
+ depth: 0,
1276
+ });
1277
+ expect(tokens[1].type).toBe(TokenType.WORD);
1278
+ expect(tokens[1].data).toBe('Text');
1279
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1280
+ });
1281
+
1282
+ it('yields property change tokens for multiple commands', () => {
1283
+ const parser = new MTextParser('\\H2.5;\\C1;\\LText\\l', undefined, {
1284
+ yieldPropertyCommands: true,
1285
+ });
1286
+ const tokens = Array.from(parser.parse());
1287
+ expect(tokens).toHaveLength(5);
1288
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1289
+ expect(tokens[0].data).toEqual({
1290
+ command: 'H',
1291
+ changes: {
1292
+ capHeight: { value: 2.5, isRelative: false },
1293
+ },
1294
+ depth: 0,
1295
+ });
1296
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1297
+ expect(tokens[1].data).toEqual({
1298
+ command: 'C',
1299
+ changes: {
1300
+ aci: 1,
1301
+ },
1302
+ depth: 0,
1303
+ });
1304
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1305
+ expect(tokens[2].data).toEqual({
1306
+ command: 'L',
1307
+ changes: {
1308
+ underline: true,
1309
+ },
1310
+ depth: 0,
1311
+ });
1312
+ expect(tokens[3].type).toBe(TokenType.WORD);
1313
+ expect(tokens[3].data).toBe('Text');
1314
+ expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1315
+ expect(tokens[3].ctx.underline).toBe(true);
1316
+ expect(tokens[4].type).toBe(TokenType.PROPERTIES_CHANGED);
1317
+ expect(tokens[4].data).toEqual({
1318
+ command: 'l',
1319
+ changes: {
1320
+ underline: false,
1321
+ },
1322
+ depth: 0,
1323
+ });
1324
+ });
1325
+
1326
+ it('yields property change tokens for paragraph properties', () => {
1327
+ const parser = new MTextParser('\\pi2;\\pqc;Indented Centered', undefined, {
1328
+ yieldPropertyCommands: true,
1329
+ });
1330
+ const tokens = Array.from(parser.parse());
1331
+ expect(tokens).toHaveLength(5);
1332
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1333
+ expect(tokens[0].data).toEqual({
1334
+ command: 'p',
1335
+ changes: {
1336
+ paragraph: {
1337
+ indent: 2,
1338
+ },
1339
+ },
1340
+ depth: 0,
1341
+ });
1342
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1343
+ expect(tokens[1].data).toEqual({
1344
+ command: 'p',
1345
+ changes: {
1346
+ paragraph: {
1347
+ align: MTextParagraphAlignment.CENTER,
1348
+ },
1349
+ },
1350
+ depth: 0,
1351
+ });
1352
+ expect(tokens[2].type).toBe(TokenType.WORD);
1353
+ expect(tokens[2].data).toBe('Indented');
1354
+ expect(tokens[2].ctx.paragraph.indent).toBe(2);
1355
+ expect(tokens[2].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1356
+ expect(tokens[3].type).toBe(TokenType.SPACE);
1357
+ expect(tokens[3].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1358
+ expect(tokens[4].type).toBe(TokenType.WORD);
1359
+ expect(tokens[4].data).toBe('Centered');
1360
+ expect(tokens[4].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1361
+ });
1362
+
1363
+ it('yields property change tokens for complex formatting', () => {
1364
+ const parser = new MTextParser('{\\H2.5;\\C1;\\FArial|b1|i1;Formatted Text}', undefined, {
1365
+ yieldPropertyCommands: true,
1366
+ });
1367
+ const tokens = Array.from(parser.parse());
1368
+ expect(tokens).toHaveLength(7);
1369
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1370
+ expect(tokens[0].data).toEqual({
1371
+ command: 'H',
1372
+ changes: {
1373
+ capHeight: { value: 2.5, isRelative: false },
1374
+ },
1375
+ depth: 1,
1376
+ });
1377
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1378
+ expect(tokens[1].data).toEqual({
1379
+ command: 'C',
1380
+ changes: {
1381
+ aci: 1,
1382
+ },
1383
+ depth: 1,
1384
+ });
1385
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1386
+ expect(tokens[2].data).toEqual({
1387
+ command: 'F',
1388
+ changes: {
1389
+ fontFace: {
1390
+ family: 'Arial',
1391
+ style: 'Italic',
1392
+ weight: 700,
1393
+ },
1394
+ },
1395
+ depth: 1,
1396
+ });
1397
+ expect(tokens[3].type).toBe(TokenType.WORD);
1398
+ expect(tokens[3].data).toBe('Formatted');
1399
+ expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1400
+ expect(tokens[3].ctx.aci).toBe(1);
1401
+ expect(tokens[3].ctx.fontFace).toEqual({
1402
+ family: 'Arial',
1403
+ style: 'Italic',
1404
+ weight: 700,
1405
+ });
1406
+ expect(tokens[4].type).toBe(TokenType.SPACE);
1407
+ expect(tokens[4].ctx.fontFace).toEqual({
1408
+ family: 'Arial',
1409
+ style: 'Italic',
1410
+ weight: 700,
1411
+ });
1412
+ expect(tokens[5].type).toBe(TokenType.WORD);
1413
+ expect(tokens[5].data).toBe('Text');
1414
+ expect(tokens[5].ctx.fontFace).toEqual({
1415
+ family: 'Arial',
1416
+ style: 'Italic',
1417
+ weight: 700,
1418
+ });
1419
+ expect(tokens[6].type).toBe(TokenType.PROPERTIES_CHANGED);
1420
+ expect(tokens[6].data).toEqual({
1421
+ command: undefined,
1422
+ changes: {
1423
+ aci: 256,
1424
+ capHeight: { value: 1, isRelative: false },
1425
+ fontFace: { family: '', style: 'Regular', weight: 400 },
1426
+ },
1427
+ depth: 0,
1428
+ });
1429
+ });
1430
+ });
1431
+
1432
+ describe('MTextParser context restoration with braces {}', () => {
1433
+ it('scopes font formatting to braces and restores after', () => {
1434
+ const parser = new MTextParser('Normal {\\fArial|i;Italic} Back to normal');
1435
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1436
+ expect(tokens[0].data).toBe('Normal');
1437
+ expect(tokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1438
+ expect(tokens[1].data).toBe('Italic');
1439
+ expect(tokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1440
+ expect(tokens[2].data).toBe('Back');
1441
+ expect(tokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1442
+ });
1443
+
1444
+ it('scopes color formatting to braces and restores after', () => {
1445
+ const parser = new MTextParser('{\\C1;Red} Normal');
1446
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1447
+ expect(tokens[0].data).toBe('Red');
1448
+ expect(tokens[0].ctx.aci).toBe(1);
1449
+ expect(tokens[1].data).toBe('Normal');
1450
+ expect(tokens[1].ctx.aci).toBe(256); // default
1451
+ });
1452
+
1453
+ it('restores previous formatting after a formatting block', () => {
1454
+ const parser = new MTextParser('\\C2;Before {\\C1;Red} After');
1455
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1456
+ expect(tokens[0].data).toBe('Before');
1457
+ expect(tokens[0].ctx.aci).toBe(2);
1458
+ expect(tokens[1].data).toBe('Red');
1459
+ expect(tokens[1].ctx.aci).toBe(1);
1460
+ expect(tokens[2].data).toBe('After');
1461
+ expect(tokens[2].ctx.aci).toBe(2);
1462
+ });
1463
+
1464
+ it('restores context correctly with nested braces', () => {
1465
+ const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}');
1466
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1467
+ expect(tokens[0].data).toBe('Red');
1468
+ expect(tokens[0].ctx.aci).toBe(1);
1469
+ expect(tokens[1].data).toBe('Blue');
1470
+ expect(tokens[1].ctx.aci).toBe(2);
1471
+ expect(tokens[2].data).toBe('RedAgain');
1472
+ expect(tokens[2].ctx.aci).toBe(1);
1473
+ });
1474
+
1475
+ it('persists formatting outside braces if not reset', () => {
1476
+ const parser = new MTextParser('\\C3;All {\\C1;Red} StillAll');
1477
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1478
+ expect(tokens[0].data).toBe('All');
1479
+ expect(tokens[0].ctx.aci).toBe(3);
1480
+ expect(tokens[1].data).toBe('Red');
1481
+ expect(tokens[1].ctx.aci).toBe(1);
1482
+ expect(tokens[2].data).toBe('StillAll');
1483
+ expect(tokens[2].ctx.aci).toBe(3);
1484
+ });
1485
+ });
1486
+
1487
+ describe('MTextParser context restoration with braces {} and yieldPropertyCommands', () => {
1488
+ it('yields property change tokens when entering and exiting a formatting block', () => {
1489
+ const parser = new MTextParser('Normal {\\fArial|i;Italic} Back', undefined, {
1490
+ yieldPropertyCommands: true,
1491
+ });
1492
+ const tokens = Array.from(parser.parse());
1493
+ // Filter for property changes and words
1494
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1495
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1496
+ // Should yield a property change for entering Arial Italic
1497
+ expect(propTokens[0].data).toEqual({
1498
+ command: 'f',
1499
+ changes: { fontFace: { family: 'Arial', style: 'Italic', weight: 400 } },
1500
+ depth: 1,
1501
+ });
1502
+ // Should yield a property change for restoring default font after block
1503
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1504
+ command: undefined,
1505
+ changes: { fontFace: { family: '', style: 'Regular', weight: 400 } },
1506
+ depth: 0,
1507
+ });
1508
+ // Check word tokens context
1509
+ expect(wordTokens[0].data).toBe('Normal');
1510
+ expect(wordTokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1511
+ expect(wordTokens[1].data).toBe('Italic');
1512
+ expect(wordTokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1513
+ expect(wordTokens[2].data).toBe('Back');
1514
+ expect(wordTokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1515
+ });
1516
+
1517
+ it('yields property change tokens for color and restores after block', () => {
1518
+ const parser = new MTextParser('{\\C1;Red} Normal', undefined, {
1519
+ yieldPropertyCommands: true,
1520
+ });
1521
+ const tokens = Array.from(parser.parse());
1522
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1523
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1524
+ expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1525
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1526
+ command: undefined,
1527
+ changes: { aci: 256 },
1528
+ depth: 0,
1529
+ });
1530
+ expect(wordTokens[0].data).toBe('Red');
1531
+ expect(wordTokens[0].ctx.aci).toBe(1);
1532
+ expect(wordTokens[1].data).toBe('Normal');
1533
+ expect(wordTokens[1].ctx.aci).toBe(256);
1534
+ });
1535
+
1536
+ it('yields property change tokens for nested braces', () => {
1537
+ const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}', undefined, {
1538
+ yieldPropertyCommands: true,
1539
+ });
1540
+ const tokens = Array.from(parser.parse());
1541
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1542
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1543
+ // Enter C1
1544
+ expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1545
+ // Enter C2
1546
+ expect(propTokens[1].data).toEqual({ command: 'C', changes: { aci: 2 }, depth: 2 });
1547
+ // Exit C2 (restore C1)
1548
+ expect(propTokens[2].data).toEqual({ command: undefined, changes: { aci: 1 }, depth: 1 });
1549
+ // Exit C1 (restore default)
1550
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1551
+ command: undefined,
1552
+ changes: { aci: 256 },
1553
+ depth: 0,
1554
+ });
1555
+ expect(wordTokens[0].data).toBe('Red');
1556
+ expect(wordTokens[0].ctx.aci).toBe(1);
1557
+ expect(wordTokens[1].data).toBe('Blue');
1558
+ expect(wordTokens[1].ctx.aci).toBe(2);
1559
+ expect(wordTokens[2].data).toBe('RedAgain');
1560
+ expect(wordTokens[2].ctx.aci).toBe(1);
1561
+ });
1562
+
1563
+ it('yields property change tokens for RGB color commands', () => {
1564
+ // \c16711680 is 0xFF0000, which is [255,0,0] (red)
1565
+ const parser = new MTextParser('\\c16711680Red Text', undefined, {
1566
+ yieldPropertyCommands: true,
1567
+ });
1568
+ const tokens = Array.from(parser.parse());
1569
+ expect(tokens).toHaveLength(4);
1570
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1571
+ expect(tokens[0].data).toEqual({
1572
+ command: 'c',
1573
+ changes: {
1574
+ aci: null,
1575
+ rgb: [255, 0, 0],
1576
+ },
1577
+ depth: 0,
1578
+ });
1579
+ expect(tokens[1].type).toBe(TokenType.WORD);
1580
+ expect(tokens[1].data).toBe('Red');
1581
+ expect(tokens[1].ctx.rgb).toEqual([255, 0, 0]);
1582
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1583
+ expect(tokens[2].ctx.rgb).toEqual([255, 0, 0]);
1584
+ expect(tokens[3].type).toBe(TokenType.WORD);
1585
+ expect(tokens[3].data).toBe('Text');
1586
+ expect(tokens[3].ctx.rgb).toEqual([255, 0, 0]);
1587
+ });
1588
+ });
1589
+ });
1590
+
1591
+ describe('MTextParser resetParagraphParameters option', () => {
1592
+ it('resets paragraph properties after NEW_PARAGRAPH when resetParagraphParameters is true', () => {
1593
+ // Create a context with non-default paragraph properties
1594
+ const ctx = new MTextContext();
1595
+ ctx.paragraph.indent = 2;
1596
+ ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1597
+
1598
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1599
+ yieldPropertyCommands: true,
1600
+ resetParagraphParameters: true,
1601
+ });
1602
+ const tokens = Array.from(parser.parse());
1603
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, PROPERTIES_CHANGED (reset), WORD(Line2)
1604
+ expect(tokens[0].type).toBe(TokenType.WORD);
1605
+ expect(tokens[0].data).toBe('Line1');
1606
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1607
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1608
+ const propChanged = tokens[2].data as import('./parser').ChangedProperties;
1609
+ expect(propChanged.changes).toHaveProperty('paragraph');
1610
+ expect(tokens[3].type).toBe(TokenType.WORD);
1611
+ expect(tokens[3].data).toBe('Line2');
1612
+ });
1613
+
1614
+ it('does not emit PROPERTIES_CHANGED after NEW_PARAGRAPH if resetParagraphParameters is false', () => {
1615
+ // Create a context with non-default paragraph properties
1616
+ const ctx = new MTextContext();
1617
+ ctx.paragraph.indent = 2;
1618
+ ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1619
+
1620
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1621
+ yieldPropertyCommands: true,
1622
+ resetParagraphParameters: false,
1623
+ });
1624
+ const tokens = Array.from(parser.parse());
1625
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1626
+ expect(tokens[0].type).toBe(TokenType.WORD);
1627
+ expect(tokens[0].data).toBe('Line1');
1628
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1629
+ expect(tokens[2].type).toBe(TokenType.WORD);
1630
+ expect(tokens[2].data).toBe('Line2');
1631
+ expect(
1632
+ tokens.find(
1633
+ t =>
1634
+ t.type === TokenType.PROPERTIES_CHANGED &&
1635
+ t.data &&
1636
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1637
+ )
1638
+ ).toBeUndefined();
1639
+ });
1640
+
1641
+ it('resets paragraph properties but does not emit PROPERTIES_CHANGED if yieldPropertyCommands is false', () => {
1642
+ // Create a context with non-default paragraph properties
1643
+ const ctx = new MTextContext();
1644
+ ctx.paragraph.indent = 2;
1645
+ ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1646
+
1647
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1648
+ yieldPropertyCommands: false,
1649
+ resetParagraphParameters: true,
1650
+ });
1651
+ const tokens = Array.from(parser.parse());
1652
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1653
+ expect(tokens[0].type).toBe(TokenType.WORD);
1654
+ expect(tokens[0].data).toBe('Line1');
1655
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1656
+ expect(tokens[2].type).toBe(TokenType.WORD);
1657
+ expect(tokens[2].data).toBe('Line2');
1658
+ expect(
1659
+ tokens.find(
1660
+ t =>
1661
+ t.type === TokenType.PROPERTIES_CHANGED &&
1662
+ t.data &&
1663
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1664
+ )
1665
+ ).toBeUndefined();
1666
+ });
1667
+
1668
+ it('does not emit PROPERTIES_CHANGED when using default context with resetParagraphParameters true', () => {
1669
+ const parser = new MTextParser('Line1\\PLine2', undefined, {
1670
+ yieldPropertyCommands: true,
1671
+ resetParagraphParameters: true,
1672
+ });
1673
+ const tokens = Array.from(parser.parse());
1674
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - no PROPERTIES_CHANGED because default context has default paragraph properties
1675
+ expect(tokens[0].type).toBe(TokenType.WORD);
1676
+ expect(tokens[0].data).toBe('Line1');
1677
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1678
+ expect(tokens[2].type).toBe(TokenType.WORD);
1679
+ expect(tokens[2].data).toBe('Line2');
1680
+ expect(
1681
+ tokens.find(
1682
+ t =>
1683
+ t.type === TokenType.PROPERTIES_CHANGED &&
1684
+ t.data &&
1685
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1686
+ )
1687
+ ).toBeUndefined();
1688
+ });
1689
+ });
1690
+
1691
+ describe('TextScanner', () => {
1692
+ let scanner: TextScanner;
1693
+
1694
+ beforeEach(() => {
1695
+ scanner = new TextScanner('Hello World');
1696
+ });
1697
+
1698
+ it('initializes with correct state', () => {
1699
+ expect(scanner.currentIndex).toBe(0);
1700
+ expect(scanner.isEmpty).toBe(false);
1701
+ expect(scanner.hasData).toBe(true);
1702
+ });
1703
+
1704
+ it('consumes characters', () => {
1705
+ expect(scanner.get()).toBe('H');
1706
+ expect(scanner.currentIndex).toBe(1);
1707
+ expect(scanner.get()).toBe('e');
1708
+ expect(scanner.currentIndex).toBe(2);
1709
+ });
1710
+
1711
+ it('peeks characters', () => {
1712
+ expect(scanner.peek()).toBe('H');
1713
+ expect(scanner.peek(1)).toBe('e');
1714
+ expect(scanner.currentIndex).toBe(0);
1715
+ });
1716
+
1717
+ it('consumes multiple characters', () => {
1718
+ scanner.consume(5);
1719
+ expect(scanner.currentIndex).toBe(5);
1720
+ expect(scanner.get()).toBe(' ');
1721
+ });
1722
+
1723
+ it('finds characters', () => {
1724
+ expect(scanner.find('W')).toBe(6);
1725
+ expect(scanner.find('X')).toBe(-1);
1726
+ });
1727
+
1728
+ it('handles escaped characters in find', () => {
1729
+ scanner = new TextScanner('Hello\\;World');
1730
+ expect(scanner.find(';', true)).toBe(6);
1731
+ });
1732
+
1733
+ it('gets remaining text', () => {
1734
+ scanner.consume(6);
1735
+ expect(scanner.tail).toBe('World');
1736
+ });
1737
+
1738
+ it('handles end of text', () => {
1739
+ scanner.consume(11);
1740
+ expect(scanner.isEmpty).toBe(true);
1741
+ expect(scanner.hasData).toBe(false);
1742
+ expect(scanner.get()).toBe('');
1743
+ expect(scanner.peek()).toBe('');
1744
+ });
1745
+ });
1746
+
1747
+ describe('getFonts', () => {
1748
+ it('should return empty set for empty string', () => {
1749
+ const result = getFonts('');
1750
+ expect(result).toEqual(new Set());
1751
+ });
1752
+
1753
+ it('should extract single font name', () => {
1754
+ const result = getFonts('\\fArial|Hello World');
1755
+ expect(result).toEqual(new Set(['arial']));
1756
+ });
1757
+
1758
+ it('should extract multiple unique font names', () => {
1759
+ const result = getFonts('\\fArial|Hello \\fTimes New Roman|World');
1760
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
1761
+ });
1762
+
1763
+ it('should handle case-insensitive font names', () => {
1764
+ const result = getFonts('\\fARIAL|Hello \\fArial|World');
1765
+ expect(result).toEqual(new Set(['arial']));
1766
+ });
1767
+
1768
+ it('should handle font names with spaces', () => {
1769
+ const result = getFonts('\\fTimes New Roman|Hello \\fComic Sans MS|World');
1770
+ expect(result).toEqual(new Set(['times new roman', 'comic sans ms']));
1771
+ });
1772
+
1773
+ it('should handle multiple font changes in sequence', () => {
1774
+ const result = getFonts('\\fArial|Hello \\fTimes New Roman|World \\fArial|Again');
1775
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
1776
+ });
1777
+
1778
+ it('should handle font names with special characters', () => {
1779
+ const result = getFonts('\\fArial-Bold|Hello \\fTimes-New-Roman|World');
1780
+ expect(result).toEqual(new Set(['arial-bold', 'times-new-roman']));
1781
+ });
1782
+
1783
+ it('should handle both lowercase and uppercase font commands', () => {
1784
+ const result = getFonts('\\fArial|Hello \\FTimes New Roman|World');
1785
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
1786
+ });
1787
+
1788
+ it('should handle complex MText with semicolon terminators', () => {
1789
+ const mtext =
1790
+ '{\\C1;\\W2;\\FSimSun;SimSun Text}\\P{\\C2;\\W0.5;\\FArial;Arial Text}\\P{\\C3;\\O30;\\FRomans;Romans Text}\\P{\\C4;\\Q1;\\FSimHei;SimHei Text}\\P{\\C5;\\Q0.5;\\FSimKai;SimKai Text}';
1791
+ const result = getFonts(mtext);
1792
+ expect(result).toEqual(new Set(['simsun', 'arial', 'romans', 'simhei', 'simkai']));
1793
+ });
1794
+
1795
+ it('should preserve font extensions when removeExtension is false', () => {
1796
+ const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
1797
+ const result = getFonts(mtext, false);
1798
+ expect(result).toEqual(new Set(['arial.ttf', 'times new roman.otf']));
1799
+ });
1800
+
1801
+ it('should remove font extensions when removeExtension is true', () => {
1802
+ const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
1803
+ const result = getFonts(mtext, true);
1804
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
1805
+ });
1806
+
1807
+ it('should handle various font extensions', () => {
1808
+ const mtext = '\\fFont1.ttf|Text1 \\fFont2.otf|Text2 \\fFont3.woff|Text3 \\fFont4.shx|Text4';
1809
+ const result = getFonts(mtext, true);
1810
+ expect(result).toEqual(new Set(['font1', 'font2', 'font3', 'font4']));
1811
+ });
1812
+
1813
+ it('should not remove non-font extensions', () => {
1814
+ const mtext = '\\fFont1.txt|Text1 \\fFont2.doc|Text2';
1815
+ const result = getFonts(mtext, true);
1816
+ expect(result).toEqual(new Set(['font1.txt', 'font2.doc']));
1817
+ });
1818
+ });
1819
+
1820
+ describe('MTextColor', () => {
1821
+ it('defaults to ACI 256 (by layer)', () => {
1822
+ const color = new MTextColor();
1823
+ expect(color.aci).toBe(256);
1824
+ expect(color.rgb).toBeNull();
1825
+ expect(color.isAci).toBe(true);
1826
+ expect(color.isRgb).toBe(false);
1827
+ });
1828
+
1829
+ it('can be constructed with ACI', () => {
1830
+ const color = new MTextColor(1);
1831
+ expect(color.aci).toBe(1);
1832
+ expect(color.rgb).toBeNull();
1833
+ expect(color.isAci).toBe(true);
1834
+ expect(color.isRgb).toBe(false);
1835
+ });
1836
+
1837
+ it('can be constructed with RGB', () => {
1838
+ const color = new MTextColor([255, 0, 0]);
1839
+ expect(color.aci).toBeNull();
1840
+ expect(color.rgb).toEqual([255, 0, 0]);
1841
+ expect(color.isAci).toBe(false);
1842
+ expect(color.isRgb).toBe(true);
1843
+ });
1844
+
1845
+ it('switches from ACI to RGB and back', () => {
1846
+ const color = new MTextColor(2);
1847
+ expect(color.aci).toBe(2);
1848
+ color.rgb = [0, 255, 0];
1849
+ expect(color.aci).toBeNull();
1850
+ expect(color.rgb).toEqual([0, 255, 0]);
1851
+ color.aci = 7;
1852
+ expect(color.aci).toBe(7);
1853
+ expect(color.rgb).toBeNull();
1854
+ });
1855
+
1856
+ it('copy() produces a deep copy', () => {
1857
+ const color = new MTextColor([1, 2, 3]);
1858
+ const copy = color.copy();
1859
+ expect(copy).not.toBe(color);
1860
+ expect(copy.aci).toBe(color.aci);
1861
+ expect(copy.rgb).toEqual(color.rgb);
1862
+ copy.rgb = [4, 5, 6];
1863
+ expect(color.rgb).toEqual([1, 2, 3]);
1864
+ expect(copy.rgb).toEqual([4, 5, 6]);
1865
+ });
1866
+ });