@mlightcad/mtext-parser 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/parser.js ADDED
@@ -0,0 +1,1652 @@
1
+ /**
2
+ * Token types used in MText parsing
3
+ */
4
+ export var TokenType;
5
+ (function (TokenType) {
6
+ /** No token */
7
+ TokenType[TokenType["NONE"] = 0] = "NONE";
8
+ /** Word token with string data */
9
+ TokenType[TokenType["WORD"] = 1] = "WORD";
10
+ /** Stack token with [numerator, denominator, type] data */
11
+ TokenType[TokenType["STACK"] = 2] = "STACK";
12
+ /** Space token with no data */
13
+ TokenType[TokenType["SPACE"] = 3] = "SPACE";
14
+ /** Non-breaking space token with no data */
15
+ TokenType[TokenType["NBSP"] = 4] = "NBSP";
16
+ /** Tab token with no data */
17
+ TokenType[TokenType["TABULATOR"] = 5] = "TABULATOR";
18
+ /** New paragraph token with no data */
19
+ TokenType[TokenType["NEW_PARAGRAPH"] = 6] = "NEW_PARAGRAPH";
20
+ /** New column token with no data */
21
+ TokenType[TokenType["NEW_COLUMN"] = 7] = "NEW_COLUMN";
22
+ /** Wrap at dimension line token with no data */
23
+ TokenType[TokenType["WRAP_AT_DIMLINE"] = 8] = "WRAP_AT_DIMLINE";
24
+ /** Properties changed token with string data (full command) */
25
+ TokenType[TokenType["PROPERTIES_CHANGED"] = 9] = "PROPERTIES_CHANGED";
26
+ })(TokenType || (TokenType = {}));
27
+ /**
28
+ * Line alignment options for MText
29
+ */
30
+ export var MTextLineAlignment;
31
+ (function (MTextLineAlignment) {
32
+ /** Align text to bottom */
33
+ MTextLineAlignment[MTextLineAlignment["BOTTOM"] = 0] = "BOTTOM";
34
+ /** Align text to middle */
35
+ MTextLineAlignment[MTextLineAlignment["MIDDLE"] = 1] = "MIDDLE";
36
+ /** Align text to top */
37
+ MTextLineAlignment[MTextLineAlignment["TOP"] = 2] = "TOP";
38
+ })(MTextLineAlignment || (MTextLineAlignment = {}));
39
+ /**
40
+ * Paragraph alignment options for MText
41
+ */
42
+ export var MTextParagraphAlignment;
43
+ (function (MTextParagraphAlignment) {
44
+ /** Default alignment */
45
+ MTextParagraphAlignment[MTextParagraphAlignment["DEFAULT"] = 0] = "DEFAULT";
46
+ /** Left alignment */
47
+ MTextParagraphAlignment[MTextParagraphAlignment["LEFT"] = 1] = "LEFT";
48
+ /** Right alignment */
49
+ MTextParagraphAlignment[MTextParagraphAlignment["RIGHT"] = 2] = "RIGHT";
50
+ /** Center alignment */
51
+ MTextParagraphAlignment[MTextParagraphAlignment["CENTER"] = 3] = "CENTER";
52
+ /** Justified alignment */
53
+ MTextParagraphAlignment[MTextParagraphAlignment["JUSTIFIED"] = 4] = "JUSTIFIED";
54
+ /** Distributed alignment */
55
+ MTextParagraphAlignment[MTextParagraphAlignment["DISTRIBUTED"] = 5] = "DISTRIBUTED";
56
+ })(MTextParagraphAlignment || (MTextParagraphAlignment = {}));
57
+ /**
58
+ * Text stroke options for MText
59
+ */
60
+ export var MTextStroke;
61
+ (function (MTextStroke) {
62
+ /** No stroke */
63
+ MTextStroke[MTextStroke["NONE"] = 0] = "NONE";
64
+ /** Underline stroke */
65
+ MTextStroke[MTextStroke["UNDERLINE"] = 1] = "UNDERLINE";
66
+ /** Overline stroke */
67
+ MTextStroke[MTextStroke["OVERLINE"] = 2] = "OVERLINE";
68
+ /** Strike-through stroke */
69
+ MTextStroke[MTextStroke["STRIKE_THROUGH"] = 4] = "STRIKE_THROUGH";
70
+ })(MTextStroke || (MTextStroke = {}));
71
+ /**
72
+ * Special character encoding mapping
73
+ */
74
+ const SPECIAL_CHAR_ENCODING = {
75
+ c: 'Ø',
76
+ d: '°',
77
+ p: '±',
78
+ '%': '%',
79
+ };
80
+ /**
81
+ * Character to paragraph alignment mapping
82
+ */
83
+ const CHAR_TO_ALIGN = {
84
+ l: MTextParagraphAlignment.LEFT,
85
+ r: MTextParagraphAlignment.RIGHT,
86
+ c: MTextParagraphAlignment.CENTER,
87
+ j: MTextParagraphAlignment.JUSTIFIED,
88
+ d: MTextParagraphAlignment.DISTRIBUTED,
89
+ };
90
+ /**
91
+ * Convert RGB tuple to integer color value
92
+ * @param rgb - RGB color tuple
93
+ * @returns Integer color value
94
+ */
95
+ export function rgb2int(rgb) {
96
+ const [r, g, b] = rgb;
97
+ return (r << 16) | (g << 8) | b;
98
+ }
99
+ /**
100
+ * Convert integer color value to RGB tuple
101
+ * @param value - Integer color value
102
+ * @returns RGB color tuple
103
+ */
104
+ export function int2rgb(value) {
105
+ const r = (value >> 16) & 0xff;
106
+ const g = (value >> 8) & 0xff;
107
+ const b = value & 0xff;
108
+ return [r, g, b];
109
+ }
110
+ /**
111
+ * Escape DXF line endings
112
+ * @param text - Text to escape
113
+ * @returns Escaped text
114
+ */
115
+ export function escapeDxfLineEndings(text) {
116
+ return text.replace(/\r\n|\r|\n/g, '\\P');
117
+ }
118
+ /**
119
+ * Check if text contains inline formatting codes
120
+ * @param text - Text to check
121
+ * @returns True if text contains formatting codes
122
+ */
123
+ export function hasInlineFormattingCodes(text) {
124
+ return text.replace(/\\P/g, '').replace(/\\~/g, '').includes('\\');
125
+ }
126
+ /**
127
+ * Extracts all unique font names used in an MText string.
128
+ * This function searches for font commands in the format \f{fontname}| or \f{fontname}; and returns a set of unique font names.
129
+ * Font names are converted to lowercase to ensure case-insensitive uniqueness.
130
+ *
131
+ * @param mtext - The MText string to analyze for font names
132
+ * @param removeExtension - Whether to remove font file extensions (e.g., .ttf, .shx) from font names. Defaults to false.
133
+ * @returns A Set containing all unique font names found in the MText string, converted to lowercase
134
+ * @example
135
+ * ```ts
136
+ * const mtext = "\\fArial.ttf|Hello\\fTimes New Roman.otf|World";
137
+ * const fonts = getFonts(mtext, true);
138
+ * // Returns: Set(2) { "arial", "times new roman" }
139
+ * ```
140
+ */
141
+ export function getFonts(mtext, removeExtension = false) {
142
+ const fonts = new Set();
143
+ const regex = /\\[fF](.*?)[;|]/g;
144
+ [...mtext.matchAll(regex)].forEach(match => {
145
+ let fontName = match[1].toLowerCase();
146
+ if (removeExtension) {
147
+ fontName = fontName.replace(/\.(ttf|otf|woff|shx)$/, '');
148
+ }
149
+ fonts.add(fontName);
150
+ });
151
+ return fonts;
152
+ }
153
+ /**
154
+ * ContextStack manages a stack of MTextContext objects for character-level formatting.
155
+ *
156
+ * - Character-level formatting (underline, color, font, etc.) is scoped to `{}` blocks and managed by the stack.
157
+ * - Paragraph-level formatting (\p) is not scoped, but when a block ends, any paragraph property changes are merged into the parent context.
158
+ * - On pop, paragraph properties from the popped context are always merged into the new top context.
159
+ */
160
+ class ContextStack {
161
+ /**
162
+ * Creates a new ContextStack with an initial context.
163
+ * @param initial The initial MTextContext to use as the base of the stack.
164
+ */
165
+ constructor(initial) {
166
+ this.stack = [];
167
+ this.stack.push(initial);
168
+ }
169
+ /**
170
+ * Pushes a copy of the given context onto the stack.
171
+ * @param ctx The MTextContext to push (copied).
172
+ */
173
+ push(ctx) {
174
+ this.stack.push(ctx);
175
+ }
176
+ /**
177
+ * Pops the top context from the stack and merges its paragraph properties into the new top context.
178
+ * If only one context remains, nothing is popped.
179
+ * @returns The popped MTextContext, or undefined if the stack has only one context.
180
+ */
181
+ pop() {
182
+ if (this.stack.length <= 1)
183
+ return undefined;
184
+ const popped = this.stack.pop();
185
+ // Merge paragraph properties into the new top context
186
+ const top = this.stack[this.stack.length - 1];
187
+ if (JSON.stringify(top.paragraph) !== JSON.stringify(popped.paragraph)) {
188
+ top.paragraph = { ...popped.paragraph };
189
+ }
190
+ return popped;
191
+ }
192
+ /**
193
+ * Returns the current (top) context on the stack.
194
+ */
195
+ get current() {
196
+ return this.stack[this.stack.length - 1];
197
+ }
198
+ /**
199
+ * Returns the current stack depth (number of nested blocks), not counting the root context.
200
+ */
201
+ get depth() {
202
+ return this.stack.length - 1;
203
+ }
204
+ /**
205
+ * Returns the root (bottom) context, which represents the global formatting state.
206
+ * Used for paragraph property application.
207
+ */
208
+ get root() {
209
+ return this.stack[0];
210
+ }
211
+ /**
212
+ * Replaces the current (top) context with the given context.
213
+ * @param ctx The new context to set as the current context.
214
+ */
215
+ setCurrent(ctx) {
216
+ this.stack[this.stack.length - 1] = ctx;
217
+ }
218
+ }
219
+ /**
220
+ * Main parser class for MText content
221
+ */
222
+ export class MTextParser {
223
+ /**
224
+ * Creates a new MTextParser instance
225
+ * @param content - The MText content to parse
226
+ * @param ctx - Optional initial MText context
227
+ * @param options - Parser options
228
+ */
229
+ constructor(content, ctx, options = {}) {
230
+ this.continueStroke = false;
231
+ this.inStackContext = false;
232
+ this.scanner = new TextScanner(content);
233
+ const initialCtx = ctx ?? new MTextContext();
234
+ this.ctxStack = new ContextStack(initialCtx);
235
+ this.yieldPropertyCommands = options.yieldPropertyCommands ?? false;
236
+ this.resetParagraphParameters = options.resetParagraphParameters ?? false;
237
+ this.mifDecoder = options.mifDecoder ?? this.decodeMultiByteChar.bind(this);
238
+ this.mifCodeLength = options.mifCodeLength ?? 'auto';
239
+ }
240
+ /**
241
+ * Decode multi-byte character from hex code
242
+ * @param hex - Hex code string (e.g. "C4E3" or "1A2B3")
243
+ * @returns Decoded character or empty square if invalid
244
+ */
245
+ decodeMultiByteChar(hex) {
246
+ try {
247
+ // For 5-digit codes, return placeholder directly
248
+ if (hex.length === 5) {
249
+ const prefix = hex[0];
250
+ // Notes:
251
+ // I know AutoCAD uses prefix 1 for Shift-JIS, 2 for big5, and 5 for gbk.
252
+ // But I don't know whether there are other prefixes and their meanings.
253
+ let encoding = 'gbk';
254
+ if (prefix === '1') {
255
+ encoding = 'shift-jis';
256
+ }
257
+ else if (prefix === '2') {
258
+ encoding = 'big5';
259
+ }
260
+ const bytes = new Uint8Array([
261
+ parseInt(hex.substr(1, 2), 16),
262
+ parseInt(hex.substr(3, 2), 16),
263
+ ]);
264
+ const decoder = new TextDecoder(encoding);
265
+ const result = decoder.decode(bytes);
266
+ return result;
267
+ }
268
+ else if (hex.length === 4) {
269
+ // For 4-digit hex codes, decode as 2-byte character
270
+ const bytes = new Uint8Array([
271
+ parseInt(hex.substr(0, 2), 16),
272
+ parseInt(hex.substr(2, 2), 16),
273
+ ]);
274
+ // Try GBK first
275
+ const gbkDecoder = new TextDecoder('gbk');
276
+ const gbkResult = gbkDecoder.decode(bytes);
277
+ if (gbkResult !== '▯') {
278
+ return gbkResult;
279
+ }
280
+ // Try BIG5 if GBK fails
281
+ const big5Decoder = new TextDecoder('big5');
282
+ const big5Result = big5Decoder.decode(bytes);
283
+ if (big5Result !== '▯') {
284
+ return big5Result;
285
+ }
286
+ }
287
+ return '▯';
288
+ }
289
+ catch {
290
+ return '▯';
291
+ }
292
+ }
293
+ /**
294
+ * Extract MIF hex code from scanner
295
+ * @param length - The length of the hex code to extract (4 or 5), or 'auto' to detect
296
+ * @returns The extracted hex code, or null if not found
297
+ */
298
+ extractMifCode(length) {
299
+ if (length === 'auto') {
300
+ // Try 5 digits first if available, then fall back to 4 digits
301
+ const code5 = this.scanner.tail.match(/^[0-9A-Fa-f]{5}/)?.[0];
302
+ if (code5) {
303
+ return code5;
304
+ }
305
+ const code4 = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/)?.[0];
306
+ if (code4) {
307
+ return code4;
308
+ }
309
+ return null;
310
+ }
311
+ else {
312
+ const code = this.scanner.tail.match(new RegExp(`^[0-9A-Fa-f]{${length}}`))?.[0];
313
+ return code ?? null;
314
+ }
315
+ }
316
+ /**
317
+ * Push current context onto the stack
318
+ */
319
+ pushCtx() {
320
+ this.ctxStack.push(this.ctxStack.current);
321
+ }
322
+ /**
323
+ * Pop context from the stack
324
+ */
325
+ popCtx() {
326
+ this.ctxStack.pop();
327
+ }
328
+ /**
329
+ * Parse stacking expression (numerator/denominator)
330
+ * @returns Tuple of [TokenType.STACK, [numerator, denominator, type]]
331
+ */
332
+ parseStacking() {
333
+ const scanner = new TextScanner(this.extractExpression(true));
334
+ let numerator = '';
335
+ let denominator = '';
336
+ let stackingType = '';
337
+ const getNextChar = () => {
338
+ let c = scanner.peek();
339
+ let escape = false;
340
+ if (c.charCodeAt(0) < 32) {
341
+ c = ' ';
342
+ }
343
+ if (c === '\\') {
344
+ escape = true;
345
+ scanner.consume(1);
346
+ c = scanner.peek();
347
+ }
348
+ scanner.consume(1);
349
+ return [c, escape];
350
+ };
351
+ const parseNumerator = () => {
352
+ let word = '';
353
+ while (scanner.hasData) {
354
+ const [c, escape] = getNextChar();
355
+ // Check for stacking operators first
356
+ if (!escape && (c === '/' || c === '#' || c === '^')) {
357
+ return [word, c];
358
+ }
359
+ word += c;
360
+ }
361
+ return [word, ''];
362
+ };
363
+ const parseDenominator = (skipLeadingSpace) => {
364
+ let word = '';
365
+ let skipping = skipLeadingSpace;
366
+ while (scanner.hasData) {
367
+ const [c, escape] = getNextChar();
368
+ if (skipping && c === ' ') {
369
+ continue;
370
+ }
371
+ skipping = false;
372
+ // Stop at terminator unless escaped
373
+ if (!escape && c === ';') {
374
+ break;
375
+ }
376
+ word += c;
377
+ }
378
+ return word;
379
+ };
380
+ [numerator, stackingType] = parseNumerator();
381
+ if (stackingType) {
382
+ // Only skip leading space for caret divider
383
+ denominator = parseDenominator(stackingType === '^');
384
+ }
385
+ // Special case for \S^!/^?;
386
+ if (numerator === '' && denominator.includes('I/')) {
387
+ return [TokenType.STACK, [' ', ' ', '/']];
388
+ }
389
+ // Handle caret as a stacking operator
390
+ if (stackingType === '^') {
391
+ return [TokenType.STACK, [numerator, denominator, '^']];
392
+ }
393
+ return [TokenType.STACK, [numerator, denominator, stackingType]];
394
+ }
395
+ /**
396
+ * Parse MText properties
397
+ * @param cmd - The property command to parse
398
+ * @returns Property changes if yieldPropertyCommands is true and changes occurred
399
+ */
400
+ parseProperties(cmd) {
401
+ const prevCtx = this.ctxStack.current.copy();
402
+ const newCtx = this.ctxStack.current.copy();
403
+ switch (cmd) {
404
+ case 'L':
405
+ newCtx.underline = true;
406
+ this.continueStroke = true;
407
+ break;
408
+ case 'l':
409
+ newCtx.underline = false;
410
+ if (!newCtx.hasAnyStroke) {
411
+ this.continueStroke = false;
412
+ }
413
+ break;
414
+ case 'O':
415
+ newCtx.overline = true;
416
+ this.continueStroke = true;
417
+ break;
418
+ case 'o':
419
+ newCtx.overline = false;
420
+ if (!newCtx.hasAnyStroke) {
421
+ this.continueStroke = false;
422
+ }
423
+ break;
424
+ case 'K':
425
+ newCtx.strikeThrough = true;
426
+ this.continueStroke = true;
427
+ break;
428
+ case 'k':
429
+ newCtx.strikeThrough = false;
430
+ if (!newCtx.hasAnyStroke) {
431
+ this.continueStroke = false;
432
+ }
433
+ break;
434
+ case 'A':
435
+ this.parseAlign(newCtx);
436
+ break;
437
+ case 'C':
438
+ this.parseAciColor(newCtx);
439
+ break;
440
+ case 'c':
441
+ this.parseRgbColor(newCtx);
442
+ break;
443
+ case 'H':
444
+ this.parseHeight(newCtx);
445
+ break;
446
+ case 'W':
447
+ this.parseWidth(newCtx);
448
+ break;
449
+ case 'Q':
450
+ this.parseOblique(newCtx);
451
+ break;
452
+ case 'T':
453
+ this.parseCharTracking(newCtx);
454
+ break;
455
+ case 'p':
456
+ this.parseParagraphProperties(newCtx);
457
+ break;
458
+ case 'f':
459
+ case 'F':
460
+ this.parseFontProperties(newCtx);
461
+ break;
462
+ default:
463
+ throw new Error(`Unknown command: ${cmd}`);
464
+ }
465
+ // Update continueStroke based on current stroke state
466
+ this.continueStroke = newCtx.hasAnyStroke;
467
+ newCtx.continueStroke = this.continueStroke;
468
+ // Use setCurrent to replace the current context
469
+ this.ctxStack.setCurrent(newCtx);
470
+ if (this.yieldPropertyCommands) {
471
+ const changes = this.getPropertyChanges(prevCtx, newCtx);
472
+ if (Object.keys(changes).length > 0) {
473
+ return {
474
+ command: cmd,
475
+ changes,
476
+ depth: this.ctxStack.depth,
477
+ };
478
+ }
479
+ }
480
+ }
481
+ /**
482
+ * Get property changes between two contexts
483
+ * @param oldCtx - The old context
484
+ * @param newCtx - The new context
485
+ * @returns Object containing changed properties
486
+ */
487
+ getPropertyChanges(oldCtx, newCtx) {
488
+ const changes = {};
489
+ if (oldCtx.underline !== newCtx.underline) {
490
+ changes.underline = newCtx.underline;
491
+ }
492
+ if (oldCtx.overline !== newCtx.overline) {
493
+ changes.overline = newCtx.overline;
494
+ }
495
+ if (oldCtx.strikeThrough !== newCtx.strikeThrough) {
496
+ changes.strikeThrough = newCtx.strikeThrough;
497
+ }
498
+ if (oldCtx.color.aci !== newCtx.color.aci) {
499
+ changes.aci = newCtx.color.aci;
500
+ }
501
+ if (oldCtx.color.rgbValue !== newCtx.color.rgbValue) {
502
+ changes.rgb = newCtx.color.rgb;
503
+ }
504
+ if (oldCtx.align !== newCtx.align) {
505
+ changes.align = newCtx.align;
506
+ }
507
+ if (JSON.stringify(oldCtx.fontFace) !== JSON.stringify(newCtx.fontFace)) {
508
+ changes.fontFace = newCtx.fontFace;
509
+ }
510
+ if (oldCtx.capHeight.value !== newCtx.capHeight.value ||
511
+ oldCtx.capHeight.isRelative !== newCtx.capHeight.isRelative) {
512
+ changes.capHeight = newCtx.capHeight;
513
+ }
514
+ if (oldCtx.widthFactor.value !== newCtx.widthFactor.value ||
515
+ oldCtx.widthFactor.isRelative !== newCtx.widthFactor.isRelative) {
516
+ changes.widthFactor = newCtx.widthFactor;
517
+ }
518
+ if (oldCtx.charTrackingFactor.value !== newCtx.charTrackingFactor.value ||
519
+ oldCtx.charTrackingFactor.isRelative !== newCtx.charTrackingFactor.isRelative) {
520
+ changes.charTrackingFactor = newCtx.charTrackingFactor;
521
+ }
522
+ if (oldCtx.oblique !== newCtx.oblique) {
523
+ changes.oblique = newCtx.oblique;
524
+ }
525
+ if (JSON.stringify(oldCtx.paragraph) !== JSON.stringify(newCtx.paragraph)) {
526
+ // Only include changed paragraph properties
527
+ const changedProps = {};
528
+ if (oldCtx.paragraph.indent !== newCtx.paragraph.indent) {
529
+ changedProps.indent = newCtx.paragraph.indent;
530
+ }
531
+ if (oldCtx.paragraph.align !== newCtx.paragraph.align) {
532
+ changedProps.align = newCtx.paragraph.align;
533
+ }
534
+ if (oldCtx.paragraph.left !== newCtx.paragraph.left) {
535
+ changedProps.left = newCtx.paragraph.left;
536
+ }
537
+ if (oldCtx.paragraph.right !== newCtx.paragraph.right) {
538
+ changedProps.right = newCtx.paragraph.right;
539
+ }
540
+ if (JSON.stringify(oldCtx.paragraph.tabs) !== JSON.stringify(newCtx.paragraph.tabs)) {
541
+ changedProps.tabs = newCtx.paragraph.tabs;
542
+ }
543
+ if (Object.keys(changedProps).length > 0) {
544
+ changes.paragraph = changedProps;
545
+ }
546
+ }
547
+ return changes;
548
+ }
549
+ /**
550
+ * Parse alignment property
551
+ * @param ctx - The context to update
552
+ */
553
+ parseAlign(ctx) {
554
+ const char = this.scanner.get();
555
+ if ('012'.includes(char)) {
556
+ ctx.align = parseInt(char);
557
+ }
558
+ else {
559
+ ctx.align = MTextLineAlignment.BOTTOM;
560
+ }
561
+ this.consumeOptionalTerminator();
562
+ }
563
+ /**
564
+ * Parse height property
565
+ * @param ctx - The context to update
566
+ */
567
+ parseHeight(ctx) {
568
+ const expr = this.extractFloatExpression(true);
569
+ if (expr) {
570
+ try {
571
+ if (expr.endsWith('x')) {
572
+ // For height command, treat x suffix as relative value
573
+ ctx.capHeight = {
574
+ value: parseFloat(expr.slice(0, -1)),
575
+ isRelative: true,
576
+ };
577
+ }
578
+ else {
579
+ ctx.capHeight = {
580
+ value: parseFloat(expr),
581
+ isRelative: false,
582
+ };
583
+ }
584
+ }
585
+ catch {
586
+ // If parsing fails, treat the entire command as literal text
587
+ this.scanner.consume(-expr.length); // Rewind to before the expression
588
+ return;
589
+ }
590
+ }
591
+ this.consumeOptionalTerminator();
592
+ }
593
+ /**
594
+ * Parse width property
595
+ * @param ctx - The context to update
596
+ */
597
+ parseWidth(ctx) {
598
+ const expr = this.extractFloatExpression(true);
599
+ if (expr) {
600
+ try {
601
+ if (expr.endsWith('x')) {
602
+ // For width command, treat x suffix as relative value
603
+ ctx.widthFactor = {
604
+ value: parseFloat(expr.slice(0, -1)),
605
+ isRelative: true,
606
+ };
607
+ }
608
+ else {
609
+ ctx.widthFactor = {
610
+ value: parseFloat(expr),
611
+ isRelative: false,
612
+ };
613
+ }
614
+ }
615
+ catch {
616
+ // If parsing fails, treat the entire command as literal text
617
+ this.scanner.consume(-expr.length); // Rewind to before the expression
618
+ return;
619
+ }
620
+ }
621
+ this.consumeOptionalTerminator();
622
+ }
623
+ /**
624
+ * Parse character tracking property
625
+ * @param ctx - The context to update
626
+ */
627
+ parseCharTracking(ctx) {
628
+ const expr = this.extractFloatExpression(true);
629
+ if (expr) {
630
+ try {
631
+ if (expr.endsWith('x')) {
632
+ // For tracking command, treat x suffix as relative value
633
+ ctx.charTrackingFactor = {
634
+ value: Math.abs(parseFloat(expr.slice(0, -1))),
635
+ isRelative: true,
636
+ };
637
+ }
638
+ else {
639
+ ctx.charTrackingFactor = {
640
+ value: Math.abs(parseFloat(expr)),
641
+ isRelative: false,
642
+ };
643
+ }
644
+ }
645
+ catch {
646
+ // If parsing fails, treat the entire command as literal text
647
+ this.scanner.consume(-expr.length); // Rewind to before the expression
648
+ return;
649
+ }
650
+ }
651
+ this.consumeOptionalTerminator();
652
+ }
653
+ /**
654
+ * Parse float value or factor
655
+ * @param value - Current value to apply factor to
656
+ * @returns New value
657
+ */
658
+ parseFloatValueOrFactor(value) {
659
+ const expr = this.extractFloatExpression(true);
660
+ if (expr) {
661
+ if (expr.endsWith('x')) {
662
+ const factor = parseFloat(expr.slice(0, -1));
663
+ value *= factor; // Allow negative factors
664
+ }
665
+ else {
666
+ value = parseFloat(expr); // Allow negative values
667
+ }
668
+ }
669
+ return value;
670
+ }
671
+ /**
672
+ * Parse oblique angle property
673
+ * @param ctx - The context to update
674
+ */
675
+ parseOblique(ctx) {
676
+ const obliqueExpr = this.extractFloatExpression(false);
677
+ if (obliqueExpr) {
678
+ ctx.oblique = parseFloat(obliqueExpr);
679
+ }
680
+ this.consumeOptionalTerminator();
681
+ }
682
+ /**
683
+ * Parse ACI color property
684
+ * @param ctx - The context to update
685
+ */
686
+ parseAciColor(ctx) {
687
+ const aciExpr = this.extractIntExpression();
688
+ if (aciExpr) {
689
+ const aci = parseInt(aciExpr);
690
+ if (aci < 257) {
691
+ ctx.color.aci = aci;
692
+ }
693
+ }
694
+ this.consumeOptionalTerminator();
695
+ }
696
+ /**
697
+ * Parse RGB color property
698
+ * @param ctx - The context to update
699
+ */
700
+ parseRgbColor(ctx) {
701
+ const rgbExpr = this.extractIntExpression();
702
+ if (rgbExpr) {
703
+ const value = parseInt(rgbExpr) & 0xffffff;
704
+ ctx.color.rgbValue = value;
705
+ }
706
+ this.consumeOptionalTerminator();
707
+ }
708
+ /**
709
+ * Extract float expression from scanner
710
+ * @param relative - Whether to allow relative values (ending in 'x')
711
+ * @returns Extracted expression
712
+ */
713
+ extractFloatExpression(relative = false) {
714
+ const pattern = relative
715
+ ? /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?x?/
716
+ : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
717
+ const match = this.scanner.tail.match(pattern);
718
+ if (match) {
719
+ const result = match[0];
720
+ this.scanner.consume(result.length);
721
+ return result;
722
+ }
723
+ return '';
724
+ }
725
+ /**
726
+ * Extract integer expression from scanner
727
+ * @returns Extracted expression
728
+ */
729
+ extractIntExpression() {
730
+ const match = this.scanner.tail.match(/^\d+/);
731
+ if (match) {
732
+ const result = match[0];
733
+ this.scanner.consume(result.length);
734
+ return result;
735
+ }
736
+ return '';
737
+ }
738
+ /**
739
+ * Extract expression until semicolon or end
740
+ * @param escape - Whether to handle escaped semicolons
741
+ * @returns Extracted expression
742
+ */
743
+ extractExpression(escape = false) {
744
+ const stop = this.scanner.find(';', escape);
745
+ if (stop < 0) {
746
+ const expr = this.scanner.tail;
747
+ this.scanner.consume(expr.length);
748
+ return expr;
749
+ }
750
+ // Check if the semicolon is escaped by looking at the previous character
751
+ const prevChar = this.scanner.peek(stop - this.scanner.currentIndex - 1);
752
+ const isEscaped = prevChar === '\\';
753
+ const expr = this.scanner.tail.slice(0, stop - this.scanner.currentIndex + (isEscaped ? 1 : 0));
754
+ this.scanner.consume(expr.length + 1);
755
+ return expr;
756
+ }
757
+ /**
758
+ * Parse font properties
759
+ * @param ctx - The context to update
760
+ */
761
+ parseFontProperties(ctx) {
762
+ const parts = this.extractExpression().split('|');
763
+ if (parts.length > 0 && parts[0]) {
764
+ const name = parts[0];
765
+ let style = 'Regular';
766
+ let weight = 400;
767
+ for (const part of parts.slice(1)) {
768
+ if (part.startsWith('b1')) {
769
+ weight = 700;
770
+ }
771
+ else if (part === 'i' || part.startsWith('i1')) {
772
+ style = 'Italic';
773
+ }
774
+ else if (part === 'i0' || part.startsWith('i0')) {
775
+ style = 'Regular';
776
+ }
777
+ }
778
+ ctx.fontFace = {
779
+ family: name,
780
+ style,
781
+ weight,
782
+ };
783
+ }
784
+ }
785
+ /**
786
+ * Parse paragraph properties from the MText content
787
+ * Handles properties like indentation, alignment, and tab stops
788
+ * @param ctx - The context to update
789
+ */
790
+ parseParagraphProperties(ctx) {
791
+ const scanner = new TextScanner(this.extractExpression());
792
+ /** Current indentation value */
793
+ let indent = ctx.paragraph.indent;
794
+ /** Left margin value */
795
+ let left = ctx.paragraph.left;
796
+ /** Right margin value */
797
+ let right = ctx.paragraph.right;
798
+ /** Current paragraph alignment */
799
+ let align = ctx.paragraph.align;
800
+ /** Array of tab stop positions and types */
801
+ let tabStops = [];
802
+ /**
803
+ * Parse a floating point number from the scanner's current position
804
+ * Handles optional sign, decimal point, and scientific notation
805
+ * @returns The parsed float value, or 0 if no valid number is found
806
+ */
807
+ const parseFloatValue = () => {
808
+ const match = scanner.tail.match(/^[+-]?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/);
809
+ if (match) {
810
+ const value = parseFloat(match[0]);
811
+ scanner.consume(match[0].length);
812
+ while (scanner.peek() === ',') {
813
+ scanner.consume(1);
814
+ }
815
+ return value;
816
+ }
817
+ return 0;
818
+ };
819
+ while (scanner.hasData) {
820
+ const cmd = scanner.get();
821
+ switch (cmd) {
822
+ case 'i': // Indentation
823
+ indent = parseFloatValue();
824
+ break;
825
+ case 'l': // Left margin
826
+ left = parseFloatValue();
827
+ break;
828
+ case 'r': // Right margin
829
+ right = parseFloatValue();
830
+ break;
831
+ case 'x': // Skip
832
+ break;
833
+ case 'q': {
834
+ // Alignment
835
+ const adjustment = scanner.get();
836
+ align = CHAR_TO_ALIGN[adjustment] || MTextParagraphAlignment.DEFAULT;
837
+ while (scanner.peek() === ',') {
838
+ scanner.consume(1);
839
+ }
840
+ break;
841
+ }
842
+ case 't': // Tab stops
843
+ tabStops = [];
844
+ while (scanner.hasData) {
845
+ const type = scanner.peek();
846
+ if (type === 'r' || type === 'c') {
847
+ scanner.consume(1);
848
+ const value = parseFloatValue();
849
+ tabStops.push(type + value.toString());
850
+ }
851
+ else {
852
+ const value = parseFloatValue();
853
+ if (!isNaN(value)) {
854
+ tabStops.push(value);
855
+ }
856
+ else {
857
+ scanner.consume(1);
858
+ }
859
+ }
860
+ }
861
+ break;
862
+ }
863
+ }
864
+ ctx.paragraph = {
865
+ indent,
866
+ left,
867
+ right,
868
+ align,
869
+ tabs: tabStops,
870
+ };
871
+ }
872
+ /**
873
+ * Consume optional terminator (semicolon)
874
+ */
875
+ consumeOptionalTerminator() {
876
+ if (this.scanner.peek() === ';') {
877
+ this.scanner.consume(1);
878
+ }
879
+ }
880
+ /**
881
+ * Parse MText content into tokens
882
+ * @yields MTextToken objects
883
+ */
884
+ *parse() {
885
+ const wordToken = TokenType.WORD;
886
+ const spaceToken = TokenType.SPACE;
887
+ let followupToken = null;
888
+ function resetParagraph(ctx) {
889
+ const prev = { ...ctx.paragraph };
890
+ ctx.paragraph = {
891
+ indent: 0,
892
+ left: 0,
893
+ right: 0,
894
+ align: MTextParagraphAlignment.DEFAULT,
895
+ tabs: [],
896
+ };
897
+ const changed = {};
898
+ if (prev.indent !== 0)
899
+ changed.indent = 0;
900
+ if (prev.left !== 0)
901
+ changed.left = 0;
902
+ if (prev.right !== 0)
903
+ changed.right = 0;
904
+ if (prev.align !== MTextParagraphAlignment.DEFAULT)
905
+ changed.align = MTextParagraphAlignment.DEFAULT;
906
+ if (JSON.stringify(prev.tabs) !== JSON.stringify([]))
907
+ changed.tabs = [];
908
+ return changed;
909
+ }
910
+ const nextToken = () => {
911
+ let word = '';
912
+ while (this.scanner.hasData) {
913
+ let escape = false;
914
+ let letter = this.scanner.peek();
915
+ const cmdStartIndex = this.scanner.currentIndex;
916
+ // Handle control characters first
917
+ if (letter.charCodeAt(0) < 32) {
918
+ this.scanner.consume(1); // Always consume the control character
919
+ if (letter === '\t') {
920
+ return [TokenType.TABULATOR, null];
921
+ }
922
+ if (letter === '\n') {
923
+ return [TokenType.NEW_PARAGRAPH, null];
924
+ }
925
+ letter = ' ';
926
+ }
927
+ if (letter === '\\') {
928
+ if ('\\{}'.includes(this.scanner.peek(1))) {
929
+ escape = true;
930
+ this.scanner.consume(1);
931
+ letter = this.scanner.peek();
932
+ }
933
+ else {
934
+ if (word) {
935
+ return [wordToken, word];
936
+ }
937
+ this.scanner.consume(1);
938
+ const cmd = this.scanner.get();
939
+ switch (cmd) {
940
+ case '~':
941
+ return [TokenType.NBSP, null];
942
+ case 'P':
943
+ return [TokenType.NEW_PARAGRAPH, null];
944
+ case 'N':
945
+ return [TokenType.NEW_COLUMN, null];
946
+ case 'X':
947
+ return [TokenType.WRAP_AT_DIMLINE, null];
948
+ case 'S': {
949
+ this.inStackContext = true;
950
+ const result = this.parseStacking();
951
+ this.inStackContext = false;
952
+ return result;
953
+ }
954
+ case 'm':
955
+ case 'M':
956
+ // Handle multi-byte character encoding (MIF)
957
+ if (this.scanner.peek() === '+') {
958
+ this.scanner.consume(1); // Consume the '+'
959
+ const hexCode = this.extractMifCode(this.mifCodeLength);
960
+ if (hexCode) {
961
+ this.scanner.consume(hexCode.length);
962
+ const decodedChar = this.mifDecoder(hexCode);
963
+ if (word) {
964
+ return [wordToken, word];
965
+ }
966
+ return [wordToken, decodedChar];
967
+ }
968
+ // If no valid hex code found, rewind the '+' character
969
+ this.scanner.consume(-1);
970
+ }
971
+ // If not a valid multi-byte code, treat as literal text
972
+ word += '\\M';
973
+ continue;
974
+ case 'U':
975
+ // Handle Unicode escape: \U+XXXX or \U+XXXXXXXX
976
+ if (this.scanner.peek() === '+') {
977
+ this.scanner.consume(1); // Consume the '+'
978
+ const hexMatch = this.scanner.tail.match(/^[0-9A-Fa-f]{4,8}/);
979
+ if (hexMatch) {
980
+ const hexCode = hexMatch[0];
981
+ this.scanner.consume(hexCode.length);
982
+ const codePoint = parseInt(hexCode, 16);
983
+ let decodedChar = '';
984
+ try {
985
+ decodedChar = String.fromCodePoint(codePoint);
986
+ }
987
+ catch {
988
+ decodedChar = '▯';
989
+ }
990
+ if (word) {
991
+ return [wordToken, word];
992
+ }
993
+ return [wordToken, decodedChar];
994
+ }
995
+ // If no valid hex code found, rewind the '+' character
996
+ this.scanner.consume(-1);
997
+ }
998
+ // If not a valid Unicode code, treat as literal text
999
+ word += '\\U';
1000
+ continue;
1001
+ default:
1002
+ if (cmd) {
1003
+ try {
1004
+ const propertyChanges = this.parseProperties(cmd);
1005
+ if (this.yieldPropertyCommands && propertyChanges) {
1006
+ return [TokenType.PROPERTIES_CHANGED, propertyChanges];
1007
+ }
1008
+ // After processing a property command, continue with normal parsing
1009
+ continue;
1010
+ }
1011
+ catch {
1012
+ const commandText = this.scanner.tail.slice(cmdStartIndex, this.scanner.currentIndex);
1013
+ word += commandText;
1014
+ }
1015
+ }
1016
+ }
1017
+ continue;
1018
+ }
1019
+ }
1020
+ if (letter === '%' && this.scanner.peek(1) === '%') {
1021
+ const code = this.scanner.peek(2).toLowerCase();
1022
+ const specialChar = SPECIAL_CHAR_ENCODING[code];
1023
+ if (specialChar) {
1024
+ this.scanner.consume(3);
1025
+ word += specialChar;
1026
+ continue;
1027
+ }
1028
+ else {
1029
+ /**
1030
+ * Supports Control Codes: `%%ddd`, where ddd is a three-digit decimal number representing the ASCII code value of the character.
1031
+ *
1032
+ * Reference: https://help.autodesk.com/view/ACD/2026/ENU/?guid=GUID-968CBC1D-BA99-4519-ABDD-88419EB2BF92
1033
+ */
1034
+ const digits = [code, this.scanner.peek(3), this.scanner.peek(4)];
1035
+ if (digits.every(d => d >= '0' && d <= '9')) {
1036
+ const charCode = Number.parseInt(digits.join(''), 10);
1037
+ this.scanner.consume(5);
1038
+ word += String.fromCharCode(charCode);
1039
+ }
1040
+ else {
1041
+ // Skip invalid special character codes
1042
+ this.scanner.consume(3);
1043
+ }
1044
+ continue;
1045
+ }
1046
+ }
1047
+ if (letter === ' ') {
1048
+ if (word) {
1049
+ this.scanner.consume(1);
1050
+ followupToken = spaceToken;
1051
+ return [wordToken, word];
1052
+ }
1053
+ this.scanner.consume(1);
1054
+ return [spaceToken, null];
1055
+ }
1056
+ if (!escape) {
1057
+ if (letter === '{') {
1058
+ if (word) {
1059
+ return [wordToken, word];
1060
+ }
1061
+ this.scanner.consume(1);
1062
+ this.pushCtx();
1063
+ continue;
1064
+ }
1065
+ else if (letter === '}') {
1066
+ if (word) {
1067
+ return [wordToken, word];
1068
+ }
1069
+ this.scanner.consume(1);
1070
+ // Context restoration with yieldPropertyCommands
1071
+ if (this.yieldPropertyCommands) {
1072
+ const prevCtx = this.ctxStack.current;
1073
+ this.popCtx();
1074
+ const changes = this.getPropertyChanges(prevCtx, this.ctxStack.current);
1075
+ if (Object.keys(changes).length > 0) {
1076
+ return [
1077
+ TokenType.PROPERTIES_CHANGED,
1078
+ { command: undefined, changes, depth: this.ctxStack.depth },
1079
+ ];
1080
+ }
1081
+ }
1082
+ else {
1083
+ this.popCtx();
1084
+ }
1085
+ continue;
1086
+ }
1087
+ }
1088
+ // Handle caret-encoded characters only when not in stack context
1089
+ if (!this.inStackContext && letter === '^') {
1090
+ const nextChar = this.scanner.peek(1);
1091
+ if (nextChar) {
1092
+ const code = nextChar.charCodeAt(0);
1093
+ this.scanner.consume(2); // Consume both ^ and the next character
1094
+ if (code === 32) {
1095
+ // Space
1096
+ word += '^';
1097
+ }
1098
+ else if (code === 73) {
1099
+ // Tab
1100
+ if (word) {
1101
+ return [wordToken, word];
1102
+ }
1103
+ return [TokenType.TABULATOR, null];
1104
+ }
1105
+ else if (code === 74) {
1106
+ // Line feed
1107
+ if (word) {
1108
+ return [wordToken, word];
1109
+ }
1110
+ return [TokenType.NEW_PARAGRAPH, null];
1111
+ }
1112
+ else if (code === 77) {
1113
+ // Carriage return
1114
+ // Ignore carriage return
1115
+ continue;
1116
+ }
1117
+ else {
1118
+ word += '▯';
1119
+ }
1120
+ continue;
1121
+ }
1122
+ }
1123
+ this.scanner.consume(1);
1124
+ if (letter.charCodeAt(0) >= 32) {
1125
+ word += letter;
1126
+ }
1127
+ }
1128
+ if (word) {
1129
+ return [wordToken, word];
1130
+ }
1131
+ return [TokenType.NONE, null];
1132
+ };
1133
+ while (true) {
1134
+ const [type, data] = nextToken.call(this);
1135
+ if (type) {
1136
+ yield new MTextToken(type, this.ctxStack.current.copy(), data);
1137
+ if (type === TokenType.NEW_PARAGRAPH && this.resetParagraphParameters) {
1138
+ // Reset paragraph properties and emit PROPERTIES_CHANGED if needed
1139
+ const ctx = this.ctxStack.current;
1140
+ const changed = resetParagraph(ctx);
1141
+ if (this.yieldPropertyCommands && Object.keys(changed).length > 0) {
1142
+ yield new MTextToken(TokenType.PROPERTIES_CHANGED, ctx.copy(), {
1143
+ command: undefined,
1144
+ changes: { paragraph: changed },
1145
+ depth: this.ctxStack.depth,
1146
+ });
1147
+ }
1148
+ }
1149
+ if (followupToken) {
1150
+ yield new MTextToken(followupToken, this.ctxStack.current.copy(), null);
1151
+ followupToken = null;
1152
+ }
1153
+ }
1154
+ else {
1155
+ break;
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ /**
1161
+ * Text scanner for parsing MText content
1162
+ */
1163
+ export class TextScanner {
1164
+ /**
1165
+ * Create a new text scanner
1166
+ * @param text - The text to scan
1167
+ */
1168
+ constructor(text) {
1169
+ this.text = text;
1170
+ this.textLen = text.length;
1171
+ this._index = 0;
1172
+ }
1173
+ /**
1174
+ * Get the current index in the text
1175
+ */
1176
+ get currentIndex() {
1177
+ return this._index;
1178
+ }
1179
+ /**
1180
+ * Check if the scanner has reached the end of the text
1181
+ */
1182
+ get isEmpty() {
1183
+ return this._index >= this.textLen;
1184
+ }
1185
+ /**
1186
+ * Check if there is more text to scan
1187
+ */
1188
+ get hasData() {
1189
+ return this._index < this.textLen;
1190
+ }
1191
+ /**
1192
+ * Get the next character and advance the index
1193
+ * @returns The next character, or empty string if at end
1194
+ */
1195
+ get() {
1196
+ if (this.isEmpty) {
1197
+ return '';
1198
+ }
1199
+ const char = this.text[this._index];
1200
+ this._index++;
1201
+ return char;
1202
+ }
1203
+ /**
1204
+ * Advance the index by the specified count
1205
+ * @param count - Number of characters to advance
1206
+ */
1207
+ consume(count = 1) {
1208
+ this._index = Math.max(0, Math.min(this._index + count, this.textLen));
1209
+ }
1210
+ /**
1211
+ * Look at a character without advancing the index
1212
+ * @param offset - Offset from current position
1213
+ * @returns The character at the offset position, or empty string if out of bounds
1214
+ */
1215
+ peek(offset = 0) {
1216
+ const index = this._index + offset;
1217
+ if (index >= this.textLen || index < 0) {
1218
+ return '';
1219
+ }
1220
+ return this.text[index];
1221
+ }
1222
+ /**
1223
+ * Find the next occurrence of a character
1224
+ * @param char - The character to find
1225
+ * @param escape - Whether to handle escaped characters
1226
+ * @returns Index of the character, or -1 if not found
1227
+ */
1228
+ find(char, escape = false) {
1229
+ let index = this._index;
1230
+ while (index < this.textLen) {
1231
+ if (escape && this.text[index] === '\\') {
1232
+ if (index + 1 < this.textLen) {
1233
+ if (this.text[index + 1] === char) {
1234
+ return index + 1;
1235
+ }
1236
+ index += 2;
1237
+ continue;
1238
+ }
1239
+ index++;
1240
+ continue;
1241
+ }
1242
+ if (this.text[index] === char) {
1243
+ return index;
1244
+ }
1245
+ index++;
1246
+ }
1247
+ return -1;
1248
+ }
1249
+ /**
1250
+ * Get the remaining text from the current position
1251
+ */
1252
+ get tail() {
1253
+ return this.text.slice(this._index);
1254
+ }
1255
+ /**
1256
+ * Check if the next character is a space
1257
+ */
1258
+ isNextSpace() {
1259
+ return this.peek() === ' ';
1260
+ }
1261
+ /**
1262
+ * Consume spaces until a non-space character is found
1263
+ * @returns Number of spaces consumed
1264
+ */
1265
+ consumeSpaces() {
1266
+ let count = 0;
1267
+ while (this.isNextSpace()) {
1268
+ this.consume();
1269
+ count++;
1270
+ }
1271
+ return count;
1272
+ }
1273
+ }
1274
+ /**
1275
+ * Class to handle ACI and RGB color logic for MText.
1276
+ *
1277
+ * This class encapsulates color state for MText, supporting both AutoCAD Color Index (ACI) and RGB color.
1278
+ * Only one color mode is active at a time: setting an RGB color disables ACI, and vice versa.
1279
+ * RGB is stored as a single 24-bit integer (0xRRGGBB) for efficient comparison and serialization.
1280
+ *
1281
+ * Example usage:
1282
+ * ```ts
1283
+ * const color1 = new MTextColor(1); // ACI color
1284
+ * const color2 = new MTextColor([255, 0, 0]); // RGB color
1285
+ * const color3 = new MTextColor(); // Default (ACI=256, "by layer")
1286
+ * ```
1287
+ */
1288
+ export class MTextColor {
1289
+ /**
1290
+ * Create a new MTextColor instance.
1291
+ * @param color The initial color: number for ACI, [r,g,b] for RGB, or null/undefined for default (ACI=256).
1292
+ */
1293
+ constructor(color) {
1294
+ /**
1295
+ * The AutoCAD Color Index (ACI) value. Only used if no RGB color is set.
1296
+ * @default 256 ("by layer")
1297
+ */
1298
+ this._aci = 256;
1299
+ /**
1300
+ * The RGB color value as a single 24-bit integer (0xRRGGBB), or null if not set.
1301
+ * @default null
1302
+ */
1303
+ this._rgbValue = null; // Store as 0xRRGGBB or null
1304
+ if (Array.isArray(color)) {
1305
+ this.rgb = color;
1306
+ }
1307
+ else if (typeof color === 'number') {
1308
+ this.aci = color;
1309
+ }
1310
+ else {
1311
+ this.aci = 256;
1312
+ }
1313
+ }
1314
+ /**
1315
+ * Get the current ACI color value.
1316
+ * @returns The ACI color (0-256), or null if using RGB.
1317
+ */
1318
+ get aci() {
1319
+ return this._aci;
1320
+ }
1321
+ /**
1322
+ * Set the ACI color value. Setting this disables any RGB color.
1323
+ * @param value The ACI color (0-256), or null to unset.
1324
+ * @throws Error if value is out of range.
1325
+ */
1326
+ set aci(value) {
1327
+ if (value === null) {
1328
+ this._aci = null;
1329
+ }
1330
+ else if (value >= 0 && value <= 256) {
1331
+ this._aci = value;
1332
+ this._rgbValue = null;
1333
+ }
1334
+ else {
1335
+ throw new Error('ACI not in range [0, 256]');
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Get the current RGB color as a tuple [r, g, b], or null if not set.
1340
+ * @returns The RGB color tuple, or null if using ACI.
1341
+ */
1342
+ get rgb() {
1343
+ if (this._rgbValue === null)
1344
+ return null;
1345
+ // Extract R, G, B from 0xRRGGBB
1346
+ const r = (this._rgbValue >> 16) & 0xff;
1347
+ const g = (this._rgbValue >> 8) & 0xff;
1348
+ const b = this._rgbValue & 0xff;
1349
+ return [r, g, b];
1350
+ }
1351
+ /**
1352
+ * Set the RGB color. Setting this disables ACI color.
1353
+ * @param value The RGB color tuple [r, g, b], or null to use ACI.
1354
+ */
1355
+ set rgb(value) {
1356
+ if (value) {
1357
+ const [r, g, b] = value;
1358
+ this._rgbValue = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff);
1359
+ this._aci = null;
1360
+ }
1361
+ else {
1362
+ this._rgbValue = null;
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Returns true if the color is set by RGB, false if by ACI.
1367
+ */
1368
+ get isRgb() {
1369
+ return this._rgbValue !== null;
1370
+ }
1371
+ /**
1372
+ * Returns true if the color is set by ACI, false if by RGB.
1373
+ */
1374
+ get isAci() {
1375
+ return this._rgbValue === null && this._aci !== null;
1376
+ }
1377
+ /**
1378
+ * Get or set the internal RGB value as a number (0xRRGGBB), or null if not set.
1379
+ * Setting this will switch to RGB mode and set ACI to null.
1380
+ */
1381
+ get rgbValue() {
1382
+ return this._rgbValue;
1383
+ }
1384
+ set rgbValue(val) {
1385
+ if (val === null) {
1386
+ this._rgbValue = null;
1387
+ }
1388
+ else {
1389
+ this._rgbValue = val & 0xffffff;
1390
+ this._aci = null;
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Returns a deep copy of this color.
1395
+ * @returns A new MTextColor instance with the same color state.
1396
+ */
1397
+ copy() {
1398
+ const c = new MTextColor();
1399
+ c._aci = this._aci;
1400
+ c._rgbValue = this._rgbValue;
1401
+ return c;
1402
+ }
1403
+ /**
1404
+ * Returns a plain object for serialization.
1405
+ * @returns An object with aci, rgb (tuple), and rgbValue (number or null).
1406
+ */
1407
+ toObject() {
1408
+ return { aci: this._aci, rgb: this.rgb, rgbValue: this._rgbValue };
1409
+ }
1410
+ /**
1411
+ * Equality check for color.
1412
+ * @param other The other MTextColor to compare.
1413
+ * @returns True if both ACI and RGB values are equal.
1414
+ */
1415
+ equals(other) {
1416
+ return this._aci === other._aci && this._rgbValue === other._rgbValue;
1417
+ }
1418
+ }
1419
+ /**
1420
+ * MText context class for managing text formatting state
1421
+ */
1422
+ export class MTextContext {
1423
+ constructor() {
1424
+ this._stroke = 0;
1425
+ /** Whether to continue stroke formatting */
1426
+ this.continueStroke = false;
1427
+ /** Color (ACI or RGB) */
1428
+ this.color = new MTextColor();
1429
+ /** Line alignment */
1430
+ this.align = MTextLineAlignment.BOTTOM;
1431
+ /** Font face properties */
1432
+ this.fontFace = { family: '', style: 'Regular', weight: 400 };
1433
+ /** Capital letter height */
1434
+ this._capHeight = { value: 1.0, isRelative: false };
1435
+ /** Character width factor */
1436
+ this._widthFactor = { value: 1.0, isRelative: false };
1437
+ /**
1438
+ * Character tracking factor a multiplier applied to the default spacing between characters in the MText object.
1439
+ * - Value = 1.0 → Normal spacing.
1440
+ * - Value < 1.0 → Characters are closer together.
1441
+ * - Value > 1.0 → Characters are spaced farther apart.
1442
+ */
1443
+ this._charTrackingFactor = { value: 1.0, isRelative: false };
1444
+ /** Oblique angle */
1445
+ this.oblique = 0.0;
1446
+ /** Paragraph properties */
1447
+ this.paragraph = {
1448
+ indent: 0,
1449
+ left: 0,
1450
+ right: 0,
1451
+ align: MTextParagraphAlignment.DEFAULT,
1452
+ tabs: [],
1453
+ };
1454
+ }
1455
+ /**
1456
+ * Get the capital letter height
1457
+ */
1458
+ get capHeight() {
1459
+ return this._capHeight;
1460
+ }
1461
+ /**
1462
+ * Set the capital letter height
1463
+ * @param value - Height value
1464
+ */
1465
+ set capHeight(value) {
1466
+ this._capHeight = {
1467
+ value: Math.abs(value.value),
1468
+ isRelative: value.isRelative,
1469
+ };
1470
+ }
1471
+ /**
1472
+ * Get the character width factor
1473
+ */
1474
+ get widthFactor() {
1475
+ return this._widthFactor;
1476
+ }
1477
+ /**
1478
+ * Set the character width factor
1479
+ * @param value - Width factor value
1480
+ */
1481
+ set widthFactor(value) {
1482
+ this._widthFactor = {
1483
+ value: Math.abs(value.value),
1484
+ isRelative: value.isRelative,
1485
+ };
1486
+ }
1487
+ /**
1488
+ * Get the character tracking factor
1489
+ */
1490
+ get charTrackingFactor() {
1491
+ return this._charTrackingFactor;
1492
+ }
1493
+ /**
1494
+ * Set the character tracking factor
1495
+ * @param value - Tracking factor value
1496
+ */
1497
+ set charTrackingFactor(value) {
1498
+ this._charTrackingFactor = {
1499
+ value: Math.abs(value.value),
1500
+ isRelative: value.isRelative,
1501
+ };
1502
+ }
1503
+ /**
1504
+ * Get the ACI color value
1505
+ */
1506
+ get aci() {
1507
+ return this.color.aci;
1508
+ }
1509
+ /**
1510
+ * Set the ACI color value
1511
+ * @param value - ACI color value (0-256)
1512
+ * @throws Error if value is out of range
1513
+ */
1514
+ set aci(value) {
1515
+ this.color.aci = value;
1516
+ }
1517
+ /**
1518
+ * Get the RGB color value
1519
+ */
1520
+ get rgb() {
1521
+ return this.color.rgb;
1522
+ }
1523
+ /**
1524
+ * Set the RGB color value
1525
+ */
1526
+ set rgb(value) {
1527
+ this.color.rgb = value;
1528
+ }
1529
+ /**
1530
+ * Gets whether the current text should be rendered in italic style.
1531
+ * @returns {boolean} True if the font style is 'Italic', otherwise false.
1532
+ */
1533
+ get italic() {
1534
+ return this.fontFace.style === 'Italic';
1535
+ }
1536
+ /**
1537
+ * Sets whether the current text should be rendered in italic style.
1538
+ * @param value - If true, sets the font style to 'Italic'; if false, sets it to 'Regular'.
1539
+ */
1540
+ set italic(value) {
1541
+ this.fontFace.style = value ? 'Italic' : 'Regular';
1542
+ }
1543
+ /**
1544
+ * Gets whether the current text should be rendered in bold style.
1545
+ * This is primarily used for mesh fonts and affects font selection.
1546
+ * @returns {boolean} True if the font weight is 700 or higher, otherwise false.
1547
+ */
1548
+ get bold() {
1549
+ return (this.fontFace.weight || 400) >= 700;
1550
+ }
1551
+ /**
1552
+ * Sets whether the current text should be rendered in bold style.
1553
+ * This is primarily used for mesh fonts and affects font selection.
1554
+ * @param value - If true, sets the font weight to 700; if false, sets it to 400.
1555
+ */
1556
+ set bold(value) {
1557
+ this.fontFace.weight = value ? 700 : 400;
1558
+ }
1559
+ /**
1560
+ * Get whether text is underlined
1561
+ */
1562
+ get underline() {
1563
+ return Boolean(this._stroke & MTextStroke.UNDERLINE);
1564
+ }
1565
+ /**
1566
+ * Set whether text is underlined
1567
+ * @param value - Whether to underline
1568
+ */
1569
+ set underline(value) {
1570
+ this._setStrokeState(MTextStroke.UNDERLINE, value);
1571
+ }
1572
+ /**
1573
+ * Get whether text has strike-through
1574
+ */
1575
+ get strikeThrough() {
1576
+ return Boolean(this._stroke & MTextStroke.STRIKE_THROUGH);
1577
+ }
1578
+ /**
1579
+ * Set whether text has strike-through
1580
+ * @param value - Whether to strike through
1581
+ */
1582
+ set strikeThrough(value) {
1583
+ this._setStrokeState(MTextStroke.STRIKE_THROUGH, value);
1584
+ }
1585
+ /**
1586
+ * Get whether text has overline
1587
+ */
1588
+ get overline() {
1589
+ return Boolean(this._stroke & MTextStroke.OVERLINE);
1590
+ }
1591
+ /**
1592
+ * Set whether text has overline
1593
+ * @param value - Whether to overline
1594
+ */
1595
+ set overline(value) {
1596
+ this._setStrokeState(MTextStroke.OVERLINE, value);
1597
+ }
1598
+ /**
1599
+ * Check if any stroke formatting is active
1600
+ */
1601
+ get hasAnyStroke() {
1602
+ return Boolean(this._stroke);
1603
+ }
1604
+ /**
1605
+ * Set the state of a stroke type
1606
+ * @param stroke - The stroke type to set
1607
+ * @param state - Whether to enable or disable the stroke
1608
+ */
1609
+ _setStrokeState(stroke, state = true) {
1610
+ if (state) {
1611
+ this._stroke |= stroke;
1612
+ }
1613
+ else {
1614
+ this._stroke &= ~stroke;
1615
+ }
1616
+ }
1617
+ /**
1618
+ * Create a copy of this context
1619
+ * @returns A new context with the same properties
1620
+ */
1621
+ copy() {
1622
+ const ctx = new MTextContext();
1623
+ ctx._stroke = this._stroke;
1624
+ ctx.continueStroke = this.continueStroke;
1625
+ ctx.color = this.color.copy();
1626
+ ctx.align = this.align;
1627
+ ctx.fontFace = { ...this.fontFace };
1628
+ ctx._capHeight = { ...this._capHeight };
1629
+ ctx._widthFactor = { ...this._widthFactor };
1630
+ ctx._charTrackingFactor = { ...this._charTrackingFactor };
1631
+ ctx.oblique = this.oblique;
1632
+ ctx.paragraph = { ...this.paragraph };
1633
+ return ctx;
1634
+ }
1635
+ }
1636
+ /**
1637
+ * Token class for MText parsing
1638
+ */
1639
+ export class MTextToken {
1640
+ /**
1641
+ * Create a new MText token
1642
+ * @param type - The token type
1643
+ * @param ctx - The text context at this token
1644
+ * @param data - Optional token data
1645
+ */
1646
+ constructor(type, ctx, data) {
1647
+ this.type = type;
1648
+ this.ctx = ctx;
1649
+ this.data = data;
1650
+ }
1651
+ }
1652
+ //# sourceMappingURL=parser.js.map