@mlightcad/mtext-parser 1.3.3 → 1.4.1

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