@mlightcad/mtext-parser 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2015 +1,2157 @@
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
- describe('yieldPercentSymbols', () => {
751
- it('emits PERCENT_SYMBOL tokens for %%c, %%d, and %%p', () => {
752
- const parser = new MTextParser('%%c%%d%%p', undefined, {
753
- yieldPercentSymbols: true,
754
- });
755
- const tokens = Array.from(parser.parse());
756
- expect(tokens).toHaveLength(3);
757
- expect(tokens[0]).toMatchObject({
758
- type: TokenType.PERCENT_SYMBOL,
759
- data: { kind: 'named', code: 'c', char: 'Ø' },
760
- });
761
- expect(tokens[1]).toMatchObject({
762
- type: TokenType.PERCENT_SYMBOL,
763
- data: { kind: 'named', code: 'd', char: '°' },
764
- });
765
- expect(tokens[2]).toMatchObject({
766
- type: TokenType.PERCENT_SYMBOL,
767
- data: { kind: 'named', code: 'p', char: '±' },
768
- });
769
- });
770
-
771
- it('emits PERCENT_SYMBOL for numeric %%ddd codes', () => {
772
- const parser = new MTextParser('%%130%%132', undefined, {
773
- yieldPercentSymbols: true,
774
- });
775
- const tokens = Array.from(parser.parse());
776
- expect(tokens).toHaveLength(2);
777
- expect(tokens[0]).toMatchObject({
778
- type: TokenType.PERCENT_SYMBOL,
779
- data: {
780
- kind: 'numeric',
781
- charCode: 130,
782
- char: String.fromCharCode(130),
783
- },
784
- });
785
- expect(tokens[1]).toMatchObject({
786
- type: TokenType.PERCENT_SYMBOL,
787
- data: {
788
- kind: 'numeric',
789
- charCode: 132,
790
- char: String.fromCharCode(132),
791
- },
792
- });
793
- });
794
-
795
- it('emits PERCENT_SYMBOL for literal percent (%%%)', () => {
796
- const parser = new MTextParser('%%%', undefined, {
797
- yieldPercentSymbols: true,
798
- });
799
- const tokens = Array.from(parser.parse());
800
- expect(tokens).toHaveLength(1);
801
- expect(tokens[0]).toMatchObject({
802
- type: TokenType.PERCENT_SYMBOL,
803
- data: { kind: 'literal', char: '%' },
804
- });
805
- });
806
-
807
- it('interleaves WORD and PERCENT_SYMBOL tokens', () => {
808
- const parser = new MTextParser('A%%cB', undefined, {
809
- yieldPercentSymbols: true,
810
- });
811
- const tokens = Array.from(parser.parse());
812
- expect(tokens).toHaveLength(3);
813
- expect(tokens[0].type).toBe(TokenType.WORD);
814
- expect(tokens[0].data).toBe('A');
815
- expect(tokens[1].type).toBe(TokenType.PERCENT_SYMBOL);
816
- expect(tokens[1].data).toEqual({ kind: 'named', code: 'c', char: 'Ø' });
817
- expect(tokens[2].type).toBe(TokenType.WORD);
818
- expect(tokens[2].data).toBe('B');
819
- });
820
-
821
- it('preserves default WORD expansion when yieldPercentSymbols is false', () => {
822
- const parser = new MTextParser('%%c', undefined, {
823
- yieldPercentSymbols: false,
824
- });
825
- const tokens = Array.from(parser.parse());
826
- expect(tokens[0].type).toBe(TokenType.WORD);
827
- expect(tokens[0].data).toBe('Ø');
828
- });
829
- });
830
- });
831
-
832
- describe('GBK character encoding', () => {
833
- it('decodes GBK hex codes', () => {
834
- // Test "你" (C4E3 in GBK)
835
- let parser = new MTextParser('\\M+C4E3', undefined, { yieldPropertyCommands: false });
836
- let tokens = Array.from(parser.parse());
837
- expect(tokens[0].type).toBe(TokenType.WORD);
838
- expect(tokens[0].data).toBe('你');
839
-
840
- // Test "好" (BAC3 in GBK)
841
- parser = new MTextParser('\\M+BAC3', undefined, { yieldPropertyCommands: false });
842
- tokens = Array.from(parser.parse());
843
- expect(tokens[0].type).toBe(TokenType.WORD);
844
- expect(tokens[0].data).toBe('好');
845
-
846
- // Test multiple GBK characters
847
- parser = new MTextParser('\\M+C4E3\\M+BAC3', undefined, { yieldPropertyCommands: false });
848
- tokens = Array.from(parser.parse());
849
- expect(tokens[0].type).toBe(TokenType.WORD);
850
- expect(tokens[0].data).toBe('你');
851
- expect(tokens[1].type).toBe(TokenType.WORD);
852
- expect(tokens[1].data).toBe('好');
853
- });
854
-
855
- it('handles invalid GBK codes', () => {
856
- // Test invalid hex code
857
- let parser = new MTextParser('\\M+XXXX', undefined, { yieldPropertyCommands: false });
858
- let tokens = Array.from(parser.parse());
859
- expect(tokens[0].type).toBe(TokenType.WORD);
860
- expect(tokens[0].data).toBe('\\M+XXXX');
861
-
862
- // Test incomplete hex code
863
- parser = new MTextParser('\\M+C4', undefined, { yieldPropertyCommands: false });
864
- tokens = Array.from(parser.parse());
865
- expect(tokens[0].type).toBe(TokenType.WORD);
866
- expect(tokens[0].data).toBe('\\M+C4');
867
-
868
- // Test missing plus sign
869
- parser = new MTextParser('\\MC4E3', undefined, { yieldPropertyCommands: false });
870
- tokens = Array.from(parser.parse());
871
- expect(tokens[0].type).toBe(TokenType.WORD);
872
- expect(tokens[0].data).toBe('\\MC4E3');
873
- });
874
-
875
- it('handles GBK characters with other formatting', () => {
876
- // Test GBK characters with height command
877
- const parser = new MTextParser('\\H2.5;\\M+C4E3\\H.5x;\\M+BAC3', undefined, {
878
- yieldPropertyCommands: false,
879
- });
880
- const tokens = Array.from(parser.parse());
881
- expect(tokens).toHaveLength(2);
882
- expect(tokens[0].type).toBe(TokenType.WORD);
883
- expect(tokens[0].data).toBe('你');
884
- expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
885
- expect(tokens[1].type).toBe(TokenType.WORD);
886
- expect(tokens[1].data).toBe('好');
887
- expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
888
- });
889
-
890
- it('handles GBK characters with font properties', () => {
891
- const parser = new MTextParser('{\\fgbcbig.shx|b0|i0|c0|p0;\\M+C4E3\\M+BAC3}', undefined, {
892
- yieldPropertyCommands: false,
893
- });
894
- const tokens = Array.from(parser.parse());
895
- expect(tokens[0].type).toBe(TokenType.WORD);
896
- expect(tokens[0].data).toBe('你');
897
- expect(tokens[1].type).toBe(TokenType.WORD);
898
- expect(tokens[1].data).toBe('好');
899
- expect(tokens[0].ctx.fontFace).toEqual({
900
- family: 'gbcbig.shx',
901
- style: 'Regular',
902
- weight: 400,
903
- });
904
- expect(tokens[1].ctx.fontFace).toEqual({
905
- family: 'gbcbig.shx',
906
- style: 'Regular',
907
- weight: 400,
908
- });
909
- });
910
- });
911
-
912
- describe('MIF (Multibyte Interchange Format) with custom decoder', () => {
913
- it('uses custom decoder when provided', () => {
914
- const customDecoder = (hex: string) => {
915
- // Custom decoder that reverses the hex and converts to char
916
- const reversed = hex.split('').reverse().join('');
917
- const codePoint = parseInt(reversed, 16);
918
- return String.fromCodePoint(codePoint);
919
- };
920
-
921
- const parser = new MTextParser('\\M+C4E3', undefined, {
922
- mifDecoder: customDecoder,
923
- });
924
- const tokens = Array.from(parser.parse());
925
- expect(tokens[0].type).toBe(TokenType.WORD);
926
- // The custom decoder will produce different output
927
- expect(tokens[0].data).not.toBe('\\M+C4E3');
928
- });
929
-
930
- it('parses 5-digit MIF codes with auto-detect', () => {
931
- // Use default decoder with auto-detect
932
- const parser = new MTextParser('\\M+1A2B3', undefined, {
933
- mifCodeLength: 'auto',
934
- });
935
- const tokens = Array.from(parser.parse());
936
- expect(tokens[0].type).toBe(TokenType.WORD);
937
- // Should successfully parse 5-digit code
938
- expect(tokens[0].data).not.toBe('\\M+1A2B3');
939
- });
940
-
941
- it('parses 5-digit MIF codes when specified', () => {
942
- const parser = new MTextParser('\\M+1A2B3', undefined, {
943
- mifCodeLength: 5,
944
- });
945
- const tokens = Array.from(parser.parse());
946
- expect(tokens[0].type).toBe(TokenType.WORD);
947
- expect(tokens[0].data).not.toBe('\\M+1A2B3');
948
- });
949
-
950
- it('parses 4-digit MIF codes when specified', () => {
951
- const parser = new MTextParser('\\M+C4E3', undefined, {
952
- mifCodeLength: 4,
953
- });
954
- const tokens = Array.from(parser.parse());
955
- expect(tokens[0].type).toBe(TokenType.WORD);
956
- expect(tokens[0].data).toBe('你');
957
- });
958
-
959
- it('falls back to 4-digit when 5-digit not available', () => {
960
- const parser = new MTextParser('\\M+C4E3', undefined, {
961
- mifCodeLength: 'auto',
962
- });
963
- const tokens = Array.from(parser.parse());
964
- expect(tokens[0].type).toBe(TokenType.WORD);
965
- expect(tokens[0].data).toBe('你');
966
- });
967
-
968
- it('uses custom decoder with specific code length', () => {
969
- const customDecoder = (hex: string) => `[DECODED:${hex}]`;
970
- const parser = new MTextParser('\\M+C4E3', undefined, {
971
- mifDecoder: customDecoder,
972
- mifCodeLength: 4,
973
- });
974
- const tokens = Array.from(parser.parse());
975
- expect(tokens[0].type).toBe(TokenType.WORD);
976
- expect(tokens[0].data).toBe('[DECODED:C4E3]');
977
- });
978
-
979
- it('parses complex MText with 5-digit MIF codes and Unicode', () => {
980
- // Test data: \M+1928D:\M+18DD1\M+197702.0t\U+9540\U+950C\M+194C2\M+190A7\M+18DEC\M+18142
981
- // According to user, \M+19770 should be parsed as 5-digit code, leaving "2.0t" as separate characters
982
- // But the parser's auto-detect logic tries 5 digits first, which consumes "19770"
983
- // So "2" becomes part of the next sequence
984
- const mtext =
985
- '\\M+1928D:\\M+18DD1\\M+197702.0t\\U+9540\\U+950C\\M+194C2\\M+190A7\\M+18DEC\\M+18142';
986
- const parser = new MTextParser(mtext, undefined, {
987
- mifCodeLength: 'auto',
988
- });
989
- const tokens = Array.from(parser.parse());
990
-
991
- // Should parse without errors and generate tokens
992
- // The parser produces 11 tokens: each MIF code and Unicode becomes one token, and "2.0t" becomes a single token
993
- expect(tokens.length).toBe(11);
994
-
995
- // Verify the decoded characters are valid
996
- const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
997
- expect(wordTokens.length).toBeGreaterThan(0);
998
-
999
- // Note: 5-digit MIF codes return placeholder character '▯' as per decodeMultiByteChar implementation
1000
- // Only verify that Unicode characters (4-digit hex) are decoded properly
1001
- const unicodeTokens = wordTokens.filter(t => t.data && t.data !== '▯');
1002
- expect(unicodeTokens.length).toBeGreaterThan(0);
1003
- });
1004
-
1005
- it('decodes specific 5-digit MIF codes correctly', () => {
1006
- // Test individual 5-digit MIF codes from the provided test data
1007
- const testCases = [
1008
- { code: '1928D', decoded: '注' },
1009
- { code: '18DD1', decoded: '采' },
1010
- { code: '19770', decoded: '用' },
1011
- { code: '194C2', decoded: '板' },
1012
- { code: '190A7', decoded: '制' },
1013
- { code: '18DEC', decoded: '作' },
1014
- { code: '18142', decoded: '。' },
1015
- ];
1016
-
1017
- testCases.forEach(({ code, decoded }) => {
1018
- const parser = new MTextParser(`\\M+${code}`, undefined, {
1019
- mifCodeLength: 5,
1020
- });
1021
- const tokens = Array.from(parser.parse());
1022
- expect(tokens[0].type).toBe(TokenType.WORD);
1023
- // Note: decodeMultiByteChar returns '▯' for 5-digit codes
1024
- if (tokens[0].data && typeof tokens[0].data === 'string') {
1025
- expect(tokens[0].data).toBe(decoded);
1026
- }
1027
- });
1028
- });
1029
- });
1030
-
1031
- describe('Unicode (\\U+XXXX) escape sequences', () => {
1032
- it('decodes Unicode BMP code points with exactly four hex digits', () => {
1033
- // Greek capital omega: \U+03A9
1034
- const parser = new MTextParser('Omega: \\U+03A9');
1035
- const tokens = Array.from(parser.parse());
1036
- expect(tokens[0].type).toBe(TokenType.WORD);
1037
- expect(tokens[0].data).toBe('Omega:');
1038
- expect(tokens[1].type).toBe(TokenType.SPACE);
1039
- expect(tokens[2].type).toBe(TokenType.WORD);
1040
- expect(tokens[2].data).toBe('Ω');
1041
- });
1042
-
1043
- it('decodes supplementary-plane characters via UTF-16 surrogate pairs', () => {
1044
- // Grinning face U+1F600 as AutoCAD surrogate pair \U+D83D\U+DE00
1045
- const parser = new MTextParser('Smile: \\U+D83D\\U+DE00');
1046
- const tokens = Array.from(parser.parse());
1047
- expect(tokens[0].type).toBe(TokenType.WORD);
1048
- expect(tokens[0].data).toBe('Smile:');
1049
- expect(tokens[1].type).toBe(TokenType.SPACE);
1050
- expect(tokens[2].type).toBe(TokenType.WORD);
1051
- expect(tokens[2].data).toBe('😀');
1052
- });
1053
-
1054
- it('preserves literal supplementary-plane characters in word tokens', () => {
1055
- const parser = new MTextParser('Hello 😀 world');
1056
- const tokens = Array.from(parser.parse());
1057
- expect(tokens[0].data).toBe('Hello');
1058
- expect(tokens[1].type).toBe(TokenType.SPACE);
1059
- expect(tokens[2].type).toBe(TokenType.WORD);
1060
- expect(tokens[2].data).toBe('😀');
1061
- expect([...(tokens[2].data as string)]).toEqual(['😀']);
1062
- expect(tokens[3].type).toBe(TokenType.SPACE);
1063
- expect(tokens[4].data).toBe('world');
1064
- });
1065
-
1066
- it('does not consume hex digits after the four-digit code (cad-viewer#615)', () => {
1067
- const parser = new MTextParser('\\U+22054.0通');
1068
- const tokens = Array.from(parser.parse());
1069
- const words = tokens
1070
- .filter(token => token.type === TokenType.WORD)
1071
- .map(token => String(token.data))
1072
- .join('');
1073
- expect(words).toBe('∅4.0通');
1074
- });
1075
-
1076
- it('treats a fifth hex digit after \\U+XXXX as literal text', () => {
1077
- // Non-standard \\U+1F600 must be read as U+1F60 + literal "0"
1078
- const parser = new MTextParser('Smile: \\U+1F600');
1079
- const tokens = Array.from(parser.parse());
1080
- expect(tokens[0].type).toBe(TokenType.WORD);
1081
- expect(tokens[0].data).toBe('Smile:');
1082
- expect(tokens[1].type).toBe(TokenType.SPACE);
1083
- expect(tokens[2].type).toBe(TokenType.WORD);
1084
- expect(tokens[2].data).toBe(String.fromCharCode(0x1f60));
1085
- expect(tokens[3].type).toBe(TokenType.WORD);
1086
- expect(tokens[3].data).toBe('0');
1087
- });
1088
-
1089
- it('handles invalid or incomplete Unicode escapes as literal text', () => {
1090
- // Not enough hex digits
1091
- let parser = new MTextParser('Test: \\U+12');
1092
- let tokens = Array.from(parser.parse());
1093
- expect(tokens[0].type).toBe(TokenType.WORD);
1094
- expect(tokens[0].data).toBe('Test:');
1095
- expect(tokens[1].type).toBe(TokenType.SPACE);
1096
- expect(tokens[2].type).toBe(TokenType.WORD);
1097
- expect(tokens[2].data).toBe('\\U+12');
1098
-
1099
- // Invalid hex
1100
- parser = new MTextParser('Test: \\U+ZZZZ');
1101
- tokens = Array.from(parser.parse());
1102
- expect(tokens[0].type).toBe(TokenType.WORD);
1103
- expect(tokens[0].data).toBe('Test:');
1104
- expect(tokens[1].type).toBe(TokenType.SPACE);
1105
- expect(tokens[2].type).toBe(TokenType.WORD);
1106
- expect(tokens[2].data).toBe('\\U+ZZZZ');
1107
- });
1108
- });
1109
-
1110
- describe('stacking', () => {
1111
- it('parses basic fractions with different dividers', () => {
1112
- let parser = new MTextParser('\\S1/2;');
1113
- let tokens = Array.from(parser.parse());
1114
- expect(tokens[0].type).toBe(TokenType.STACK);
1115
- expect(tokens[0].data).toEqual(['1', '2', '/']);
1116
-
1117
- parser = new MTextParser('\\S1#2;');
1118
- tokens = Array.from(parser.parse());
1119
- expect(tokens[0].type).toBe(TokenType.STACK);
1120
- expect(tokens[0].data).toEqual(['1', '2', '#']);
1121
- });
1122
-
1123
- it('handles caret for baseline alignment', () => {
1124
- let parser = new MTextParser('\\S1^2;');
1125
- let tokens = Array.from(parser.parse());
1126
- expect(tokens[0].type).toBe(TokenType.STACK);
1127
- expect(tokens[0].data).toEqual(['1', '2', '^']);
1128
-
1129
- // Test with spaces
1130
- parser = new MTextParser('\\S1 2^3 4;');
1131
- tokens = Array.from(parser.parse());
1132
- expect(tokens[0].type).toBe(TokenType.STACK);
1133
- expect(tokens[0].data).toEqual(['1 2', '3 4', '^']);
1134
-
1135
- // Test with escaped characters
1136
- parser = new MTextParser('\\S1^2\\;;');
1137
- tokens = Array.from(parser.parse());
1138
- expect(tokens[0].type).toBe(TokenType.STACK);
1139
- expect(tokens[0].data).toEqual(['1', '2;', '^']);
1140
- });
1141
-
1142
- it('handles spaces in numerator and denominator', () => {
1143
- const parser = new MTextParser('\\S1 2/3 4;');
1144
- const tokens = Array.from(parser.parse());
1145
- expect(tokens[0].type).toBe(TokenType.STACK);
1146
- expect(tokens[0].data).toEqual(['1 2', '3 4', '/']);
1147
- });
1148
-
1149
- it('handles spaces after / and # dividers', () => {
1150
- let parser = new MTextParser('\\S1/ 2;');
1151
- let tokens = Array.from(parser.parse());
1152
- expect(tokens[0].type).toBe(TokenType.STACK);
1153
- expect(tokens[0].data).toEqual(['1', ' 2', '/']);
1154
-
1155
- parser = new MTextParser('\\S1# 2;');
1156
- tokens = Array.from(parser.parse());
1157
- expect(tokens[0].type).toBe(TokenType.STACK);
1158
- expect(tokens[0].data).toEqual(['1', ' 2', '#']);
1159
- });
1160
-
1161
- it('handles escaped terminator', () => {
1162
- const parser = new MTextParser('\\S1/2\\;;');
1163
- const tokens = Array.from(parser.parse());
1164
- expect(tokens[0].type).toBe(TokenType.STACK);
1165
- expect(tokens[0].data).toEqual(['1', '2;', '/']);
1166
- });
1167
-
1168
- it('ignores backslashes except for escaped terminator', () => {
1169
- const parser = new MTextParser('\\S\\N^ \\P;');
1170
- const tokens = Array.from(parser.parse());
1171
- expect(tokens[0].type).toBe(TokenType.STACK);
1172
- expect(tokens[0].data).toEqual(['N', 'P', '^']);
1173
- });
1174
-
1175
- it('renders grouping chars as simple braces', () => {
1176
- const parser = new MTextParser('\\S{1}/2;');
1177
- const tokens = Array.from(parser.parse());
1178
- expect(tokens[0].type).toBe(TokenType.STACK);
1179
- expect(tokens[0].data).toEqual(['{1}', '2', '/']);
1180
- });
1181
-
1182
- it('treats carets in stack formatting as literal text', () => {
1183
- let parser = new MTextParser('\\S^I/^J;');
1184
- let tokens = Array.from(parser.parse());
1185
- expect(tokens[0].type).toBe(TokenType.STACK);
1186
- expect(tokens[0].data).toEqual([' ', ' ', '/']);
1187
-
1188
- parser = new MTextParser('\\Sabc^def;');
1189
- tokens = Array.from(parser.parse());
1190
- expect(tokens[0].type).toBe(TokenType.STACK);
1191
- expect(tokens[0].data).toEqual(['abc', 'def', '^']);
1192
- });
1193
-
1194
- it('handles subscript and superscript', () => {
1195
- // Subscript
1196
- let parser = new MTextParser('abc\\S^ 1;');
1197
- let tokens = Array.from(parser.parse());
1198
- expect(tokens[0].type).toBe(TokenType.WORD);
1199
- expect(tokens[0].data).toEqual('abc');
1200
- expect(tokens[1].type).toBe(TokenType.STACK);
1201
- expect(tokens[1].data).toEqual(['', '1', '^']);
1202
-
1203
- // Superscript
1204
- parser = new MTextParser('abc\\S1^ ;');
1205
- tokens = Array.from(parser.parse());
1206
- expect(tokens[0].type).toBe(TokenType.WORD);
1207
- expect(tokens[0].data).toEqual('abc');
1208
- expect(tokens[1].type).toBe(TokenType.STACK);
1209
- expect(tokens[1].data).toEqual(['1', '', '^']);
1210
- });
1211
-
1212
- it('handles multiple divider chars', () => {
1213
- const parser = new MTextParser('\\S1/2/3;');
1214
- const tokens = Array.from(parser.parse());
1215
- expect(tokens[0].type).toBe(TokenType.STACK);
1216
- expect(tokens[0].data).toEqual(['1', '2/3', '/']);
1217
- });
1218
-
1219
- it('requires terminator for command end', () => {
1220
- const parser = new MTextParser('\\S1/2');
1221
- const tokens = Array.from(parser.parse());
1222
- expect(tokens[0].type).toBe(TokenType.STACK);
1223
- expect(tokens[0].data).toEqual(['1', '2', '/']);
1224
- });
1225
-
1226
- it('handles complex fractions', () => {
1227
- const parser = new MTextParser('\\S1 2/3 4^ 5 6;');
1228
- const tokens = Array.from(parser.parse());
1229
- expect(tokens[0].type).toBe(TokenType.STACK);
1230
- expect(tokens[0].data).toEqual(['1 2', '3 4^ 5 6', '/']);
1231
- });
1232
- });
1233
-
1234
- describe('paragraph properties', () => {
1235
- it('parses indentation', () => {
1236
- const parser = new MTextParser('\\pi2;Indented');
1237
- const tokens = Array.from(parser.parse());
1238
- expect(tokens[0].type).toBe(TokenType.WORD);
1239
- expect(tokens[0].data).toBe('Indented');
1240
- expect(tokens[0].ctx.paragraph.indent).toBe(2);
1241
- });
1242
-
1243
- it('parses alignment', () => {
1244
- const parser = new MTextParser('\\pqc;Centered');
1245
- const tokens = Array.from(parser.parse());
1246
- expect(tokens[0].type).toBe(TokenType.WORD);
1247
- expect(tokens[0].data).toBe('Centered');
1248
- expect(tokens[0].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1249
- });
1250
-
1251
- it('switches alignment', () => {
1252
- const mtext =
1253
- 'Line1: {\\pql;Left aligned paragraph.}\\PLine2: {\\pqc;Center aligned paragraph.} Middle\\PLine3: {\\pql;Back to left.} {End}';
1254
- const ctx = new MTextContext();
1255
- ctx.fontFace.family = 'simkai';
1256
- ctx.capHeight = { value: 0.1, isRelative: true };
1257
- ctx.widthFactor = { value: 1.0, isRelative: true };
1258
- ctx.align = MTextLineAlignment.BOTTOM;
1259
- ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1260
- const parser = new MTextParser(mtext, ctx, { yieldPropertyCommands: true });
1261
- const tokens = Array.from(parser.parse());
1262
- // Filter for word tokens
1263
- const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1264
- const expected = [
1265
- // Paragraph 1 (LEFT)
1266
- { data: 'Line1:', align: MTextParagraphAlignment.LEFT },
1267
- { data: 'Left', align: MTextParagraphAlignment.LEFT },
1268
- { data: 'aligned', align: MTextParagraphAlignment.LEFT },
1269
- { data: 'paragraph.', align: MTextParagraphAlignment.LEFT },
1270
- // Paragraph 2 (CENTER)
1271
- { data: 'Line2:', align: MTextParagraphAlignment.LEFT },
1272
- { data: 'Center', align: MTextParagraphAlignment.CENTER },
1273
- { data: 'aligned', align: MTextParagraphAlignment.CENTER },
1274
- { data: 'paragraph.', align: MTextParagraphAlignment.CENTER },
1275
- { data: 'Middle', align: MTextParagraphAlignment.CENTER },
1276
- // Paragraph 3 (LEFT)
1277
- { data: 'Line3:', align: MTextParagraphAlignment.CENTER },
1278
- { data: 'Back', align: MTextParagraphAlignment.LEFT },
1279
- { data: 'to', align: MTextParagraphAlignment.LEFT },
1280
- { data: 'left.', align: MTextParagraphAlignment.LEFT },
1281
- { data: 'End', align: MTextParagraphAlignment.LEFT },
1282
- ];
1283
- expect(wordTokens).toHaveLength(expected.length);
1284
- for (let i = 0; i < expected.length; i++) {
1285
- console.log(wordTokens[i]);
1286
- expect(wordTokens[i].data).toBe(expected[i].data);
1287
- expect(wordTokens[i].ctx.paragraph.align).toBe(expected[i].align);
1288
- }
1289
- });
1290
- });
1291
-
1292
- describe('property commands with yieldPropertyCommands', () => {
1293
- it('yields property change tokens for formatting commands', () => {
1294
- const parser = new MTextParser('\\LUnderlined\\l', undefined, {
1295
- yieldPropertyCommands: true,
1296
- });
1297
- const tokens = Array.from(parser.parse());
1298
- expect(tokens).toHaveLength(3);
1299
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1300
- expect(tokens[0].data).toEqual({
1301
- command: 'L',
1302
- changes: {
1303
- underline: true,
1304
- },
1305
- depth: 0,
1306
- });
1307
- expect(tokens[1].type).toBe(TokenType.WORD);
1308
- expect(tokens[1].data).toBe('Underlined');
1309
- expect(tokens[1].ctx.underline).toBe(true);
1310
- expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1311
- expect(tokens[2].data).toEqual({
1312
- command: 'l',
1313
- changes: {
1314
- underline: false,
1315
- },
1316
- depth: 0,
1317
- });
1318
- expect(tokens[2].ctx.underline).toBe(false);
1319
- });
1320
-
1321
- it('yields property change tokens for color commands', () => {
1322
- const parser = new MTextParser('\\C1Red Text', undefined, { yieldPropertyCommands: true });
1323
- const tokens = Array.from(parser.parse());
1324
- expect(tokens).toHaveLength(4);
1325
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1326
- expect(tokens[0].data).toEqual({
1327
- command: 'C',
1328
- changes: {
1329
- aci: 1,
1330
- },
1331
- depth: 0,
1332
- });
1333
- expect(tokens[1].type).toBe(TokenType.WORD);
1334
- expect(tokens[1].data).toBe('Red');
1335
- expect(tokens[1].ctx.aci).toBe(1);
1336
- expect(tokens[2].type).toBe(TokenType.SPACE);
1337
- expect(tokens[2].ctx.aci).toBe(1);
1338
- expect(tokens[3].type).toBe(TokenType.WORD);
1339
- expect(tokens[3].data).toBe('Text');
1340
- expect(tokens[3].ctx.aci).toBe(1);
1341
- });
1342
-
1343
- it('yields property change tokens for font properties', () => {
1344
- const parser = new MTextParser('\\FArial|b1|i1;Bold Italic', undefined, {
1345
- yieldPropertyCommands: true,
1346
- });
1347
- const tokens = Array.from(parser.parse());
1348
- expect(tokens).toHaveLength(4);
1349
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1350
- expect(tokens[0].data).toEqual({
1351
- command: 'F',
1352
- changes: {
1353
- fontFace: {
1354
- family: 'Arial',
1355
- style: 'Italic',
1356
- weight: 700,
1357
- },
1358
- },
1359
- depth: 0,
1360
- });
1361
- expect(tokens[1].type).toBe(TokenType.WORD);
1362
- expect(tokens[1].data).toBe('Bold');
1363
- expect(tokens[1].ctx.fontFace).toEqual({
1364
- family: 'Arial',
1365
- style: 'Italic',
1366
- weight: 700,
1367
- });
1368
- expect(tokens[2].type).toBe(TokenType.SPACE);
1369
- expect(tokens[2].ctx.fontFace).toEqual({
1370
- family: 'Arial',
1371
- style: 'Italic',
1372
- weight: 700,
1373
- });
1374
- expect(tokens[3].type).toBe(TokenType.WORD);
1375
- expect(tokens[3].data).toBe('Italic');
1376
- expect(tokens[3].ctx.fontFace).toEqual({
1377
- family: 'Arial',
1378
- style: 'Italic',
1379
- weight: 700,
1380
- });
1381
- });
1382
-
1383
- it('yields property change tokens for height command', () => {
1384
- const parser = new MTextParser('\\H2.5;Text', undefined, { yieldPropertyCommands: true });
1385
- const tokens = Array.from(parser.parse());
1386
- expect(tokens).toHaveLength(2);
1387
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1388
- expect(tokens[0].data).toEqual({
1389
- command: 'H',
1390
- changes: {
1391
- capHeight: { value: 2.5, isRelative: false },
1392
- },
1393
- depth: 0,
1394
- });
1395
- expect(tokens[1].type).toBe(TokenType.WORD);
1396
- expect(tokens[1].data).toBe('Text');
1397
- expect(tokens[1].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1398
- });
1399
-
1400
- it('yields property change tokens for multiple commands', () => {
1401
- const parser = new MTextParser('\\H2.5;\\C1;\\LText\\l', undefined, {
1402
- yieldPropertyCommands: true,
1403
- });
1404
- const tokens = Array.from(parser.parse());
1405
- expect(tokens).toHaveLength(5);
1406
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1407
- expect(tokens[0].data).toEqual({
1408
- command: 'H',
1409
- changes: {
1410
- capHeight: { value: 2.5, isRelative: false },
1411
- },
1412
- depth: 0,
1413
- });
1414
- expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1415
- expect(tokens[1].data).toEqual({
1416
- command: 'C',
1417
- changes: {
1418
- aci: 1,
1419
- },
1420
- depth: 0,
1421
- });
1422
- expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1423
- expect(tokens[2].data).toEqual({
1424
- command: 'L',
1425
- changes: {
1426
- underline: true,
1427
- },
1428
- depth: 0,
1429
- });
1430
- expect(tokens[3].type).toBe(TokenType.WORD);
1431
- expect(tokens[3].data).toBe('Text');
1432
- expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1433
- expect(tokens[3].ctx.underline).toBe(true);
1434
- expect(tokens[4].type).toBe(TokenType.PROPERTIES_CHANGED);
1435
- expect(tokens[4].data).toEqual({
1436
- command: 'l',
1437
- changes: {
1438
- underline: false,
1439
- },
1440
- depth: 0,
1441
- });
1442
- });
1443
-
1444
- it('yields property change tokens for paragraph properties', () => {
1445
- const parser = new MTextParser('\\pi2;\\pqc;Indented Centered', undefined, {
1446
- yieldPropertyCommands: true,
1447
- });
1448
- const tokens = Array.from(parser.parse());
1449
- expect(tokens).toHaveLength(5);
1450
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1451
- expect(tokens[0].data).toEqual({
1452
- command: 'p',
1453
- changes: {
1454
- paragraph: {
1455
- indent: 2,
1456
- },
1457
- },
1458
- depth: 0,
1459
- });
1460
- expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1461
- expect(tokens[1].data).toEqual({
1462
- command: 'p',
1463
- changes: {
1464
- paragraph: {
1465
- align: MTextParagraphAlignment.CENTER,
1466
- },
1467
- },
1468
- depth: 0,
1469
- });
1470
- expect(tokens[2].type).toBe(TokenType.WORD);
1471
- expect(tokens[2].data).toBe('Indented');
1472
- expect(tokens[2].ctx.paragraph.indent).toBe(2);
1473
- expect(tokens[2].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1474
- expect(tokens[3].type).toBe(TokenType.SPACE);
1475
- expect(tokens[3].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1476
- expect(tokens[4].type).toBe(TokenType.WORD);
1477
- expect(tokens[4].data).toBe('Centered');
1478
- expect(tokens[4].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1479
- });
1480
-
1481
- it('yields property change tokens for complex formatting', () => {
1482
- const parser = new MTextParser('{\\H2.5;\\C1;\\FArial|b1|i1;Formatted Text}', undefined, {
1483
- yieldPropertyCommands: true,
1484
- });
1485
- const tokens = Array.from(parser.parse());
1486
- expect(tokens).toHaveLength(7);
1487
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1488
- expect(tokens[0].data).toEqual({
1489
- command: 'H',
1490
- changes: {
1491
- capHeight: { value: 2.5, isRelative: false },
1492
- },
1493
- depth: 1,
1494
- });
1495
- expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1496
- expect(tokens[1].data).toEqual({
1497
- command: 'C',
1498
- changes: {
1499
- aci: 1,
1500
- },
1501
- depth: 1,
1502
- });
1503
- expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1504
- expect(tokens[2].data).toEqual({
1505
- command: 'F',
1506
- changes: {
1507
- fontFace: {
1508
- family: 'Arial',
1509
- style: 'Italic',
1510
- weight: 700,
1511
- },
1512
- },
1513
- depth: 1,
1514
- });
1515
- expect(tokens[3].type).toBe(TokenType.WORD);
1516
- expect(tokens[3].data).toBe('Formatted');
1517
- expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1518
- expect(tokens[3].ctx.aci).toBe(1);
1519
- expect(tokens[3].ctx.fontFace).toEqual({
1520
- family: 'Arial',
1521
- style: 'Italic',
1522
- weight: 700,
1523
- });
1524
- expect(tokens[4].type).toBe(TokenType.SPACE);
1525
- expect(tokens[4].ctx.fontFace).toEqual({
1526
- family: 'Arial',
1527
- style: 'Italic',
1528
- weight: 700,
1529
- });
1530
- expect(tokens[5].type).toBe(TokenType.WORD);
1531
- expect(tokens[5].data).toBe('Text');
1532
- expect(tokens[5].ctx.fontFace).toEqual({
1533
- family: 'Arial',
1534
- style: 'Italic',
1535
- weight: 700,
1536
- });
1537
- expect(tokens[6].type).toBe(TokenType.PROPERTIES_CHANGED);
1538
- expect(tokens[6].data).toEqual({
1539
- command: undefined,
1540
- changes: {
1541
- aci: 256,
1542
- capHeight: { value: 1, isRelative: false },
1543
- fontFace: { family: '', style: 'Regular', weight: 400 },
1544
- },
1545
- depth: 0,
1546
- });
1547
- });
1548
- });
1549
-
1550
- describe('MTextParser context restoration with braces {}', () => {
1551
- it('scopes font formatting to braces and restores after', () => {
1552
- const parser = new MTextParser('Normal {\\fArial|i;Italic} Back to normal');
1553
- const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1554
- expect(tokens[0].data).toBe('Normal');
1555
- expect(tokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1556
- expect(tokens[1].data).toBe('Italic');
1557
- expect(tokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1558
- expect(tokens[2].data).toBe('Back');
1559
- expect(tokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1560
- });
1561
-
1562
- it('scopes color formatting to braces and restores after', () => {
1563
- const parser = new MTextParser('{\\C1;Red} Normal');
1564
- const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1565
- expect(tokens[0].data).toBe('Red');
1566
- expect(tokens[0].ctx.aci).toBe(1);
1567
- expect(tokens[1].data).toBe('Normal');
1568
- expect(tokens[1].ctx.aci).toBe(256); // default
1569
- });
1570
-
1571
- it('restores previous formatting after a formatting block', () => {
1572
- const parser = new MTextParser('\\C2;Before {\\C1;Red} After');
1573
- const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1574
- expect(tokens[0].data).toBe('Before');
1575
- expect(tokens[0].ctx.aci).toBe(2);
1576
- expect(tokens[1].data).toBe('Red');
1577
- expect(tokens[1].ctx.aci).toBe(1);
1578
- expect(tokens[2].data).toBe('After');
1579
- expect(tokens[2].ctx.aci).toBe(2);
1580
- });
1581
-
1582
- it('restores context correctly with nested braces', () => {
1583
- const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}');
1584
- const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1585
- expect(tokens[0].data).toBe('Red');
1586
- expect(tokens[0].ctx.aci).toBe(1);
1587
- expect(tokens[1].data).toBe('Blue');
1588
- expect(tokens[1].ctx.aci).toBe(2);
1589
- expect(tokens[2].data).toBe('RedAgain');
1590
- expect(tokens[2].ctx.aci).toBe(1);
1591
- });
1592
-
1593
- it('persists formatting outside braces if not reset', () => {
1594
- const parser = new MTextParser('\\C3;All {\\C1;Red} StillAll');
1595
- const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1596
- expect(tokens[0].data).toBe('All');
1597
- expect(tokens[0].ctx.aci).toBe(3);
1598
- expect(tokens[1].data).toBe('Red');
1599
- expect(tokens[1].ctx.aci).toBe(1);
1600
- expect(tokens[2].data).toBe('StillAll');
1601
- expect(tokens[2].ctx.aci).toBe(3);
1602
- });
1603
- });
1604
-
1605
- describe('MTextParser context restoration with braces {} and yieldPropertyCommands', () => {
1606
- it('yields property change tokens when entering and exiting a formatting block', () => {
1607
- const parser = new MTextParser('Normal {\\fArial|i;Italic} Back', undefined, {
1608
- yieldPropertyCommands: true,
1609
- });
1610
- const tokens = Array.from(parser.parse());
1611
- // Filter for property changes and words
1612
- const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1613
- const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1614
- // Should yield a property change for entering Arial Italic
1615
- expect(propTokens[0].data).toEqual({
1616
- command: 'f',
1617
- changes: { fontFace: { family: 'Arial', style: 'Italic', weight: 400 } },
1618
- depth: 1,
1619
- });
1620
- // Should yield a property change for restoring default font after block
1621
- expect(propTokens[propTokens.length - 1].data).toEqual({
1622
- command: undefined,
1623
- changes: { fontFace: { family: '', style: 'Regular', weight: 400 } },
1624
- depth: 0,
1625
- });
1626
- // Check word tokens context
1627
- expect(wordTokens[0].data).toBe('Normal');
1628
- expect(wordTokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1629
- expect(wordTokens[1].data).toBe('Italic');
1630
- expect(wordTokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1631
- expect(wordTokens[2].data).toBe('Back');
1632
- expect(wordTokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1633
- });
1634
-
1635
- it('yields property change tokens for color and restores after block', () => {
1636
- const parser = new MTextParser('{\\C1;Red} Normal', undefined, {
1637
- yieldPropertyCommands: true,
1638
- });
1639
- const tokens = Array.from(parser.parse());
1640
- const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1641
- const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1642
- expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1643
- expect(propTokens[propTokens.length - 1].data).toEqual({
1644
- command: undefined,
1645
- changes: { aci: 256 },
1646
- depth: 0,
1647
- });
1648
- expect(wordTokens[0].data).toBe('Red');
1649
- expect(wordTokens[0].ctx.aci).toBe(1);
1650
- expect(wordTokens[1].data).toBe('Normal');
1651
- expect(wordTokens[1].ctx.aci).toBe(256);
1652
- });
1653
-
1654
- it('yields property change tokens for nested braces', () => {
1655
- const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}', undefined, {
1656
- yieldPropertyCommands: true,
1657
- });
1658
- const tokens = Array.from(parser.parse());
1659
- const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1660
- const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1661
- // Enter C1
1662
- expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1663
- // Enter C2
1664
- expect(propTokens[1].data).toEqual({ command: 'C', changes: { aci: 2 }, depth: 2 });
1665
- // Exit C2 (restore C1)
1666
- expect(propTokens[2].data).toEqual({ command: undefined, changes: { aci: 1 }, depth: 1 });
1667
- // Exit C1 (restore default)
1668
- expect(propTokens[propTokens.length - 1].data).toEqual({
1669
- command: undefined,
1670
- changes: { aci: 256 },
1671
- depth: 0,
1672
- });
1673
- expect(wordTokens[0].data).toBe('Red');
1674
- expect(wordTokens[0].ctx.aci).toBe(1);
1675
- expect(wordTokens[1].data).toBe('Blue');
1676
- expect(wordTokens[1].ctx.aci).toBe(2);
1677
- expect(wordTokens[2].data).toBe('RedAgain');
1678
- expect(wordTokens[2].ctx.aci).toBe(1);
1679
- });
1680
-
1681
- it('yields property change tokens for RGB color commands', () => {
1682
- // \c16711680 is 0xFF0000, which is [255,0,0] (red)
1683
- const parser = new MTextParser('\\c16711680Red Text', undefined, {
1684
- yieldPropertyCommands: true,
1685
- });
1686
- const tokens = Array.from(parser.parse());
1687
- expect(tokens).toHaveLength(4);
1688
- expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1689
- expect(tokens[0].data).toEqual({
1690
- command: 'c',
1691
- changes: {
1692
- aci: null,
1693
- rgb: [255, 0, 0],
1694
- },
1695
- depth: 0,
1696
- });
1697
- expect(tokens[1].type).toBe(TokenType.WORD);
1698
- expect(tokens[1].data).toBe('Red');
1699
- expect(tokens[1].ctx.rgb).toEqual([255, 0, 0]);
1700
- expect(tokens[2].type).toBe(TokenType.SPACE);
1701
- expect(tokens[2].ctx.rgb).toEqual([255, 0, 0]);
1702
- expect(tokens[3].type).toBe(TokenType.WORD);
1703
- expect(tokens[3].data).toBe('Text');
1704
- expect(tokens[3].ctx.rgb).toEqual([255, 0, 0]);
1705
- });
1706
- });
1707
- });
1708
-
1709
- describe('MTextParser resetParagraphParameters option', () => {
1710
- it('resets paragraph properties after NEW_PARAGRAPH when resetParagraphParameters is true', () => {
1711
- // Create a context with non-default paragraph properties
1712
- const ctx = new MTextContext();
1713
- ctx.paragraph.indent = 2;
1714
- ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1715
-
1716
- const parser = new MTextParser('Line1\\PLine2', ctx, {
1717
- yieldPropertyCommands: true,
1718
- resetParagraphParameters: true,
1719
- });
1720
- const tokens = Array.from(parser.parse());
1721
- // Should emit: WORD(Line1), NEW_PARAGRAPH, PROPERTIES_CHANGED (reset), WORD(Line2)
1722
- expect(tokens[0].type).toBe(TokenType.WORD);
1723
- expect(tokens[0].data).toBe('Line1');
1724
- expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1725
- expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1726
- const propChanged = tokens[2].data as import('./parser').ChangedProperties;
1727
- expect(propChanged.changes).toHaveProperty('paragraph');
1728
- expect(tokens[3].type).toBe(TokenType.WORD);
1729
- expect(tokens[3].data).toBe('Line2');
1730
- });
1731
-
1732
- it('does not emit PROPERTIES_CHANGED after NEW_PARAGRAPH if resetParagraphParameters is false', () => {
1733
- // Create a context with non-default paragraph properties
1734
- const ctx = new MTextContext();
1735
- ctx.paragraph.indent = 2;
1736
- ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1737
-
1738
- const parser = new MTextParser('Line1\\PLine2', ctx, {
1739
- yieldPropertyCommands: true,
1740
- resetParagraphParameters: false,
1741
- });
1742
- const tokens = Array.from(parser.parse());
1743
- // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1744
- expect(tokens[0].type).toBe(TokenType.WORD);
1745
- expect(tokens[0].data).toBe('Line1');
1746
- expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1747
- expect(tokens[2].type).toBe(TokenType.WORD);
1748
- expect(tokens[2].data).toBe('Line2');
1749
- expect(
1750
- tokens.find(
1751
- t =>
1752
- t.type === TokenType.PROPERTIES_CHANGED &&
1753
- t.data &&
1754
- (t.data as import('./parser').ChangedProperties).changes?.paragraph
1755
- )
1756
- ).toBeUndefined();
1757
- });
1758
-
1759
- it('resets paragraph properties but does not emit PROPERTIES_CHANGED if yieldPropertyCommands is false', () => {
1760
- // Create a context with non-default paragraph properties
1761
- const ctx = new MTextContext();
1762
- ctx.paragraph.indent = 2;
1763
- ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1764
-
1765
- const parser = new MTextParser('Line1\\PLine2', ctx, {
1766
- yieldPropertyCommands: false,
1767
- resetParagraphParameters: true,
1768
- });
1769
- const tokens = Array.from(parser.parse());
1770
- // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1771
- expect(tokens[0].type).toBe(TokenType.WORD);
1772
- expect(tokens[0].data).toBe('Line1');
1773
- expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1774
- expect(tokens[2].type).toBe(TokenType.WORD);
1775
- expect(tokens[2].data).toBe('Line2');
1776
- expect(
1777
- tokens.find(
1778
- t =>
1779
- t.type === TokenType.PROPERTIES_CHANGED &&
1780
- t.data &&
1781
- (t.data as import('./parser').ChangedProperties).changes?.paragraph
1782
- )
1783
- ).toBeUndefined();
1784
- });
1785
-
1786
- it('does not emit PROPERTIES_CHANGED when using default context with resetParagraphParameters true', () => {
1787
- const parser = new MTextParser('Line1\\PLine2', undefined, {
1788
- yieldPropertyCommands: true,
1789
- resetParagraphParameters: true,
1790
- });
1791
- const tokens = Array.from(parser.parse());
1792
- // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - no PROPERTIES_CHANGED because default context has default paragraph properties
1793
- expect(tokens[0].type).toBe(TokenType.WORD);
1794
- expect(tokens[0].data).toBe('Line1');
1795
- expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1796
- expect(tokens[2].type).toBe(TokenType.WORD);
1797
- expect(tokens[2].data).toBe('Line2');
1798
- expect(
1799
- tokens.find(
1800
- t =>
1801
- t.type === TokenType.PROPERTIES_CHANGED &&
1802
- t.data &&
1803
- (t.data as import('./parser').ChangedProperties).changes?.paragraph
1804
- )
1805
- ).toBeUndefined();
1806
- });
1807
- });
1808
-
1809
- describe('TextScanner', () => {
1810
- let scanner: TextScanner;
1811
-
1812
- beforeEach(() => {
1813
- scanner = new TextScanner('Hello World');
1814
- });
1815
-
1816
- it('initializes with correct state', () => {
1817
- expect(scanner.currentIndex).toBe(0);
1818
- expect(scanner.isEmpty).toBe(false);
1819
- expect(scanner.hasData).toBe(true);
1820
- });
1821
-
1822
- it('consumes characters', () => {
1823
- expect(scanner.get()).toBe('H');
1824
- expect(scanner.currentIndex).toBe(1);
1825
- expect(scanner.get()).toBe('e');
1826
- expect(scanner.currentIndex).toBe(2);
1827
- });
1828
-
1829
- it('peeks characters', () => {
1830
- expect(scanner.peek()).toBe('H');
1831
- expect(scanner.peek(1)).toBe('e');
1832
- expect(scanner.currentIndex).toBe(0);
1833
- });
1834
-
1835
- it('consumes multiple characters', () => {
1836
- scanner.consume(5);
1837
- expect(scanner.currentIndex).toBe(5);
1838
- expect(scanner.get()).toBe(' ');
1839
- });
1840
-
1841
- it('finds characters', () => {
1842
- expect(scanner.find('W')).toBe(6);
1843
- expect(scanner.find('X')).toBe(-1);
1844
- });
1845
-
1846
- it('handles escaped characters in find', () => {
1847
- scanner = new TextScanner('Hello\\;World');
1848
- expect(scanner.find(';', true)).toBe(6);
1849
- });
1850
-
1851
- it('gets remaining text', () => {
1852
- scanner.consume(6);
1853
- expect(scanner.tail).toBe('World');
1854
- });
1855
-
1856
- it('handles end of text', () => {
1857
- scanner.consume(11);
1858
- expect(scanner.isEmpty).toBe(true);
1859
- expect(scanner.hasData).toBe(false);
1860
- expect(scanner.get()).toBe('');
1861
- expect(scanner.peek()).toBe('');
1862
- });
1863
- });
1864
-
1865
- describe('getFonts', () => {
1866
- it('should return empty set for empty string', () => {
1867
- const result = getFonts('');
1868
- expect(result).toEqual(new Set());
1869
- });
1870
-
1871
- it('should extract single font name', () => {
1872
- const result = getFonts('\\fArial|Hello World');
1873
- expect(result).toEqual(new Set(['arial']));
1874
- });
1875
-
1876
- it('should extract multiple unique font names', () => {
1877
- const result = getFonts('\\fArial|Hello \\fTimes New Roman|World');
1878
- expect(result).toEqual(new Set(['arial', 'times new roman']));
1879
- });
1880
-
1881
- it('should handle case-insensitive font names', () => {
1882
- const result = getFonts('\\fARIAL|Hello \\fArial|World');
1883
- expect(result).toEqual(new Set(['arial']));
1884
- });
1885
-
1886
- it('should handle font names with spaces', () => {
1887
- const result = getFonts('\\fTimes New Roman|Hello \\fComic Sans MS|World');
1888
- expect(result).toEqual(new Set(['times new roman', 'comic sans ms']));
1889
- });
1890
-
1891
- it('should handle multiple font changes in sequence', () => {
1892
- const result = getFonts('\\fArial|Hello \\fTimes New Roman|World \\fArial|Again');
1893
- expect(result).toEqual(new Set(['arial', 'times new roman']));
1894
- });
1895
-
1896
- it('should handle font names with special characters', () => {
1897
- const result = getFonts('\\fArial-Bold|Hello \\fTimes-New-Roman|World');
1898
- expect(result).toEqual(new Set(['arial-bold', 'times-new-roman']));
1899
- });
1900
-
1901
- it('should handle both lowercase and uppercase font commands', () => {
1902
- const result = getFonts('\\fArial|Hello \\FTimes New Roman|World');
1903
- expect(result).toEqual(new Set(['arial', 'times new roman']));
1904
- });
1905
-
1906
- it('should handle complex MText with semicolon terminators', () => {
1907
- const mtext =
1908
- '{\\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}';
1909
- const result = getFonts(mtext);
1910
- expect(result).toEqual(new Set(['simsun', 'arial', 'romans', 'simhei', 'simkai']));
1911
- });
1912
-
1913
- it('should preserve font extensions when removeExtension is false', () => {
1914
- const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
1915
- const result = getFonts(mtext, false);
1916
- expect(result).toEqual(new Set(['arial.ttf', 'times new roman.otf']));
1917
- });
1918
-
1919
- it('should remove font extensions when removeExtension is true', () => {
1920
- const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
1921
- const result = getFonts(mtext, true);
1922
- expect(result).toEqual(new Set(['arial', 'times new roman']));
1923
- });
1924
-
1925
- it('should handle various font extensions', () => {
1926
- const mtext = '\\fFont1.ttf|Text1 \\fFont2.otf|Text2 \\fFont3.woff|Text3 \\fFont4.shx|Text4';
1927
- const result = getFonts(mtext, true);
1928
- expect(result).toEqual(new Set(['font1', 'font2', 'font3', 'font4']));
1929
- });
1930
-
1931
- it('should not remove non-font extensions', () => {
1932
- const mtext = '\\fFont1.txt|Text1 \\fFont2.doc|Text2';
1933
- const result = getFonts(mtext, true);
1934
- expect(result).toEqual(new Set(['font1.txt', 'font2.doc']));
1935
- });
1936
- });
1937
-
1938
- describe('MTextColor', () => {
1939
- it('defaults to ACI 256 (by layer)', () => {
1940
- const color = new MTextColor();
1941
- expect(color.aci).toBe(256);
1942
- expect(color.rgb).toBeNull();
1943
- expect(color.isAci).toBe(true);
1944
- expect(color.isRgb).toBe(false);
1945
- });
1946
-
1947
- it('can be constructed with ACI', () => {
1948
- const color = new MTextColor(1);
1949
- expect(color.aci).toBe(1);
1950
- expect(color.rgb).toBeNull();
1951
- expect(color.isAci).toBe(true);
1952
- expect(color.isRgb).toBe(false);
1953
- });
1954
-
1955
- it('can be constructed with RGB', () => {
1956
- const color = new MTextColor([255, 0, 0]);
1957
- expect(color.aci).toBeNull();
1958
- expect(color.rgb).toEqual([255, 0, 0]);
1959
- expect(color.isAci).toBe(false);
1960
- expect(color.isRgb).toBe(true);
1961
- });
1962
-
1963
- it('switches from ACI to RGB and back', () => {
1964
- const color = new MTextColor(2);
1965
- expect(color.aci).toBe(2);
1966
- color.rgb = [0, 255, 0];
1967
- expect(color.aci).toBeNull();
1968
- expect(color.rgb).toEqual([0, 255, 0]);
1969
- color.aci = 7;
1970
- expect(color.aci).toBe(7);
1971
- expect(color.rgb).toBeNull();
1972
- });
1973
-
1974
- it('copy() produces a deep copy', () => {
1975
- const color = new MTextColor([1, 2, 3]);
1976
- const copy = color.copy();
1977
- expect(copy).not.toBe(color);
1978
- expect(copy.aci).toBe(color.aci);
1979
- expect(copy.rgb).toEqual(color.rgb);
1980
- copy.rgb = [4, 5, 6];
1981
- expect(color.rgb).toEqual([1, 2, 3]);
1982
- expect(copy.rgb).toEqual([4, 5, 6]);
1983
- });
1984
-
1985
- it('fromCssColor parses hex and rgb()', () => {
1986
- const hex = MTextColor.fromCssColor('#ff0000');
1987
- expect(hex).not.toBeNull();
1988
- expect(hex!.rgb).toEqual([255, 0, 0]);
1989
-
1990
- const shortHex = MTextColor.fromCssColor('#0f0');
1991
- expect(shortHex).not.toBeNull();
1992
- expect(shortHex!.rgb).toEqual([0, 255, 0]);
1993
-
1994
- const rgb = MTextColor.fromCssColor('rgb(0, 128, 255)');
1995
- expect(rgb).not.toBeNull();
1996
- expect(rgb!.rgb).toEqual([0, 128, 255]);
1997
- });
1998
-
1999
- it('fromCssColor handles rgba() and rejects transparent', () => {
2000
- const rgba = MTextColor.fromCssColor('rgba(10, 20, 30, 0.5)');
2001
- expect(rgba).not.toBeNull();
2002
- expect(rgba!.rgb).toEqual([10, 20, 30]);
2003
-
2004
- const transparent = MTextColor.fromCssColor('transparent');
2005
- expect(transparent).toBeNull();
2006
- });
2007
-
2008
- it('toCssColor returns hex for RGB and null for ACI', () => {
2009
- const rgb = new MTextColor([255, 0, 0]);
2010
- expect(rgb.toCssColor()).toBe('#ff0000');
2011
-
2012
- const aci = new MTextColor(1);
2013
- expect(aci.toCssColor()).toBeNull();
2014
- });
2015
- });
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
+ it('keeps pending words before raw newline and tab', () => {
318
+ // LibreDWG and some DXF paths expand AutoCAD `\P` to a literal `\n`.
319
+ // The word accumulated before that control must still be emitted.
320
+ let parser = new MTextParser('技术要求\n1 焊接');
321
+ let tokens = Array.from(parser.parse());
322
+ expect(tokens.map(t => [t.type, t.data])).toEqual([
323
+ [TokenType.WORD, '技术要求'],
324
+ [TokenType.NEW_PARAGRAPH, null],
325
+ [TokenType.WORD, '1'],
326
+ [TokenType.SPACE, null],
327
+ [TokenType.WORD, '焊接'],
328
+ ]);
329
+
330
+ parser = new MTextParser('abc\tdef');
331
+ tokens = Array.from(parser.parse());
332
+ expect(tokens.map(t => [t.type, t.data])).toEqual([
333
+ [TokenType.WORD, 'abc'],
334
+ [TokenType.TABULATOR, null],
335
+ [TokenType.WORD, 'def'],
336
+ ]);
337
+
338
+ parser = new MTextParser('封口;\n2');
339
+ tokens = Array.from(parser.parse());
340
+ expect(tokens.map(t => [t.type, t.data])).toEqual([
341
+ [TokenType.WORD, '封口;'],
342
+ [TokenType.NEW_PARAGRAPH, null],
343
+ [TokenType.WORD, '2'],
344
+ ]);
345
+ });
346
+ });
347
+
348
+ describe('formatting', () => {
349
+ it('parses underline', () => {
350
+ const parser = new MTextParser('\\LUnderlined\\l');
351
+ const tokens = Array.from(parser.parse());
352
+ expect(tokens[0].type).toBe(TokenType.WORD);
353
+ expect(tokens[0].data).toBe('Underlined');
354
+ expect(tokens[0].ctx.underline).toBe(true);
355
+ });
356
+
357
+ it('parses color', () => {
358
+ const parser = new MTextParser('\\C1Red Text');
359
+ const tokens = Array.from(parser.parse());
360
+ expect(tokens[0].type).toBe(TokenType.WORD);
361
+ expect(tokens[0].data).toBe('Red');
362
+ expect(tokens[0].ctx.aci).toBe(1);
363
+ });
364
+
365
+ it('parses font properties', () => {
366
+ const parser = new MTextParser('\\FArial|b1|i1;Bold Italic');
367
+ const tokens = Array.from(parser.parse());
368
+ expect(tokens[0].type).toBe(TokenType.WORD);
369
+ expect(tokens[0].data).toBe('Bold');
370
+ expect(tokens[0].ctx.fontFace).toEqual({
371
+ family: 'Arial',
372
+ style: 'Italic',
373
+ weight: 700,
374
+ });
375
+ });
376
+
377
+ describe('height command', () => {
378
+ it('parses absolute height values', () => {
379
+ const parser = new MTextParser('\\H2.5;Text');
380
+ const 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('parses relative height values with x suffix', () => {
387
+ const parser = new MTextParser('\\H2.5x;Text');
388
+ const 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: 2.5, isRelative: true });
392
+ });
393
+
394
+ it('handles optional terminator', () => {
395
+ const parser = new MTextParser('\\H2.5Text');
396
+ const tokens = Array.from(parser.parse());
397
+ expect(tokens[0].type).toBe(TokenType.WORD);
398
+ expect(tokens[0].data).toBe('Text');
399
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
400
+ });
401
+
402
+ it('handles leading signs', () => {
403
+ let parser = new MTextParser('\\H-2.5;Text');
404
+ let tokens = Array.from(parser.parse());
405
+ expect(tokens[0].type).toBe(TokenType.WORD);
406
+ expect(tokens[0].data).toBe('Text');
407
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
408
+
409
+ parser = new MTextParser('\\H+2.5;Text');
410
+ tokens = Array.from(parser.parse());
411
+ expect(tokens[0].type).toBe(TokenType.WORD);
412
+ expect(tokens[0].data).toBe('Text');
413
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
414
+ });
415
+
416
+ it('handles decimal values without leading zero', () => {
417
+ let parser = new MTextParser('\\H.5x;Text');
418
+ let tokens = Array.from(parser.parse());
419
+ expect(tokens[0].type).toBe(TokenType.WORD);
420
+ expect(tokens[0].data).toBe('Text');
421
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
422
+
423
+ parser = new MTextParser('\\H-.5x;Text');
424
+ tokens = Array.from(parser.parse());
425
+ expect(tokens[0].type).toBe(TokenType.WORD);
426
+ expect(tokens[0].data).toBe('Text');
427
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
428
+
429
+ parser = new MTextParser('\\H+.5x;Text');
430
+ tokens = Array.from(parser.parse());
431
+ expect(tokens[0].type).toBe(TokenType.WORD);
432
+ expect(tokens[0].data).toBe('Text');
433
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
434
+ });
435
+
436
+ it('handles exponential notation', () => {
437
+ let parser = new MTextParser('\\H1e2;Text');
438
+ let tokens = Array.from(parser.parse());
439
+ expect(tokens[0].type).toBe(TokenType.WORD);
440
+ expect(tokens[0].data).toBe('Text');
441
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 100, isRelative: false });
442
+
443
+ parser = new MTextParser('\\H1e-2;Text');
444
+ tokens = Array.from(parser.parse());
445
+ expect(tokens[0].type).toBe(TokenType.WORD);
446
+ expect(tokens[0].data).toBe('Text');
447
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.01, isRelative: false });
448
+
449
+ parser = new MTextParser('\\H.5e2;Text');
450
+ tokens = Array.from(parser.parse());
451
+ expect(tokens[0].type).toBe(TokenType.WORD);
452
+ expect(tokens[0].data).toBe('Text');
453
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: false });
454
+
455
+ parser = new MTextParser('\\H.5e-2;Text');
456
+ tokens = Array.from(parser.parse());
457
+ expect(tokens[0].type).toBe(TokenType.WORD);
458
+ expect(tokens[0].data).toBe('Text');
459
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.005, isRelative: false });
460
+ });
461
+
462
+ it('handles invalid floating point values', () => {
463
+ let parser = new MTextParser('\\H1..5;Text');
464
+ let tokens = Array.from(parser.parse());
465
+ expect(tokens[0].type).toBe(TokenType.WORD);
466
+ expect(tokens[0].data).toBe('.5;Text');
467
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
468
+
469
+ parser = new MTextParser('\\H1e;Text');
470
+ tokens = Array.from(parser.parse());
471
+ expect(tokens[0].type).toBe(TokenType.WORD);
472
+ expect(tokens[0].data).toBe('e;Text');
473
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
474
+
475
+ parser = new MTextParser('\\H1e+;Text');
476
+ tokens = Array.from(parser.parse());
477
+ expect(tokens[0].type).toBe(TokenType.WORD);
478
+ expect(tokens[0].data).toBe('e+;Text');
479
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
480
+ });
481
+
482
+ it('handles complex height expressions', () => {
483
+ let parser = new MTextParser('\\H+1.5e-1x;Text');
484
+ let tokens = Array.from(parser.parse());
485
+ expect(tokens[0].type).toBe(TokenType.WORD);
486
+ expect(tokens[0].data).toBe('Text');
487
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 0.15, isRelative: true });
488
+
489
+ parser = new MTextParser('\\H-.5e+2x;Text');
490
+ tokens = Array.from(parser.parse());
491
+ expect(tokens[0].type).toBe(TokenType.WORD);
492
+ expect(tokens[0].data).toBe('Text');
493
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: true }); // Negative values are ignored
494
+ });
495
+
496
+ it('handles multiple height commands', () => {
497
+ const parser = new MTextParser('\\H2.5;First\\H.5x;Second');
498
+ const tokens = Array.from(parser.parse());
499
+ expect(tokens[0].type).toBe(TokenType.WORD);
500
+ expect(tokens[0].data).toBe('First');
501
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
502
+ expect(tokens[1].type).toBe(TokenType.WORD);
503
+ expect(tokens[1].data).toBe('Second');
504
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
505
+ });
506
+
507
+ it('handles height command with no value', () => {
508
+ const parser = new MTextParser('\\H;Text');
509
+ const tokens = Array.from(parser.parse());
510
+ expect(tokens[0].type).toBe(TokenType.WORD);
511
+ expect(tokens[0].data).toBe('Text');
512
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value
513
+ });
514
+ });
515
+
516
+ describe('width command', () => {
517
+ it('parses absolute width values', () => {
518
+ const parser = new MTextParser('\\W2.5;Text');
519
+ const 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('parses relative width values with x suffix', () => {
526
+ const parser = new MTextParser('\\W2.5x;Text');
527
+ const 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: 2.5, isRelative: true });
531
+ });
532
+
533
+ it('handles optional terminator', () => {
534
+ const parser = new MTextParser('\\W2.5Text');
535
+ const tokens = Array.from(parser.parse());
536
+ expect(tokens[0].type).toBe(TokenType.WORD);
537
+ expect(tokens[0].data).toBe('Text');
538
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
539
+ });
540
+
541
+ it('handles leading signs', () => {
542
+ let parser = new MTextParser('\\W-2.5;Text');
543
+ let tokens = Array.from(parser.parse());
544
+ expect(tokens[0].type).toBe(TokenType.WORD);
545
+ expect(tokens[0].data).toBe('Text');
546
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
547
+
548
+ parser = new MTextParser('\\W+2.5;Text');
549
+ tokens = Array.from(parser.parse());
550
+ expect(tokens[0].type).toBe(TokenType.WORD);
551
+ expect(tokens[0].data).toBe('Text');
552
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
553
+ });
554
+
555
+ it('handles decimal values without leading zero', () => {
556
+ let parser = new MTextParser('\\W.5x;Text');
557
+ let tokens = Array.from(parser.parse());
558
+ expect(tokens[0].type).toBe(TokenType.WORD);
559
+ expect(tokens[0].data).toBe('Text');
560
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true });
561
+
562
+ parser = new MTextParser('\\W-.5x;Text');
563
+ tokens = Array.from(parser.parse());
564
+ expect(tokens[0].type).toBe(TokenType.WORD);
565
+ expect(tokens[0].data).toBe('Text');
566
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
567
+ });
568
+
569
+ it('handles multiple width commands', () => {
570
+ const parser = new MTextParser('\\W2.5;First\\W.5x;Second');
571
+ const tokens = Array.from(parser.parse());
572
+ expect(tokens[0].type).toBe(TokenType.WORD);
573
+ expect(tokens[0].data).toBe('First');
574
+ expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false });
575
+ expect(tokens[1].type).toBe(TokenType.WORD);
576
+ expect(tokens[1].data).toBe('Second');
577
+ expect(tokens[1].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true });
578
+ });
579
+ });
580
+
581
+ describe('character tracking command', () => {
582
+ it('parses absolute tracking values', () => {
583
+ const parser = new MTextParser('\\T2.5;Text');
584
+ const 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('parses relative tracking values with x suffix', () => {
591
+ const parser = new MTextParser('\\T2.5x;Text');
592
+ const 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: 2.5, isRelative: true });
596
+ });
597
+
598
+ it('handles optional terminator', () => {
599
+ const parser = new MTextParser('\\T2.5Text');
600
+ const tokens = Array.from(parser.parse());
601
+ expect(tokens[0].type).toBe(TokenType.WORD);
602
+ expect(tokens[0].data).toBe('Text');
603
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
604
+ });
605
+
606
+ it('handles leading signs', () => {
607
+ let parser = new MTextParser('\\T-2.5;Text');
608
+ let tokens = Array.from(parser.parse());
609
+ expect(tokens[0].type).toBe(TokenType.WORD);
610
+ expect(tokens[0].data).toBe('Text');
611
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored
612
+
613
+ parser = new MTextParser('\\T+2.5;Text');
614
+ tokens = Array.from(parser.parse());
615
+ expect(tokens[0].type).toBe(TokenType.WORD);
616
+ expect(tokens[0].data).toBe('Text');
617
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
618
+ });
619
+
620
+ it('handles decimal values without leading zero', () => {
621
+ let parser = new MTextParser('\\T.5x;Text');
622
+ let tokens = Array.from(parser.parse());
623
+ expect(tokens[0].type).toBe(TokenType.WORD);
624
+ expect(tokens[0].data).toBe('Text');
625
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
626
+
627
+ parser = new MTextParser('\\T-.5x;Text');
628
+ tokens = Array.from(parser.parse());
629
+ expect(tokens[0].type).toBe(TokenType.WORD);
630
+ expect(tokens[0].data).toBe('Text');
631
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored
632
+ });
633
+
634
+ it('handles multiple tracking commands', () => {
635
+ const parser = new MTextParser('\\T2.5;First\\T.5x;Second');
636
+ const tokens = Array.from(parser.parse());
637
+ expect(tokens[0].type).toBe(TokenType.WORD);
638
+ expect(tokens[0].data).toBe('First');
639
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false });
640
+ expect(tokens[1].type).toBe(TokenType.WORD);
641
+ expect(tokens[1].data).toBe('Second');
642
+ expect(tokens[1].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true });
643
+ });
644
+
645
+ it('handles tracking command with no value', () => {
646
+ const parser = new MTextParser('\\T;Text');
647
+ const tokens = Array.from(parser.parse());
648
+ expect(tokens[0].type).toBe(TokenType.WORD);
649
+ expect(tokens[0].data).toBe('Text');
650
+ expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); // Default value
651
+ });
652
+ });
653
+
654
+ describe('oblique command', () => {
655
+ it('parses positive oblique angle', () => {
656
+ const parser = new MTextParser('\\Q15;Text');
657
+ const tokens = Array.from(parser.parse());
658
+ expect(tokens[0].type).toBe(TokenType.WORD);
659
+ expect(tokens[0].data).toBe('Text');
660
+ expect(tokens[0].ctx.oblique).toBe(15);
661
+ });
662
+
663
+ it('parses negative oblique angle', () => {
664
+ const parser = new MTextParser('\\Q-15;Text');
665
+ const tokens = Array.from(parser.parse());
666
+ expect(tokens[0].type).toBe(TokenType.WORD);
667
+ expect(tokens[0].data).toBe('Text');
668
+ expect(tokens[0].ctx.oblique).toBe(-15);
669
+ });
670
+
671
+ it('handles optional terminator', () => {
672
+ const parser = new MTextParser('\\Q15Text');
673
+ const tokens = Array.from(parser.parse());
674
+ expect(tokens[0].type).toBe(TokenType.WORD);
675
+ expect(tokens[0].data).toBe('Text');
676
+ expect(tokens[0].ctx.oblique).toBe(15);
677
+ });
678
+
679
+ it('handles decimal values', () => {
680
+ const parser = new MTextParser('\\Q15.5;Text');
681
+ const tokens = Array.from(parser.parse());
682
+ expect(tokens[0].type).toBe(TokenType.WORD);
683
+ expect(tokens[0].data).toBe('Text');
684
+ expect(tokens[0].ctx.oblique).toBe(15.5);
685
+ });
686
+
687
+ it('handles multiple oblique commands', () => {
688
+ const parser = new MTextParser('\\Q15;First\\Q-30;Second');
689
+ const tokens = Array.from(parser.parse());
690
+ expect(tokens[0].type).toBe(TokenType.WORD);
691
+ expect(tokens[0].data).toBe('First');
692
+ expect(tokens[0].ctx.oblique).toBe(15);
693
+ expect(tokens[1].type).toBe(TokenType.WORD);
694
+ expect(tokens[1].data).toBe('Second');
695
+ expect(tokens[1].ctx.oblique).toBe(-30);
696
+ });
697
+ });
698
+
699
+ describe('special encoded characters', () => {
700
+ it('renders diameter symbol (%%c)', () => {
701
+ let parser = new MTextParser('%%cText');
702
+ let tokens = Array.from(parser.parse());
703
+ expect(tokens[0].type).toBe(TokenType.WORD);
704
+ expect(tokens[0].data).toBe('ØText');
705
+
706
+ parser = new MTextParser('%%CText');
707
+ tokens = Array.from(parser.parse());
708
+ expect(tokens[0].type).toBe(TokenType.WORD);
709
+ expect(tokens[0].data).toBe('ØText');
710
+ });
711
+
712
+ it('renders degree symbol (%%d)', () => {
713
+ let parser = new MTextParser('%%dText');
714
+ let tokens = Array.from(parser.parse());
715
+ expect(tokens[0].type).toBe(TokenType.WORD);
716
+ expect(tokens[0].data).toBe('°Text');
717
+
718
+ parser = new MTextParser('%%DText');
719
+ tokens = Array.from(parser.parse());
720
+ expect(tokens[0].type).toBe(TokenType.WORD);
721
+ expect(tokens[0].data).toBe('°Text');
722
+ });
723
+
724
+ it('renders plus-minus symbol (%%p)', () => {
725
+ let parser = new MTextParser('%%pText');
726
+ let tokens = Array.from(parser.parse());
727
+ expect(tokens[0].type).toBe(TokenType.WORD);
728
+ expect(tokens[0].data).toBe('±Text');
729
+
730
+ parser = new MTextParser('%%PText');
731
+ tokens = Array.from(parser.parse());
732
+ expect(tokens[0].type).toBe(TokenType.WORD);
733
+ expect(tokens[0].data).toBe('±Text');
734
+ });
735
+
736
+ it('handles multiple special characters in sequence', () => {
737
+ const parser = new MTextParser('%%c%%d%%pText');
738
+ const tokens = Array.from(parser.parse());
739
+ expect(tokens[0].type).toBe(TokenType.WORD);
740
+ expect(tokens[0].data).toBe('ذ±Text');
741
+ });
742
+
743
+ it('handles special characters with spaces', () => {
744
+ const parser = new MTextParser('%%c %%d %%p Text');
745
+ const tokens = Array.from(parser.parse());
746
+ expect(tokens).toHaveLength(7);
747
+ expect(tokens[0].type).toBe(TokenType.WORD);
748
+ expect(tokens[0].data).toBe('Ø');
749
+ expect(tokens[1].type).toBe(TokenType.SPACE);
750
+ expect(tokens[2].type).toBe(TokenType.WORD);
751
+ expect(tokens[2].data).toBe('°');
752
+ expect(tokens[3].type).toBe(TokenType.SPACE);
753
+ expect(tokens[4].type).toBe(TokenType.WORD);
754
+ expect(tokens[4].data).toBe('±');
755
+ expect(tokens[5].type).toBe(TokenType.SPACE);
756
+ expect(tokens[6].type).toBe(TokenType.WORD);
757
+ expect(tokens[6].data).toBe('Text');
758
+ });
759
+
760
+ it('handles special characters with formatting', () => {
761
+ const parser = new MTextParser('\\H2.5;%%c\\H.5x;%%d%%p');
762
+ const tokens = Array.from(parser.parse());
763
+ expect(tokens).toHaveLength(2);
764
+ expect(tokens[0].type).toBe(TokenType.WORD);
765
+ expect(tokens[0].data).toBe('Ø');
766
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
767
+ expect(tokens[1].type).toBe(TokenType.WORD);
768
+ expect(tokens[1].data).toBe('°±');
769
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
770
+ });
771
+
772
+ it('handles invalid special character codes', () => {
773
+ const parser = new MTextParser('%%x%%y%%zText');
774
+ const tokens = Array.from(parser.parse());
775
+ expect(tokens[0].type).toBe(TokenType.WORD);
776
+ expect(tokens[0].data).toBe('Text');
777
+ });
778
+
779
+ it('renders numeric %%nnn with 1–3 digits', () => {
780
+ // Two digits: %%34 → " (U+0022)
781
+ let parser = new MTextParser(' 4%%34 ABS');
782
+ let tokens = Array.from(parser.parse());
783
+ expect(tokens).toHaveLength(4);
784
+ expect(tokens[0].type).toBe(TokenType.SPACE);
785
+ expect(tokens[1].type).toBe(TokenType.WORD);
786
+ expect(tokens[1].data).toBe('4"');
787
+ expect(tokens[2].type).toBe(TokenType.SPACE);
788
+ expect(tokens[3].type).toBe(TokenType.WORD);
789
+ expect(tokens[3].data).toBe('ABS');
790
+
791
+ // One digit followed by non-digit
792
+ parser = new MTextParser('%%9x');
793
+ tokens = Array.from(parser.parse());
794
+ expect(tokens[0].type).toBe(TokenType.WORD);
795
+ expect(tokens[0].data).toBe('\tx');
796
+
797
+ // Three digits (degree)
798
+ parser = new MTextParser('%%176');
799
+ tokens = Array.from(parser.parse());
800
+ expect(tokens[0].data).toBe('°');
801
+
802
+ // Leading-zero three-digit form of the same code point as %%34
803
+ parser = new MTextParser('%%034');
804
+ tokens = Array.from(parser.parse());
805
+ expect(tokens[0].data).toBe('"');
806
+ });
807
+ });
808
+
809
+ describe('yieldPercentSymbols', () => {
810
+ it('emits PERCENT_SYMBOL tokens for %%c, %%d, and %%p', () => {
811
+ const parser = new MTextParser('%%c%%d%%p', undefined, {
812
+ yieldPercentSymbols: true,
813
+ });
814
+ const tokens = Array.from(parser.parse());
815
+ expect(tokens).toHaveLength(3);
816
+ expect(tokens[0]).toMatchObject({
817
+ type: TokenType.PERCENT_SYMBOL,
818
+ data: { kind: 'named', code: 'c', char: 'Ø' },
819
+ });
820
+ expect(tokens[1]).toMatchObject({
821
+ type: TokenType.PERCENT_SYMBOL,
822
+ data: { kind: 'named', code: 'd', char: '°' },
823
+ });
824
+ expect(tokens[2]).toMatchObject({
825
+ type: TokenType.PERCENT_SYMBOL,
826
+ data: { kind: 'named', code: 'p', char: '±' },
827
+ });
828
+ });
829
+
830
+ it('emits PERCENT_SYMBOL for numeric %%nnn codes', () => {
831
+ const parser = new MTextParser('%%34%%130%%132', undefined, {
832
+ yieldPercentSymbols: true,
833
+ });
834
+ const tokens = Array.from(parser.parse());
835
+ expect(tokens).toHaveLength(3);
836
+ expect(tokens[0]).toMatchObject({
837
+ type: TokenType.PERCENT_SYMBOL,
838
+ data: {
839
+ kind: 'numeric',
840
+ charCode: 34,
841
+ char: '"',
842
+ },
843
+ });
844
+ expect(tokens[1]).toMatchObject({
845
+ type: TokenType.PERCENT_SYMBOL,
846
+ data: {
847
+ kind: 'numeric',
848
+ charCode: 130,
849
+ char: String.fromCharCode(130),
850
+ },
851
+ });
852
+ expect(tokens[2]).toMatchObject({
853
+ type: TokenType.PERCENT_SYMBOL,
854
+ data: {
855
+ kind: 'numeric',
856
+ charCode: 132,
857
+ char: String.fromCharCode(132),
858
+ },
859
+ });
860
+ });
861
+
862
+ it('emits PERCENT_SYMBOL for literal percent (%%%)', () => {
863
+ const parser = new MTextParser('%%%', undefined, {
864
+ yieldPercentSymbols: true,
865
+ });
866
+ const tokens = Array.from(parser.parse());
867
+ expect(tokens).toHaveLength(1);
868
+ expect(tokens[0]).toMatchObject({
869
+ type: TokenType.PERCENT_SYMBOL,
870
+ data: { kind: 'literal', char: '%' },
871
+ });
872
+ });
873
+
874
+ it('interleaves WORD and PERCENT_SYMBOL tokens', () => {
875
+ const parser = new MTextParser('A%%cB', undefined, {
876
+ yieldPercentSymbols: true,
877
+ });
878
+ const tokens = Array.from(parser.parse());
879
+ expect(tokens).toHaveLength(3);
880
+ expect(tokens[0].type).toBe(TokenType.WORD);
881
+ expect(tokens[0].data).toBe('A');
882
+ expect(tokens[1].type).toBe(TokenType.PERCENT_SYMBOL);
883
+ expect(tokens[1].data).toEqual({ kind: 'named', code: 'c', char: 'Ø' });
884
+ expect(tokens[2].type).toBe(TokenType.WORD);
885
+ expect(tokens[2].data).toBe('B');
886
+ });
887
+
888
+ it('preserves default WORD expansion when yieldPercentSymbols is false', () => {
889
+ const parser = new MTextParser('%%c', undefined, {
890
+ yieldPercentSymbols: false,
891
+ });
892
+ const tokens = Array.from(parser.parse());
893
+ expect(tokens[0].type).toBe(TokenType.WORD);
894
+ expect(tokens[0].data).toBe('Ø');
895
+ });
896
+ });
897
+ });
898
+
899
+ describe('GBK character encoding', () => {
900
+ it('decodes GBK hex codes', () => {
901
+ // Test "你" (C4E3 in GBK)
902
+ let parser = new MTextParser('\\M+C4E3', undefined, { yieldPropertyCommands: false });
903
+ let tokens = Array.from(parser.parse());
904
+ expect(tokens[0].type).toBe(TokenType.WORD);
905
+ expect(tokens[0].data).toBe('你');
906
+
907
+ // Test "好" (BAC3 in GBK)
908
+ parser = new MTextParser('\\M+BAC3', undefined, { yieldPropertyCommands: false });
909
+ tokens = Array.from(parser.parse());
910
+ expect(tokens[0].type).toBe(TokenType.WORD);
911
+ expect(tokens[0].data).toBe('好');
912
+
913
+ // Test multiple GBK characters
914
+ parser = new MTextParser('\\M+C4E3\\M+BAC3', undefined, { yieldPropertyCommands: false });
915
+ tokens = Array.from(parser.parse());
916
+ expect(tokens[0].type).toBe(TokenType.WORD);
917
+ expect(tokens[0].data).toBe('你');
918
+ expect(tokens[1].type).toBe(TokenType.WORD);
919
+ expect(tokens[1].data).toBe('好');
920
+ });
921
+
922
+ it('handles invalid GBK codes', () => {
923
+ // Test invalid hex code
924
+ let parser = new MTextParser('\\M+XXXX', undefined, { yieldPropertyCommands: false });
925
+ let tokens = Array.from(parser.parse());
926
+ expect(tokens[0].type).toBe(TokenType.WORD);
927
+ expect(tokens[0].data).toBe('\\M+XXXX');
928
+
929
+ // Test incomplete hex code
930
+ parser = new MTextParser('\\M+C4', undefined, { yieldPropertyCommands: false });
931
+ tokens = Array.from(parser.parse());
932
+ expect(tokens[0].type).toBe(TokenType.WORD);
933
+ expect(tokens[0].data).toBe('\\M+C4');
934
+
935
+ // Test missing plus sign
936
+ parser = new MTextParser('\\MC4E3', undefined, { yieldPropertyCommands: false });
937
+ tokens = Array.from(parser.parse());
938
+ expect(tokens[0].type).toBe(TokenType.WORD);
939
+ expect(tokens[0].data).toBe('\\MC4E3');
940
+ });
941
+
942
+ it('handles GBK characters with other formatting', () => {
943
+ // Test GBK characters with height command
944
+ const parser = new MTextParser('\\H2.5;\\M+C4E3\\H.5x;\\M+BAC3', undefined, {
945
+ yieldPropertyCommands: false,
946
+ });
947
+ const tokens = Array.from(parser.parse());
948
+ expect(tokens).toHaveLength(2);
949
+ expect(tokens[0].type).toBe(TokenType.WORD);
950
+ expect(tokens[0].data).toBe('你');
951
+ expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
952
+ expect(tokens[1].type).toBe(TokenType.WORD);
953
+ expect(tokens[1].data).toBe('好');
954
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true });
955
+ });
956
+
957
+ it('handles GBK characters with font properties', () => {
958
+ const parser = new MTextParser('{\\fgbcbig.shx|b0|i0|c0|p0;\\M+C4E3\\M+BAC3}', undefined, {
959
+ yieldPropertyCommands: false,
960
+ });
961
+ const tokens = Array.from(parser.parse());
962
+ expect(tokens[0].type).toBe(TokenType.WORD);
963
+ expect(tokens[0].data).toBe('你');
964
+ expect(tokens[1].type).toBe(TokenType.WORD);
965
+ expect(tokens[1].data).toBe('好');
966
+ expect(tokens[0].ctx.fontFace).toEqual({
967
+ family: 'gbcbig.shx',
968
+ style: 'Regular',
969
+ weight: 400,
970
+ });
971
+ expect(tokens[1].ctx.fontFace).toEqual({
972
+ family: 'gbcbig.shx',
973
+ style: 'Regular',
974
+ weight: 400,
975
+ });
976
+ });
977
+ });
978
+
979
+ describe('MIF (Multibyte Interchange Format) with custom decoder', () => {
980
+ it('uses custom decoder when provided', () => {
981
+ const customDecoder = (hex: string) => {
982
+ // Custom decoder that reverses the hex and converts to char
983
+ const reversed = hex.split('').reverse().join('');
984
+ const codePoint = parseInt(reversed, 16);
985
+ return String.fromCodePoint(codePoint);
986
+ };
987
+
988
+ const parser = new MTextParser('\\M+C4E3', undefined, {
989
+ mifDecoder: customDecoder,
990
+ });
991
+ const tokens = Array.from(parser.parse());
992
+ expect(tokens[0].type).toBe(TokenType.WORD);
993
+ // The custom decoder will produce different output
994
+ expect(tokens[0].data).not.toBe('\\M+C4E3');
995
+ });
996
+
997
+ it('parses 5-digit MIF codes with auto-detect', () => {
998
+ // Use default decoder with auto-detect
999
+ const parser = new MTextParser('\\M+1A2B3', undefined, {
1000
+ mifCodeLength: 'auto',
1001
+ });
1002
+ const tokens = Array.from(parser.parse());
1003
+ expect(tokens[0].type).toBe(TokenType.WORD);
1004
+ // Should successfully parse 5-digit code
1005
+ expect(tokens[0].data).not.toBe('\\M+1A2B3');
1006
+ });
1007
+
1008
+ it('parses 5-digit MIF codes when specified', () => {
1009
+ const parser = new MTextParser('\\M+1A2B3', undefined, {
1010
+ mifCodeLength: 5,
1011
+ });
1012
+ const tokens = Array.from(parser.parse());
1013
+ expect(tokens[0].type).toBe(TokenType.WORD);
1014
+ expect(tokens[0].data).not.toBe('\\M+1A2B3');
1015
+ });
1016
+
1017
+ it('parses 4-digit MIF codes when specified', () => {
1018
+ const parser = new MTextParser('\\M+C4E3', undefined, {
1019
+ mifCodeLength: 4,
1020
+ });
1021
+ const tokens = Array.from(parser.parse());
1022
+ expect(tokens[0].type).toBe(TokenType.WORD);
1023
+ expect(tokens[0].data).toBe('你');
1024
+ });
1025
+
1026
+ it('falls back to 4-digit when 5-digit not available', () => {
1027
+ const parser = new MTextParser('\\M+C4E3', undefined, {
1028
+ mifCodeLength: 'auto',
1029
+ });
1030
+ const tokens = Array.from(parser.parse());
1031
+ expect(tokens[0].type).toBe(TokenType.WORD);
1032
+ expect(tokens[0].data).toBe('你');
1033
+ });
1034
+
1035
+ it('uses custom decoder with specific code length', () => {
1036
+ const customDecoder = (hex: string) => `[DECODED:${hex}]`;
1037
+ const parser = new MTextParser('\\M+C4E3', undefined, {
1038
+ mifDecoder: customDecoder,
1039
+ mifCodeLength: 4,
1040
+ });
1041
+ const tokens = Array.from(parser.parse());
1042
+ expect(tokens[0].type).toBe(TokenType.WORD);
1043
+ expect(tokens[0].data).toBe('[DECODED:C4E3]');
1044
+ });
1045
+
1046
+ it('parses complex MText with 5-digit MIF codes and Unicode', () => {
1047
+ // Test data: \M+1928D:\M+18DD1\M+197702.0t\U+9540\U+950C\M+194C2\M+190A7\M+18DEC\M+18142
1048
+ // According to user, \M+19770 should be parsed as 5-digit code, leaving "2.0t" as separate characters
1049
+ // But the parser's auto-detect logic tries 5 digits first, which consumes "19770"
1050
+ // So "2" becomes part of the next sequence
1051
+ const mtext =
1052
+ '\\M+1928D:\\M+18DD1\\M+197702.0t\\U+9540\\U+950C\\M+194C2\\M+190A7\\M+18DEC\\M+18142';
1053
+ const parser = new MTextParser(mtext, undefined, {
1054
+ mifCodeLength: 'auto',
1055
+ });
1056
+ const tokens = Array.from(parser.parse());
1057
+
1058
+ // Should parse without errors and generate tokens
1059
+ // The parser produces 11 tokens: each MIF code and Unicode becomes one token, and "2.0t" becomes a single token
1060
+ expect(tokens.length).toBe(11);
1061
+
1062
+ // Verify the decoded characters are valid
1063
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1064
+ expect(wordTokens.length).toBeGreaterThan(0);
1065
+
1066
+ // Note: 5-digit MIF codes return placeholder character '▯' as per decodeMultiByteChar implementation
1067
+ // Only verify that Unicode characters (4-digit hex) are decoded properly
1068
+ const unicodeTokens = wordTokens.filter(t => t.data && t.data !== '▯');
1069
+ expect(unicodeTokens.length).toBeGreaterThan(0);
1070
+ });
1071
+
1072
+ it('decodes specific 5-digit MIF codes correctly', () => {
1073
+ // Test individual 5-digit MIF codes from the provided test data
1074
+ const testCases = [
1075
+ { code: '1928D', decoded: '注' },
1076
+ { code: '18DD1', decoded: '采' },
1077
+ { code: '19770', decoded: '用' },
1078
+ { code: '194C2', decoded: '板' },
1079
+ { code: '190A7', decoded: '制' },
1080
+ { code: '18DEC', decoded: '作' },
1081
+ { code: '18142', decoded: '。' },
1082
+ ];
1083
+
1084
+ testCases.forEach(({ code, decoded }) => {
1085
+ const parser = new MTextParser(`\\M+${code}`, undefined, {
1086
+ mifCodeLength: 5,
1087
+ });
1088
+ const tokens = Array.from(parser.parse());
1089
+ expect(tokens[0].type).toBe(TokenType.WORD);
1090
+ // Note: decodeMultiByteChar returns '▯' for 5-digit codes
1091
+ if (tokens[0].data && typeof tokens[0].data === 'string') {
1092
+ expect(tokens[0].data).toBe(decoded);
1093
+ }
1094
+ });
1095
+ });
1096
+ });
1097
+
1098
+ describe('Unicode (\\U+XXXX) escape sequences', () => {
1099
+ it('decodes Unicode BMP code points with exactly four hex digits', () => {
1100
+ // Greek capital omega: \U+03A9
1101
+ const parser = new MTextParser('Omega: \\U+03A9');
1102
+ const tokens = Array.from(parser.parse());
1103
+ expect(tokens[0].type).toBe(TokenType.WORD);
1104
+ expect(tokens[0].data).toBe('Omega:');
1105
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1106
+ expect(tokens[2].type).toBe(TokenType.WORD);
1107
+ expect(tokens[2].data).toBe('Ω');
1108
+ });
1109
+
1110
+ it('decodes supplementary-plane characters via UTF-16 surrogate pairs', () => {
1111
+ // Grinning face U+1F600 as AutoCAD surrogate pair \U+D83D\U+DE00
1112
+ const parser = new MTextParser('Smile: \\U+D83D\\U+DE00');
1113
+ const tokens = Array.from(parser.parse());
1114
+ expect(tokens[0].type).toBe(TokenType.WORD);
1115
+ expect(tokens[0].data).toBe('Smile:');
1116
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1117
+ expect(tokens[2].type).toBe(TokenType.WORD);
1118
+ expect(tokens[2].data).toBe('😀');
1119
+ });
1120
+
1121
+ it('preserves literal supplementary-plane characters in word tokens', () => {
1122
+ const parser = new MTextParser('Hello 😀 world');
1123
+ const tokens = Array.from(parser.parse());
1124
+ expect(tokens[0].data).toBe('Hello');
1125
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1126
+ expect(tokens[2].type).toBe(TokenType.WORD);
1127
+ expect(tokens[2].data).toBe('😀');
1128
+ expect([...(tokens[2].data as string)]).toEqual(['😀']);
1129
+ expect(tokens[3].type).toBe(TokenType.SPACE);
1130
+ expect(tokens[4].data).toBe('world');
1131
+ });
1132
+
1133
+ it('does not consume hex digits after the four-digit code (cad-viewer#615)', () => {
1134
+ const parser = new MTextParser('\\U+22054.0通');
1135
+ const tokens = Array.from(parser.parse());
1136
+ const words = tokens
1137
+ .filter(token => token.type === TokenType.WORD)
1138
+ .map(token => String(token.data))
1139
+ .join('');
1140
+ expect(words).toBe('∅4.0通');
1141
+ });
1142
+
1143
+ it('treats a fifth hex digit after \\U+XXXX as literal text', () => {
1144
+ // Non-standard \\U+1F600 must be read as U+1F60 + literal "0"
1145
+ const parser = new MTextParser('Smile: \\U+1F600');
1146
+ const tokens = Array.from(parser.parse());
1147
+ expect(tokens[0].type).toBe(TokenType.WORD);
1148
+ expect(tokens[0].data).toBe('Smile:');
1149
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1150
+ expect(tokens[2].type).toBe(TokenType.WORD);
1151
+ expect(tokens[2].data).toBe(String.fromCharCode(0x1f60));
1152
+ expect(tokens[3].type).toBe(TokenType.WORD);
1153
+ expect(tokens[3].data).toBe('0');
1154
+ });
1155
+
1156
+ it('handles invalid or incomplete Unicode escapes as literal text', () => {
1157
+ // Not enough hex digits
1158
+ let parser = new MTextParser('Test: \\U+12');
1159
+ let tokens = Array.from(parser.parse());
1160
+ expect(tokens[0].type).toBe(TokenType.WORD);
1161
+ expect(tokens[0].data).toBe('Test:');
1162
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1163
+ expect(tokens[2].type).toBe(TokenType.WORD);
1164
+ expect(tokens[2].data).toBe('\\U+12');
1165
+
1166
+ // Invalid hex
1167
+ parser = new MTextParser('Test: \\U+ZZZZ');
1168
+ tokens = Array.from(parser.parse());
1169
+ expect(tokens[0].type).toBe(TokenType.WORD);
1170
+ expect(tokens[0].data).toBe('Test:');
1171
+ expect(tokens[1].type).toBe(TokenType.SPACE);
1172
+ expect(tokens[2].type).toBe(TokenType.WORD);
1173
+ expect(tokens[2].data).toBe('\\U+ZZZZ');
1174
+ });
1175
+ });
1176
+
1177
+ describe('stacking', () => {
1178
+ it('parses basic fractions with different dividers', () => {
1179
+ let parser = new MTextParser('\\S1/2;');
1180
+ let tokens = Array.from(parser.parse());
1181
+ expect(tokens[0].type).toBe(TokenType.STACK);
1182
+ expect(tokens[0].data).toEqual(['1', '2', '/']);
1183
+
1184
+ parser = new MTextParser('\\S1#2;');
1185
+ tokens = Array.from(parser.parse());
1186
+ expect(tokens[0].type).toBe(TokenType.STACK);
1187
+ expect(tokens[0].data).toEqual(['1', '2', '#']);
1188
+ });
1189
+
1190
+ it('handles caret for baseline alignment', () => {
1191
+ let parser = new MTextParser('\\S1^2;');
1192
+ let tokens = Array.from(parser.parse());
1193
+ expect(tokens[0].type).toBe(TokenType.STACK);
1194
+ expect(tokens[0].data).toEqual(['1', '2', '^']);
1195
+
1196
+ // Test with spaces
1197
+ parser = new MTextParser('\\S1 2^3 4;');
1198
+ tokens = Array.from(parser.parse());
1199
+ expect(tokens[0].type).toBe(TokenType.STACK);
1200
+ expect(tokens[0].data).toEqual(['1 2', '3 4', '^']);
1201
+
1202
+ // Test with escaped characters
1203
+ parser = new MTextParser('\\S1^2\\;;');
1204
+ tokens = Array.from(parser.parse());
1205
+ expect(tokens[0].type).toBe(TokenType.STACK);
1206
+ expect(tokens[0].data).toEqual(['1', '2;', '^']);
1207
+ });
1208
+
1209
+ it('handles spaces in numerator and denominator', () => {
1210
+ const parser = new MTextParser('\\S1 2/3 4;');
1211
+ const tokens = Array.from(parser.parse());
1212
+ expect(tokens[0].type).toBe(TokenType.STACK);
1213
+ expect(tokens[0].data).toEqual(['1 2', '3 4', '/']);
1214
+ });
1215
+
1216
+ it('handles spaces after / and # dividers', () => {
1217
+ let parser = new MTextParser('\\S1/ 2;');
1218
+ let tokens = Array.from(parser.parse());
1219
+ expect(tokens[0].type).toBe(TokenType.STACK);
1220
+ expect(tokens[0].data).toEqual(['1', ' 2', '/']);
1221
+
1222
+ parser = new MTextParser('\\S1# 2;');
1223
+ tokens = Array.from(parser.parse());
1224
+ expect(tokens[0].type).toBe(TokenType.STACK);
1225
+ expect(tokens[0].data).toEqual(['1', ' 2', '#']);
1226
+ });
1227
+
1228
+ it('handles escaped terminator', () => {
1229
+ const parser = new MTextParser('\\S1/2\\;;');
1230
+ const tokens = Array.from(parser.parse());
1231
+ expect(tokens[0].type).toBe(TokenType.STACK);
1232
+ expect(tokens[0].data).toEqual(['1', '2;', '/']);
1233
+ });
1234
+
1235
+ it('ignores backslashes except for escaped terminator', () => {
1236
+ const parser = new MTextParser('\\S\\N^ \\P;');
1237
+ const tokens = Array.from(parser.parse());
1238
+ expect(tokens[0].type).toBe(TokenType.STACK);
1239
+ expect(tokens[0].data).toEqual(['N', 'P', '^']);
1240
+ });
1241
+
1242
+ it('renders grouping chars as simple braces', () => {
1243
+ const parser = new MTextParser('\\S{1}/2;');
1244
+ const tokens = Array.from(parser.parse());
1245
+ expect(tokens[0].type).toBe(TokenType.STACK);
1246
+ expect(tokens[0].data).toEqual(['{1}', '2', '/']);
1247
+ });
1248
+
1249
+ it('treats carets in stack formatting as literal text', () => {
1250
+ let parser = new MTextParser('\\S^I/^J;');
1251
+ let tokens = Array.from(parser.parse());
1252
+ expect(tokens[0].type).toBe(TokenType.STACK);
1253
+ expect(tokens[0].data).toEqual([' ', ' ', '/']);
1254
+
1255
+ parser = new MTextParser('\\Sabc^def;');
1256
+ tokens = Array.from(parser.parse());
1257
+ expect(tokens[0].type).toBe(TokenType.STACK);
1258
+ expect(tokens[0].data).toEqual(['abc', 'def', '^']);
1259
+ });
1260
+
1261
+ it('handles subscript and superscript', () => {
1262
+ // Subscript
1263
+ let parser = new MTextParser('abc\\S^ 1;');
1264
+ let tokens = Array.from(parser.parse());
1265
+ expect(tokens[0].type).toBe(TokenType.WORD);
1266
+ expect(tokens[0].data).toEqual('abc');
1267
+ expect(tokens[1].type).toBe(TokenType.STACK);
1268
+ expect(tokens[1].data).toEqual(['', '1', '^']);
1269
+
1270
+ // Superscript
1271
+ parser = new MTextParser('abc\\S1^ ;');
1272
+ tokens = Array.from(parser.parse());
1273
+ expect(tokens[0].type).toBe(TokenType.WORD);
1274
+ expect(tokens[0].data).toEqual('abc');
1275
+ expect(tokens[1].type).toBe(TokenType.STACK);
1276
+ expect(tokens[1].data).toEqual(['1', '', '^']);
1277
+ });
1278
+
1279
+ it('handles multiple divider chars', () => {
1280
+ const parser = new MTextParser('\\S1/2/3;');
1281
+ const tokens = Array.from(parser.parse());
1282
+ expect(tokens[0].type).toBe(TokenType.STACK);
1283
+ expect(tokens[0].data).toEqual(['1', '2/3', '/']);
1284
+ });
1285
+
1286
+ it('requires terminator for command end', () => {
1287
+ const parser = new MTextParser('\\S1/2');
1288
+ const tokens = Array.from(parser.parse());
1289
+ expect(tokens[0].type).toBe(TokenType.STACK);
1290
+ expect(tokens[0].data).toEqual(['1', '2', '/']);
1291
+ });
1292
+
1293
+ it('handles complex fractions', () => {
1294
+ const parser = new MTextParser('\\S1 2/3 4^ 5 6;');
1295
+ const tokens = Array.from(parser.parse());
1296
+ expect(tokens[0].type).toBe(TokenType.STACK);
1297
+ expect(tokens[0].data).toEqual(['1 2', '3 4^ 5 6', '/']);
1298
+ });
1299
+ });
1300
+
1301
+ describe('paragraph properties', () => {
1302
+ it('parses indentation', () => {
1303
+ const parser = new MTextParser('\\pi2;Indented');
1304
+ const tokens = Array.from(parser.parse());
1305
+ expect(tokens[0].type).toBe(TokenType.WORD);
1306
+ expect(tokens[0].data).toBe('Indented');
1307
+ expect(tokens[0].ctx.paragraph.indent).toBe(2);
1308
+ });
1309
+
1310
+ it('parses a leading-decimal indent', () => {
1311
+ const parser = new MTextParser('\\pi.5;Indented');
1312
+ const tokens = Array.from(parser.parse());
1313
+ expect(tokens[0].data).toBe('Indented');
1314
+ expect(tokens[0].ctx.paragraph.indent).toBe(0.5);
1315
+ });
1316
+
1317
+ it('parses tab stops', () => {
1318
+ const parser = new MTextParser('\\pt1,2.5,r3;Tabbed');
1319
+ const tokens = Array.from(parser.parse());
1320
+ expect(tokens[0].data).toBe('Tabbed');
1321
+ expect(tokens[0].ctx.paragraph.tabs).toEqual([1, 2.5, 'r3']);
1322
+ });
1323
+
1324
+ it('parses leading-decimal and signed tab stops', () => {
1325
+ const parser = new MTextParser('\\pt.5,r.25,c.75,-1.5,r-2;Tabbed');
1326
+ const tokens = Array.from(parser.parse());
1327
+ expect(tokens[0].data).toBe('Tabbed');
1328
+ expect(tokens[0].ctx.paragraph.tabs).toEqual([0.5, 'r0.25', 'c0.75', -1.5, 'r-2']);
1329
+ });
1330
+
1331
+ it('keeps an explicit zero tab stop', () => {
1332
+ const parser = new MTextParser('\\ptr0,c0,0;Tabbed');
1333
+ const tokens = Array.from(parser.parse());
1334
+ expect(tokens[0].ctx.paragraph.tabs).toEqual(['r0', 'c0', 0]);
1335
+ });
1336
+
1337
+ it('does not invent a zero tab when a right or center stop has no number', () => {
1338
+ const parser = new MTextParser('\\pt1,r,c;Tabbed');
1339
+ const tokens = Array.from(parser.parse());
1340
+ expect(tokens[0].data).toBe('Tabbed');
1341
+ expect(tokens[0].ctx.paragraph.tabs).toEqual([1]);
1342
+ });
1343
+
1344
+ it('does not hang on a tab stop list that clears tab stops', () => {
1345
+ // AutoCAD writes `\pi0,l0,tz;` to clear paragraph tab stops. The `z`
1346
+ // is neither a tab type nor a number, and used to loop forever.
1347
+ const ctx = new MTextContext();
1348
+ ctx.paragraph.indent = 3;
1349
+ ctx.paragraph.left = 4;
1350
+ ctx.paragraph.tabs = [9];
1351
+ const parser = new MTextParser('A\\P\\pi0,l0,tz;B', ctx);
1352
+ const tokens = Array.from(parser.parse());
1353
+ const words = tokens.filter(t => t.type === TokenType.WORD);
1354
+ expect(words.map(t => t.data)).toEqual(['A', 'B']);
1355
+ expect(words[0].ctx.paragraph.tabs).toEqual([9]);
1356
+ expect(words[1].ctx.paragraph.indent).toBe(0);
1357
+ expect(words[1].ctx.paragraph.left).toBe(0);
1358
+ expect(words[1].ctx.paragraph.tabs).toEqual([]);
1359
+ });
1360
+
1361
+ it('parses alignment', () => {
1362
+ const parser = new MTextParser('\\pqc;Centered');
1363
+ const tokens = Array.from(parser.parse());
1364
+ expect(tokens[0].type).toBe(TokenType.WORD);
1365
+ expect(tokens[0].data).toBe('Centered');
1366
+ expect(tokens[0].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1367
+ });
1368
+
1369
+ it('switches alignment', () => {
1370
+ const mtext =
1371
+ 'Line1: {\\pql;Left aligned paragraph.}\\PLine2: {\\pqc;Center aligned paragraph.} Middle\\PLine3: {\\pql;Back to left.} {End}';
1372
+ const ctx = new MTextContext();
1373
+ ctx.fontFace.family = 'simkai';
1374
+ ctx.capHeight = { value: 0.1, isRelative: true };
1375
+ ctx.widthFactor = { value: 1.0, isRelative: true };
1376
+ ctx.align = MTextLineAlignment.BOTTOM;
1377
+ ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1378
+ const parser = new MTextParser(mtext, ctx, { yieldPropertyCommands: true });
1379
+ const tokens = Array.from(parser.parse());
1380
+ // Filter for word tokens
1381
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1382
+ const expected = [
1383
+ // Paragraph 1 (LEFT)
1384
+ { data: 'Line1:', align: MTextParagraphAlignment.LEFT },
1385
+ { data: 'Left', align: MTextParagraphAlignment.LEFT },
1386
+ { data: 'aligned', align: MTextParagraphAlignment.LEFT },
1387
+ { data: 'paragraph.', align: MTextParagraphAlignment.LEFT },
1388
+ // Paragraph 2 (CENTER)
1389
+ { data: 'Line2:', align: MTextParagraphAlignment.LEFT },
1390
+ { data: 'Center', align: MTextParagraphAlignment.CENTER },
1391
+ { data: 'aligned', align: MTextParagraphAlignment.CENTER },
1392
+ { data: 'paragraph.', align: MTextParagraphAlignment.CENTER },
1393
+ { data: 'Middle', align: MTextParagraphAlignment.CENTER },
1394
+ // Paragraph 3 (LEFT)
1395
+ { data: 'Line3:', align: MTextParagraphAlignment.CENTER },
1396
+ { data: 'Back', align: MTextParagraphAlignment.LEFT },
1397
+ { data: 'to', align: MTextParagraphAlignment.LEFT },
1398
+ { data: 'left.', align: MTextParagraphAlignment.LEFT },
1399
+ { data: 'End', align: MTextParagraphAlignment.LEFT },
1400
+ ];
1401
+ expect(wordTokens).toHaveLength(expected.length);
1402
+ for (let i = 0; i < expected.length; i++) {
1403
+ console.log(wordTokens[i]);
1404
+ expect(wordTokens[i].data).toBe(expected[i].data);
1405
+ expect(wordTokens[i].ctx.paragraph.align).toBe(expected[i].align);
1406
+ }
1407
+ });
1408
+ });
1409
+
1410
+ describe('property commands with yieldPropertyCommands', () => {
1411
+ it('yields property change tokens for formatting commands', () => {
1412
+ const parser = new MTextParser('\\LUnderlined\\l', undefined, {
1413
+ yieldPropertyCommands: true,
1414
+ });
1415
+ const tokens = Array.from(parser.parse());
1416
+ expect(tokens).toHaveLength(3);
1417
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1418
+ expect(tokens[0].data).toEqual({
1419
+ command: 'L',
1420
+ changes: {
1421
+ underline: true,
1422
+ },
1423
+ depth: 0,
1424
+ });
1425
+ expect(tokens[1].type).toBe(TokenType.WORD);
1426
+ expect(tokens[1].data).toBe('Underlined');
1427
+ expect(tokens[1].ctx.underline).toBe(true);
1428
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1429
+ expect(tokens[2].data).toEqual({
1430
+ command: 'l',
1431
+ changes: {
1432
+ underline: false,
1433
+ },
1434
+ depth: 0,
1435
+ });
1436
+ expect(tokens[2].ctx.underline).toBe(false);
1437
+ });
1438
+
1439
+ it('yields property change tokens for color commands', () => {
1440
+ const parser = new MTextParser('\\C1Red Text', undefined, { yieldPropertyCommands: true });
1441
+ const tokens = Array.from(parser.parse());
1442
+ expect(tokens).toHaveLength(4);
1443
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1444
+ expect(tokens[0].data).toEqual({
1445
+ command: 'C',
1446
+ changes: {
1447
+ aci: 1,
1448
+ },
1449
+ depth: 0,
1450
+ });
1451
+ expect(tokens[1].type).toBe(TokenType.WORD);
1452
+ expect(tokens[1].data).toBe('Red');
1453
+ expect(tokens[1].ctx.aci).toBe(1);
1454
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1455
+ expect(tokens[2].ctx.aci).toBe(1);
1456
+ expect(tokens[3].type).toBe(TokenType.WORD);
1457
+ expect(tokens[3].data).toBe('Text');
1458
+ expect(tokens[3].ctx.aci).toBe(1);
1459
+ });
1460
+
1461
+ it('yields property change tokens for font properties', () => {
1462
+ const parser = new MTextParser('\\FArial|b1|i1;Bold Italic', undefined, {
1463
+ yieldPropertyCommands: true,
1464
+ });
1465
+ const tokens = Array.from(parser.parse());
1466
+ expect(tokens).toHaveLength(4);
1467
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1468
+ expect(tokens[0].data).toEqual({
1469
+ command: 'F',
1470
+ changes: {
1471
+ fontFace: {
1472
+ family: 'Arial',
1473
+ style: 'Italic',
1474
+ weight: 700,
1475
+ },
1476
+ },
1477
+ depth: 0,
1478
+ });
1479
+ expect(tokens[1].type).toBe(TokenType.WORD);
1480
+ expect(tokens[1].data).toBe('Bold');
1481
+ expect(tokens[1].ctx.fontFace).toEqual({
1482
+ family: 'Arial',
1483
+ style: 'Italic',
1484
+ weight: 700,
1485
+ });
1486
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1487
+ expect(tokens[2].ctx.fontFace).toEqual({
1488
+ family: 'Arial',
1489
+ style: 'Italic',
1490
+ weight: 700,
1491
+ });
1492
+ expect(tokens[3].type).toBe(TokenType.WORD);
1493
+ expect(tokens[3].data).toBe('Italic');
1494
+ expect(tokens[3].ctx.fontFace).toEqual({
1495
+ family: 'Arial',
1496
+ style: 'Italic',
1497
+ weight: 700,
1498
+ });
1499
+ });
1500
+
1501
+ it('yields property change tokens for height command', () => {
1502
+ const parser = new MTextParser('\\H2.5;Text', undefined, { yieldPropertyCommands: true });
1503
+ const tokens = Array.from(parser.parse());
1504
+ expect(tokens).toHaveLength(2);
1505
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1506
+ expect(tokens[0].data).toEqual({
1507
+ command: 'H',
1508
+ changes: {
1509
+ capHeight: { value: 2.5, isRelative: false },
1510
+ },
1511
+ depth: 0,
1512
+ });
1513
+ expect(tokens[1].type).toBe(TokenType.WORD);
1514
+ expect(tokens[1].data).toBe('Text');
1515
+ expect(tokens[1].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1516
+ });
1517
+
1518
+ it('yields property change tokens for multiple commands', () => {
1519
+ const parser = new MTextParser('\\H2.5;\\C1;\\LText\\l', undefined, {
1520
+ yieldPropertyCommands: true,
1521
+ });
1522
+ const tokens = Array.from(parser.parse());
1523
+ expect(tokens).toHaveLength(5);
1524
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1525
+ expect(tokens[0].data).toEqual({
1526
+ command: 'H',
1527
+ changes: {
1528
+ capHeight: { value: 2.5, isRelative: false },
1529
+ },
1530
+ depth: 0,
1531
+ });
1532
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1533
+ expect(tokens[1].data).toEqual({
1534
+ command: 'C',
1535
+ changes: {
1536
+ aci: 1,
1537
+ },
1538
+ depth: 0,
1539
+ });
1540
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1541
+ expect(tokens[2].data).toEqual({
1542
+ command: 'L',
1543
+ changes: {
1544
+ underline: true,
1545
+ },
1546
+ depth: 0,
1547
+ });
1548
+ expect(tokens[3].type).toBe(TokenType.WORD);
1549
+ expect(tokens[3].data).toBe('Text');
1550
+ expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1551
+ expect(tokens[3].ctx.underline).toBe(true);
1552
+ expect(tokens[4].type).toBe(TokenType.PROPERTIES_CHANGED);
1553
+ expect(tokens[4].data).toEqual({
1554
+ command: 'l',
1555
+ changes: {
1556
+ underline: false,
1557
+ },
1558
+ depth: 0,
1559
+ });
1560
+ });
1561
+
1562
+ it('yields property change tokens for paragraph properties', () => {
1563
+ const parser = new MTextParser('\\pi2;\\pqc;Indented Centered', undefined, {
1564
+ yieldPropertyCommands: true,
1565
+ });
1566
+ const tokens = Array.from(parser.parse());
1567
+ expect(tokens).toHaveLength(5);
1568
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1569
+ expect(tokens[0].data).toEqual({
1570
+ command: 'p',
1571
+ changes: {
1572
+ paragraph: {
1573
+ indent: 2,
1574
+ },
1575
+ },
1576
+ depth: 0,
1577
+ });
1578
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1579
+ expect(tokens[1].data).toEqual({
1580
+ command: 'p',
1581
+ changes: {
1582
+ paragraph: {
1583
+ align: MTextParagraphAlignment.CENTER,
1584
+ },
1585
+ },
1586
+ depth: 0,
1587
+ });
1588
+ expect(tokens[2].type).toBe(TokenType.WORD);
1589
+ expect(tokens[2].data).toBe('Indented');
1590
+ expect(tokens[2].ctx.paragraph.indent).toBe(2);
1591
+ expect(tokens[2].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1592
+ expect(tokens[3].type).toBe(TokenType.SPACE);
1593
+ expect(tokens[3].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1594
+ expect(tokens[4].type).toBe(TokenType.WORD);
1595
+ expect(tokens[4].data).toBe('Centered');
1596
+ expect(tokens[4].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER);
1597
+ });
1598
+
1599
+ it('yields property change tokens for complex formatting', () => {
1600
+ const parser = new MTextParser('{\\H2.5;\\C1;\\FArial|b1|i1;Formatted Text}', undefined, {
1601
+ yieldPropertyCommands: true,
1602
+ });
1603
+ const tokens = Array.from(parser.parse());
1604
+ expect(tokens).toHaveLength(7);
1605
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1606
+ expect(tokens[0].data).toEqual({
1607
+ command: 'H',
1608
+ changes: {
1609
+ capHeight: { value: 2.5, isRelative: false },
1610
+ },
1611
+ depth: 1,
1612
+ });
1613
+ expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED);
1614
+ expect(tokens[1].data).toEqual({
1615
+ command: 'C',
1616
+ changes: {
1617
+ aci: 1,
1618
+ },
1619
+ depth: 1,
1620
+ });
1621
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1622
+ expect(tokens[2].data).toEqual({
1623
+ command: 'F',
1624
+ changes: {
1625
+ fontFace: {
1626
+ family: 'Arial',
1627
+ style: 'Italic',
1628
+ weight: 700,
1629
+ },
1630
+ },
1631
+ depth: 1,
1632
+ });
1633
+ expect(tokens[3].type).toBe(TokenType.WORD);
1634
+ expect(tokens[3].data).toBe('Formatted');
1635
+ expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false });
1636
+ expect(tokens[3].ctx.aci).toBe(1);
1637
+ expect(tokens[3].ctx.fontFace).toEqual({
1638
+ family: 'Arial',
1639
+ style: 'Italic',
1640
+ weight: 700,
1641
+ });
1642
+ expect(tokens[4].type).toBe(TokenType.SPACE);
1643
+ expect(tokens[4].ctx.fontFace).toEqual({
1644
+ family: 'Arial',
1645
+ style: 'Italic',
1646
+ weight: 700,
1647
+ });
1648
+ expect(tokens[5].type).toBe(TokenType.WORD);
1649
+ expect(tokens[5].data).toBe('Text');
1650
+ expect(tokens[5].ctx.fontFace).toEqual({
1651
+ family: 'Arial',
1652
+ style: 'Italic',
1653
+ weight: 700,
1654
+ });
1655
+ expect(tokens[6].type).toBe(TokenType.PROPERTIES_CHANGED);
1656
+ expect(tokens[6].data).toEqual({
1657
+ command: undefined,
1658
+ changes: {
1659
+ aci: 256,
1660
+ capHeight: { value: 1, isRelative: false },
1661
+ fontFace: { family: '', style: 'Regular', weight: 400 },
1662
+ },
1663
+ depth: 0,
1664
+ });
1665
+ });
1666
+ });
1667
+
1668
+ describe('MTextParser context restoration with braces {}', () => {
1669
+ it('scopes font formatting to braces and restores after', () => {
1670
+ const parser = new MTextParser('Normal {\\fArial|i;Italic} Back to normal');
1671
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1672
+ expect(tokens[0].data).toBe('Normal');
1673
+ expect(tokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1674
+ expect(tokens[1].data).toBe('Italic');
1675
+ expect(tokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1676
+ expect(tokens[2].data).toBe('Back');
1677
+ expect(tokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1678
+ });
1679
+
1680
+ it('scopes color formatting to braces and restores after', () => {
1681
+ const parser = new MTextParser('{\\C1;Red} Normal');
1682
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1683
+ expect(tokens[0].data).toBe('Red');
1684
+ expect(tokens[0].ctx.aci).toBe(1);
1685
+ expect(tokens[1].data).toBe('Normal');
1686
+ expect(tokens[1].ctx.aci).toBe(256); // default
1687
+ });
1688
+
1689
+ it('restores previous formatting after a formatting block', () => {
1690
+ const parser = new MTextParser('\\C2;Before {\\C1;Red} After');
1691
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1692
+ expect(tokens[0].data).toBe('Before');
1693
+ expect(tokens[0].ctx.aci).toBe(2);
1694
+ expect(tokens[1].data).toBe('Red');
1695
+ expect(tokens[1].ctx.aci).toBe(1);
1696
+ expect(tokens[2].data).toBe('After');
1697
+ expect(tokens[2].ctx.aci).toBe(2);
1698
+ });
1699
+
1700
+ it('restores context correctly with nested braces', () => {
1701
+ const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}');
1702
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1703
+ expect(tokens[0].data).toBe('Red');
1704
+ expect(tokens[0].ctx.aci).toBe(1);
1705
+ expect(tokens[1].data).toBe('Blue');
1706
+ expect(tokens[1].ctx.aci).toBe(2);
1707
+ expect(tokens[2].data).toBe('RedAgain');
1708
+ expect(tokens[2].ctx.aci).toBe(1);
1709
+ });
1710
+
1711
+ it('persists formatting outside braces if not reset', () => {
1712
+ const parser = new MTextParser('\\C3;All {\\C1;Red} StillAll');
1713
+ const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD);
1714
+ expect(tokens[0].data).toBe('All');
1715
+ expect(tokens[0].ctx.aci).toBe(3);
1716
+ expect(tokens[1].data).toBe('Red');
1717
+ expect(tokens[1].ctx.aci).toBe(1);
1718
+ expect(tokens[2].data).toBe('StillAll');
1719
+ expect(tokens[2].ctx.aci).toBe(3);
1720
+ });
1721
+ });
1722
+
1723
+ describe('MTextParser context restoration with braces {} and yieldPropertyCommands', () => {
1724
+ it('yields property change tokens when entering and exiting a formatting block', () => {
1725
+ const parser = new MTextParser('Normal {\\fArial|i;Italic} Back', undefined, {
1726
+ yieldPropertyCommands: true,
1727
+ });
1728
+ const tokens = Array.from(parser.parse());
1729
+ // Filter for property changes and words
1730
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1731
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1732
+ // Should yield a property change for entering Arial Italic
1733
+ expect(propTokens[0].data).toEqual({
1734
+ command: 'f',
1735
+ changes: { fontFace: { family: 'Arial', style: 'Italic', weight: 400 } },
1736
+ depth: 1,
1737
+ });
1738
+ // Should yield a property change for restoring default font after block
1739
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1740
+ command: undefined,
1741
+ changes: { fontFace: { family: '', style: 'Regular', weight: 400 } },
1742
+ depth: 0,
1743
+ });
1744
+ // Check word tokens context
1745
+ expect(wordTokens[0].data).toBe('Normal');
1746
+ expect(wordTokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1747
+ expect(wordTokens[1].data).toBe('Italic');
1748
+ expect(wordTokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 });
1749
+ expect(wordTokens[2].data).toBe('Back');
1750
+ expect(wordTokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 });
1751
+ });
1752
+
1753
+ it('yields property change tokens for color and restores after block', () => {
1754
+ const parser = new MTextParser('{\\C1;Red} Normal', undefined, {
1755
+ yieldPropertyCommands: true,
1756
+ });
1757
+ const tokens = Array.from(parser.parse());
1758
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1759
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1760
+ expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1761
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1762
+ command: undefined,
1763
+ changes: { aci: 256 },
1764
+ depth: 0,
1765
+ });
1766
+ expect(wordTokens[0].data).toBe('Red');
1767
+ expect(wordTokens[0].ctx.aci).toBe(1);
1768
+ expect(wordTokens[1].data).toBe('Normal');
1769
+ expect(wordTokens[1].ctx.aci).toBe(256);
1770
+ });
1771
+
1772
+ it('yields property change tokens for nested braces', () => {
1773
+ const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}', undefined, {
1774
+ yieldPropertyCommands: true,
1775
+ });
1776
+ const tokens = Array.from(parser.parse());
1777
+ const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED);
1778
+ const wordTokens = tokens.filter(t => t.type === TokenType.WORD);
1779
+ // Enter C1
1780
+ expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 });
1781
+ // Enter C2
1782
+ expect(propTokens[1].data).toEqual({ command: 'C', changes: { aci: 2 }, depth: 2 });
1783
+ // Exit C2 (restore C1)
1784
+ expect(propTokens[2].data).toEqual({ command: undefined, changes: { aci: 1 }, depth: 1 });
1785
+ // Exit C1 (restore default)
1786
+ expect(propTokens[propTokens.length - 1].data).toEqual({
1787
+ command: undefined,
1788
+ changes: { aci: 256 },
1789
+ depth: 0,
1790
+ });
1791
+ expect(wordTokens[0].data).toBe('Red');
1792
+ expect(wordTokens[0].ctx.aci).toBe(1);
1793
+ expect(wordTokens[1].data).toBe('Blue');
1794
+ expect(wordTokens[1].ctx.aci).toBe(2);
1795
+ expect(wordTokens[2].data).toBe('RedAgain');
1796
+ expect(wordTokens[2].ctx.aci).toBe(1);
1797
+ });
1798
+
1799
+ it('yields property change tokens for RGB color commands', () => {
1800
+ // \c16711680 is 0xFF0000, which is [255,0,0] (red)
1801
+ const parser = new MTextParser('\\c16711680Red Text', undefined, {
1802
+ yieldPropertyCommands: true,
1803
+ });
1804
+ const tokens = Array.from(parser.parse());
1805
+ expect(tokens).toHaveLength(4);
1806
+ expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED);
1807
+ expect(tokens[0].data).toEqual({
1808
+ command: 'c',
1809
+ changes: {
1810
+ aci: null,
1811
+ rgb: [255, 0, 0],
1812
+ },
1813
+ depth: 0,
1814
+ });
1815
+ expect(tokens[1].type).toBe(TokenType.WORD);
1816
+ expect(tokens[1].data).toBe('Red');
1817
+ expect(tokens[1].ctx.rgb).toEqual([255, 0, 0]);
1818
+ expect(tokens[2].type).toBe(TokenType.SPACE);
1819
+ expect(tokens[2].ctx.rgb).toEqual([255, 0, 0]);
1820
+ expect(tokens[3].type).toBe(TokenType.WORD);
1821
+ expect(tokens[3].data).toBe('Text');
1822
+ expect(tokens[3].ctx.rgb).toEqual([255, 0, 0]);
1823
+ });
1824
+ });
1825
+ });
1826
+
1827
+ describe('MTextParser resetParagraphParameters option', () => {
1828
+ it('resets paragraph properties after NEW_PARAGRAPH when resetParagraphParameters is true', () => {
1829
+ // Create a context with non-default paragraph properties
1830
+ const ctx = new MTextContext();
1831
+ ctx.paragraph.indent = 2;
1832
+ ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1833
+
1834
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1835
+ yieldPropertyCommands: true,
1836
+ resetParagraphParameters: true,
1837
+ });
1838
+ const tokens = Array.from(parser.parse());
1839
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, PROPERTIES_CHANGED (reset), WORD(Line2)
1840
+ expect(tokens[0].type).toBe(TokenType.WORD);
1841
+ expect(tokens[0].data).toBe('Line1');
1842
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1843
+ expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED);
1844
+ const propChanged = tokens[2].data as import('./parser').ChangedProperties;
1845
+ expect(propChanged.changes).toHaveProperty('paragraph');
1846
+ expect(tokens[3].type).toBe(TokenType.WORD);
1847
+ expect(tokens[3].data).toBe('Line2');
1848
+ });
1849
+
1850
+ it('does not emit PROPERTIES_CHANGED after NEW_PARAGRAPH if resetParagraphParameters is false', () => {
1851
+ // Create a context with non-default paragraph properties
1852
+ const ctx = new MTextContext();
1853
+ ctx.paragraph.indent = 2;
1854
+ ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1855
+
1856
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1857
+ yieldPropertyCommands: true,
1858
+ resetParagraphParameters: false,
1859
+ });
1860
+ const tokens = Array.from(parser.parse());
1861
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1862
+ expect(tokens[0].type).toBe(TokenType.WORD);
1863
+ expect(tokens[0].data).toBe('Line1');
1864
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1865
+ expect(tokens[2].type).toBe(TokenType.WORD);
1866
+ expect(tokens[2].data).toBe('Line2');
1867
+ expect(
1868
+ tokens.find(
1869
+ t =>
1870
+ t.type === TokenType.PROPERTIES_CHANGED &&
1871
+ t.data &&
1872
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1873
+ )
1874
+ ).toBeUndefined();
1875
+ });
1876
+
1877
+ it('resets paragraph properties but does not emit PROPERTIES_CHANGED if yieldPropertyCommands is false', () => {
1878
+ // Create a context with non-default paragraph properties
1879
+ const ctx = new MTextContext();
1880
+ ctx.paragraph.indent = 2;
1881
+ ctx.paragraph.align = MTextParagraphAlignment.CENTER;
1882
+
1883
+ const parser = new MTextParser('Line1\\PLine2', ctx, {
1884
+ yieldPropertyCommands: false,
1885
+ resetParagraphParameters: true,
1886
+ });
1887
+ const tokens = Array.from(parser.parse());
1888
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2)
1889
+ expect(tokens[0].type).toBe(TokenType.WORD);
1890
+ expect(tokens[0].data).toBe('Line1');
1891
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1892
+ expect(tokens[2].type).toBe(TokenType.WORD);
1893
+ expect(tokens[2].data).toBe('Line2');
1894
+ expect(
1895
+ tokens.find(
1896
+ t =>
1897
+ t.type === TokenType.PROPERTIES_CHANGED &&
1898
+ t.data &&
1899
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1900
+ )
1901
+ ).toBeUndefined();
1902
+ });
1903
+
1904
+ it('does not emit PROPERTIES_CHANGED when using default context with resetParagraphParameters true', () => {
1905
+ const parser = new MTextParser('Line1\\PLine2', undefined, {
1906
+ yieldPropertyCommands: true,
1907
+ resetParagraphParameters: true,
1908
+ });
1909
+ const tokens = Array.from(parser.parse());
1910
+ // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - no PROPERTIES_CHANGED because default context has default paragraph properties
1911
+ expect(tokens[0].type).toBe(TokenType.WORD);
1912
+ expect(tokens[0].data).toBe('Line1');
1913
+ expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH);
1914
+ expect(tokens[2].type).toBe(TokenType.WORD);
1915
+ expect(tokens[2].data).toBe('Line2');
1916
+ expect(
1917
+ tokens.find(
1918
+ t =>
1919
+ t.type === TokenType.PROPERTIES_CHANGED &&
1920
+ t.data &&
1921
+ (t.data as import('./parser').ChangedProperties).changes?.paragraph
1922
+ )
1923
+ ).toBeUndefined();
1924
+ });
1925
+
1926
+ it('resets paragraph properties after raw newline followup NEW_PARAGRAPH', () => {
1927
+ const ctx = new MTextContext();
1928
+ ctx.paragraph.indent = 2;
1929
+ ctx.paragraph.align = MTextParagraphAlignment.LEFT;
1930
+
1931
+ // Pending word + literal `\n` emits WORD then followup NEW_PARAGRAPH;
1932
+ // reset must still run for the followup token.
1933
+ const parser = new MTextParser('Line1\nLine2', ctx, {
1934
+ yieldPropertyCommands: true,
1935
+ resetParagraphParameters: true,
1936
+ });
1937
+ const tokens = Array.from(parser.parse());
1938
+ expect(tokens.map(t => t.type)).toEqual([
1939
+ TokenType.WORD,
1940
+ TokenType.NEW_PARAGRAPH,
1941
+ TokenType.PROPERTIES_CHANGED,
1942
+ TokenType.WORD,
1943
+ ]);
1944
+ expect(tokens[0].data).toBe('Line1');
1945
+ expect(tokens[3].data).toBe('Line2');
1946
+ const propChanged = tokens[2].data as import('./parser').ChangedProperties;
1947
+ expect(propChanged.changes).toHaveProperty('paragraph');
1948
+ });
1949
+ });
1950
+
1951
+ describe('TextScanner', () => {
1952
+ let scanner: TextScanner;
1953
+
1954
+ beforeEach(() => {
1955
+ scanner = new TextScanner('Hello World');
1956
+ });
1957
+
1958
+ it('initializes with correct state', () => {
1959
+ expect(scanner.currentIndex).toBe(0);
1960
+ expect(scanner.isEmpty).toBe(false);
1961
+ expect(scanner.hasData).toBe(true);
1962
+ });
1963
+
1964
+ it('consumes characters', () => {
1965
+ expect(scanner.get()).toBe('H');
1966
+ expect(scanner.currentIndex).toBe(1);
1967
+ expect(scanner.get()).toBe('e');
1968
+ expect(scanner.currentIndex).toBe(2);
1969
+ });
1970
+
1971
+ it('peeks characters', () => {
1972
+ expect(scanner.peek()).toBe('H');
1973
+ expect(scanner.peek(1)).toBe('e');
1974
+ expect(scanner.currentIndex).toBe(0);
1975
+ });
1976
+
1977
+ it('consumes multiple characters', () => {
1978
+ scanner.consume(5);
1979
+ expect(scanner.currentIndex).toBe(5);
1980
+ expect(scanner.get()).toBe(' ');
1981
+ });
1982
+
1983
+ it('finds characters', () => {
1984
+ expect(scanner.find('W')).toBe(6);
1985
+ expect(scanner.find('X')).toBe(-1);
1986
+ });
1987
+
1988
+ it('handles escaped characters in find', () => {
1989
+ scanner = new TextScanner('Hello\\;World');
1990
+ expect(scanner.find(';', true)).toBe(6);
1991
+ });
1992
+
1993
+ it('gets remaining text', () => {
1994
+ scanner.consume(6);
1995
+ expect(scanner.tail).toBe('World');
1996
+ });
1997
+
1998
+ it('handles end of text', () => {
1999
+ scanner.consume(11);
2000
+ expect(scanner.isEmpty).toBe(true);
2001
+ expect(scanner.hasData).toBe(false);
2002
+ expect(scanner.get()).toBe('');
2003
+ expect(scanner.peek()).toBe('');
2004
+ });
2005
+ });
2006
+
2007
+ describe('getFonts', () => {
2008
+ it('should return empty set for empty string', () => {
2009
+ const result = getFonts('');
2010
+ expect(result).toEqual(new Set());
2011
+ });
2012
+
2013
+ it('should extract single font name', () => {
2014
+ const result = getFonts('\\fArial|Hello World');
2015
+ expect(result).toEqual(new Set(['arial']));
2016
+ });
2017
+
2018
+ it('should extract multiple unique font names', () => {
2019
+ const result = getFonts('\\fArial|Hello \\fTimes New Roman|World');
2020
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
2021
+ });
2022
+
2023
+ it('should handle case-insensitive font names', () => {
2024
+ const result = getFonts('\\fARIAL|Hello \\fArial|World');
2025
+ expect(result).toEqual(new Set(['arial']));
2026
+ });
2027
+
2028
+ it('should handle font names with spaces', () => {
2029
+ const result = getFonts('\\fTimes New Roman|Hello \\fComic Sans MS|World');
2030
+ expect(result).toEqual(new Set(['times new roman', 'comic sans ms']));
2031
+ });
2032
+
2033
+ it('should handle multiple font changes in sequence', () => {
2034
+ const result = getFonts('\\fArial|Hello \\fTimes New Roman|World \\fArial|Again');
2035
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
2036
+ });
2037
+
2038
+ it('should handle font names with special characters', () => {
2039
+ const result = getFonts('\\fArial-Bold|Hello \\fTimes-New-Roman|World');
2040
+ expect(result).toEqual(new Set(['arial-bold', 'times-new-roman']));
2041
+ });
2042
+
2043
+ it('should handle both lowercase and uppercase font commands', () => {
2044
+ const result = getFonts('\\fArial|Hello \\FTimes New Roman|World');
2045
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
2046
+ });
2047
+
2048
+ it('should handle complex MText with semicolon terminators', () => {
2049
+ const mtext =
2050
+ '{\\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}';
2051
+ const result = getFonts(mtext);
2052
+ expect(result).toEqual(new Set(['simsun', 'arial', 'romans', 'simhei', 'simkai']));
2053
+ });
2054
+
2055
+ it('should preserve font extensions when removeExtension is false', () => {
2056
+ const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
2057
+ const result = getFonts(mtext, false);
2058
+ expect(result).toEqual(new Set(['arial.ttf', 'times new roman.otf']));
2059
+ });
2060
+
2061
+ it('should remove font extensions when removeExtension is true', () => {
2062
+ const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World';
2063
+ const result = getFonts(mtext, true);
2064
+ expect(result).toEqual(new Set(['arial', 'times new roman']));
2065
+ });
2066
+
2067
+ it('should handle various font extensions', () => {
2068
+ const mtext = '\\fFont1.ttf|Text1 \\fFont2.otf|Text2 \\fFont3.woff|Text3 \\fFont4.shx|Text4';
2069
+ const result = getFonts(mtext, true);
2070
+ expect(result).toEqual(new Set(['font1', 'font2', 'font3', 'font4']));
2071
+ });
2072
+
2073
+ it('should not remove non-font extensions', () => {
2074
+ const mtext = '\\fFont1.txt|Text1 \\fFont2.doc|Text2';
2075
+ const result = getFonts(mtext, true);
2076
+ expect(result).toEqual(new Set(['font1.txt', 'font2.doc']));
2077
+ });
2078
+ });
2079
+
2080
+ describe('MTextColor', () => {
2081
+ it('defaults to ACI 256 (by layer)', () => {
2082
+ const color = new MTextColor();
2083
+ expect(color.aci).toBe(256);
2084
+ expect(color.rgb).toBeNull();
2085
+ expect(color.isAci).toBe(true);
2086
+ expect(color.isRgb).toBe(false);
2087
+ });
2088
+
2089
+ it('can be constructed with ACI', () => {
2090
+ const color = new MTextColor(1);
2091
+ expect(color.aci).toBe(1);
2092
+ expect(color.rgb).toBeNull();
2093
+ expect(color.isAci).toBe(true);
2094
+ expect(color.isRgb).toBe(false);
2095
+ });
2096
+
2097
+ it('can be constructed with RGB', () => {
2098
+ const color = new MTextColor([255, 0, 0]);
2099
+ expect(color.aci).toBeNull();
2100
+ expect(color.rgb).toEqual([255, 0, 0]);
2101
+ expect(color.isAci).toBe(false);
2102
+ expect(color.isRgb).toBe(true);
2103
+ });
2104
+
2105
+ it('switches from ACI to RGB and back', () => {
2106
+ const color = new MTextColor(2);
2107
+ expect(color.aci).toBe(2);
2108
+ color.rgb = [0, 255, 0];
2109
+ expect(color.aci).toBeNull();
2110
+ expect(color.rgb).toEqual([0, 255, 0]);
2111
+ color.aci = 7;
2112
+ expect(color.aci).toBe(7);
2113
+ expect(color.rgb).toBeNull();
2114
+ });
2115
+
2116
+ it('copy() produces a deep copy', () => {
2117
+ const color = new MTextColor([1, 2, 3]);
2118
+ const copy = color.copy();
2119
+ expect(copy).not.toBe(color);
2120
+ expect(copy.aci).toBe(color.aci);
2121
+ expect(copy.rgb).toEqual(color.rgb);
2122
+ copy.rgb = [4, 5, 6];
2123
+ expect(color.rgb).toEqual([1, 2, 3]);
2124
+ expect(copy.rgb).toEqual([4, 5, 6]);
2125
+ });
2126
+
2127
+ it('fromCssColor parses hex and rgb()', () => {
2128
+ const hex = MTextColor.fromCssColor('#ff0000');
2129
+ expect(hex).not.toBeNull();
2130
+ expect(hex!.rgb).toEqual([255, 0, 0]);
2131
+
2132
+ const shortHex = MTextColor.fromCssColor('#0f0');
2133
+ expect(shortHex).not.toBeNull();
2134
+ expect(shortHex!.rgb).toEqual([0, 255, 0]);
2135
+
2136
+ const rgb = MTextColor.fromCssColor('rgb(0, 128, 255)');
2137
+ expect(rgb).not.toBeNull();
2138
+ expect(rgb!.rgb).toEqual([0, 128, 255]);
2139
+ });
2140
+
2141
+ it('fromCssColor handles rgba() and rejects transparent', () => {
2142
+ const rgba = MTextColor.fromCssColor('rgba(10, 20, 30, 0.5)');
2143
+ expect(rgba).not.toBeNull();
2144
+ expect(rgba!.rgb).toEqual([10, 20, 30]);
2145
+
2146
+ const transparent = MTextColor.fromCssColor('transparent');
2147
+ expect(transparent).toBeNull();
2148
+ });
2149
+
2150
+ it('toCssColor returns hex for RGB and null for ACI', () => {
2151
+ const rgb = new MTextColor([255, 0, 0]);
2152
+ expect(rgb.toCssColor()).toBe('#ff0000');
2153
+
2154
+ const aci = new MTextColor(1);
2155
+ expect(aci.toCssColor()).toBeNull();
2156
+ });
2157
+ });