@mlightcad/mtext-parser 1.5.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts CHANGED
@@ -1,2129 +1,2167 @@
1
- /**
2
- * Token types used in MText parsing
3
- */
4
- export enum TokenType {
5
- /** No token */
6
- NONE = 0,
7
- /** Word token with string data */
8
- WORD = 1,
9
- /** Stack token with [numerator, denominator, type] data */
10
- STACK = 2,
11
- /** Space token with no data */
12
- SPACE = 3,
13
- /** Non-breaking space token with no data */
14
- NBSP = 4,
15
- /** Tab token with no data */
16
- TABULATOR = 5,
17
- /** New paragraph token with no data */
18
- NEW_PARAGRAPH = 6,
19
- /** New column token with no data */
20
- NEW_COLUMN = 7,
21
- /** Wrap at dimension line token with no data */
22
- WRAP_AT_DIMLINE = 8,
23
- /** Properties changed token with string data (full command) */
24
- PROPERTIES_CHANGED = 9,
25
- /** AutoCAD percent-sign symbol code (`%%c`, `%%d`, `%%p`, `%%nnn`, `%%%`) */
26
- PERCENT_SYMBOL = 10,
27
- }
28
-
29
- /**
30
- * AutoCAD percent-sign symbol emitted when {@link MTextParserOptions.yieldPercentSymbols}
31
- * is enabled.
32
- */
33
- export type PercentSymbolData =
34
- | {
35
- kind: 'named';
36
- /** Percent code letter: `%%c`, `%%d`, or `%%p`. */
37
- code: 'c' | 'd' | 'p';
38
- /** Unicode expansion used for display (e.g. `%%c` → `Ø`). */
39
- char: string;
40
- }
41
- | {
42
- kind: 'numeric';
43
- /**
44
- * Decimal code from `%%nnn`, where `nnn` is 1–3 digits (Unicode/ASCII
45
- * code point, e.g. `%%34` → `"`, `%%176` → `°`).
46
- */
47
- charCode: number;
48
- /** `String.fromCharCode(charCode)`. */
49
- char: string;
50
- }
51
- | {
52
- kind: 'literal';
53
- /** Literal percent from `%%%`. */
54
- char: '%';
55
- };
56
-
57
- /**
58
- * Represents a factor value that can be either absolute or relative.
59
- * Used for properties like height, width, and character tracking in MText formatting.
60
- */
61
- export interface FactorValue {
62
- /** The numeric value of the factor */
63
- value: number;
64
- /** Whether the value is relative (true) or absolute (false) */
65
- isRelative: boolean;
66
- }
67
-
68
- /**
69
- * Format properties of MText word tokens.
70
- * This interface defines all the formatting properties that can be applied to MText content,
71
- * including text styling, colors, alignment, font properties, and paragraph formatting.
72
- */
73
- export interface Properties {
74
- /** Whether text is underlined */
75
- underline?: boolean;
76
- /** Whether text has an overline */
77
- overline?: boolean;
78
- /** Whether text has strike-through */
79
- strikeThrough?: boolean;
80
- /** AutoCAD Color Index (ACI) color value (0-256), or null if not set */
81
- aci?: number | null;
82
- /** RGB color tuple [r, g, b], or null if not set */
83
- rgb?: RGB | null;
84
- /** Line alignment for the text */
85
- align?: MTextLineAlignment;
86
- /** Font face properties including family, style, and weight */
87
- fontFace?: FontFace;
88
- /** Capital letter height factor (can be relative or absolute) */
89
- capHeight?: FactorValue;
90
- /** Character width factor (can be relative or absolute) */
91
- widthFactor?: FactorValue;
92
- /** Character tracking factor for spacing between characters (can be relative or absolute) */
93
- charTrackingFactor?: FactorValue;
94
- /** Oblique angle in degrees for text slant */
95
- oblique?: number;
96
- /** Paragraph formatting properties (partial to allow selective updates) */
97
- paragraph?: Partial<ParagraphProperties>;
98
- }
99
-
100
- /**
101
- * Represents a change in MText properties, including the command, the changed properties, and the context depth.
102
- */
103
- export interface ChangedProperties {
104
- /**
105
- * The property command that triggered the change (e.g., 'L', 'C', 'f').
106
- * The command will be undefined if it is to restore context.
107
- */
108
- command: string | undefined;
109
- /**
110
- * The set of properties that have changed as a result of the command.
111
- */
112
- changes: Properties;
113
- /**
114
- * The current context stack depth when the property change occurs.
115
- * - 0: The change is global (applies outside of any `{}` block).
116
- * - >0: The change is local (applies within one or more nested `{}` blocks).
117
- */
118
- depth: number; // 0 = global, >0 = local
119
- }
120
-
121
- /**
122
- * Type for token data based on token type
123
- */
124
- export type TokenData = {
125
- [TokenType.NONE]: null;
126
- [TokenType.WORD]: string;
127
- [TokenType.STACK]: [string, string, string];
128
- [TokenType.SPACE]: null;
129
- [TokenType.NBSP]: null;
130
- [TokenType.TABULATOR]: null;
131
- [TokenType.NEW_PARAGRAPH]: null;
132
- [TokenType.NEW_COLUMN]: null;
133
- [TokenType.WRAP_AT_DIMLINE]: null;
134
- [TokenType.PROPERTIES_CHANGED]: ChangedProperties;
135
- [TokenType.PERCENT_SYMBOL]: PercentSymbolData;
136
- };
137
-
138
- /**
139
- * Line alignment options for MText
140
- */
141
- export enum MTextLineAlignment {
142
- /** Align text to bottom */
143
- BOTTOM = 0,
144
- /** Align text to middle */
145
- MIDDLE = 1,
146
- /** Align text to top */
147
- TOP = 2,
148
- }
149
-
150
- /**
151
- * Paragraph alignment options for MText
152
- */
153
- export enum MTextParagraphAlignment {
154
- /** Default alignment */
155
- DEFAULT = 0,
156
- /** Left alignment */
157
- LEFT = 1,
158
- /** Right alignment */
159
- RIGHT = 2,
160
- /** Center alignment */
161
- CENTER = 3,
162
- /** Justified alignment */
163
- JUSTIFIED = 4,
164
- /** Distributed alignment */
165
- DISTRIBUTED = 5,
166
- }
167
-
168
- /**
169
- * Text stroke options for MText
170
- */
171
- export enum MTextStroke {
172
- /** No stroke */
173
- NONE = 0,
174
- /** Underline stroke */
175
- UNDERLINE = 1,
176
- /** Overline stroke */
177
- OVERLINE = 2,
178
- /** Strike-through stroke */
179
- STRIKE_THROUGH = 4,
180
- }
181
-
182
- /**
183
- * RGB color tuple
184
- */
185
- export type RGB = [number, number, number];
186
-
187
- /**
188
- * Font style type
189
- */
190
- export type FontStyle = 'Regular' | 'Italic';
191
-
192
- /**
193
- * Font face properties
194
- */
195
- export interface FontFace {
196
- /** Font family name */
197
- family: string;
198
- /** Font style (e.g., 'Regular', 'Italic') */
199
- style: FontStyle;
200
- /** Font weight (e.g., 400 for normal, 700 for bold) */
201
- weight: number;
202
- }
203
-
204
- /**
205
- * Paragraph properties
206
- */
207
- export interface ParagraphProperties {
208
- /** Indentation value */
209
- indent: number;
210
- /** Left margin value */
211
- left: number;
212
- /** Right margin value */
213
- right: number;
214
- /** Paragraph alignment */
215
- align: MTextParagraphAlignment;
216
- /** Tab stop positions and types */
217
- tabs: (number | string)[];
218
- }
219
-
220
- /**
221
- * Special character encoding mapping
222
- */
223
- const SPECIAL_CHAR_ENCODING: Record<string, string> = {
224
- c: 'Ø',
225
- d: '°',
226
- p: '±',
227
- '%': '%',
228
- };
229
-
230
- /**
231
- * Character to paragraph alignment mapping
232
- */
233
- const CHAR_TO_ALIGN: Record<string, MTextParagraphAlignment> = {
234
- l: MTextParagraphAlignment.LEFT,
235
- r: MTextParagraphAlignment.RIGHT,
236
- c: MTextParagraphAlignment.CENTER,
237
- j: MTextParagraphAlignment.JUSTIFIED,
238
- d: MTextParagraphAlignment.DISTRIBUTED,
239
- };
240
-
241
- /**
242
- * Convert RGB tuple to integer color value
243
- * @param rgb - RGB color tuple
244
- * @returns Integer color value
245
- */
246
- export function rgb2int(rgb: RGB): number {
247
- const [r, g, b] = rgb;
248
- return (r << 16) | (g << 8) | b;
249
- }
250
-
251
- /**
252
- * Convert integer color value to RGB tuple
253
- * @param value - Integer color value
254
- * @returns RGB color tuple
255
- */
256
- export function int2rgb(value: number): RGB {
257
- const r = (value >> 16) & 0xff;
258
- const g = (value >> 8) & 0xff;
259
- const b = value & 0xff;
260
- return [r, g, b];
261
- }
262
-
263
- function clampColorChannel(value: number): number {
264
- return Math.max(0, Math.min(255, Math.round(value)));
265
- }
266
-
267
- function normalizeColorNumber(color: number): number {
268
- return Math.max(0, Math.min(0xffffff, Math.round(color)));
269
- }
270
-
271
- function colorNumberToHex(color: number | null): string | null {
272
- if (color === null) return null;
273
- return `#${normalizeColorNumber(color).toString(16).padStart(6, '0')}`;
274
- }
275
-
276
- function normalizeHexColor(value: string | null | undefined): string | null {
277
- if (!value) return null;
278
- const normalized = value.trim().toLowerCase();
279
- if (/^#[0-9a-f]{6}$/.test(normalized)) return normalized;
280
- if (/^[0-9a-f]{6}$/.test(normalized)) return `#${normalized}`;
281
- if (/^#[0-9a-f]{3}$/.test(normalized)) {
282
- const r = normalized[1];
283
- const g = normalized[2];
284
- const b = normalized[3];
285
- return `#${r}${r}${g}${g}${b}${b}`;
286
- }
287
- if (/^[0-9a-f]{3}$/.test(normalized)) {
288
- const r = normalized[0];
289
- const g = normalized[1];
290
- const b = normalized[2];
291
- return `#${r}${r}${g}${g}${b}${b}`;
292
- }
293
- return null;
294
- }
295
-
296
- function cssColorToRgbValue(value: string | null | undefined): number | null {
297
- if (!value) return null;
298
- const raw = value.trim().toLowerCase();
299
- if (raw === 'transparent') return null;
300
-
301
- const hex = normalizeHexColor(raw);
302
- if (hex) {
303
- return normalizeColorNumber(Number.parseInt(hex.slice(1), 16));
304
- }
305
-
306
- const fnMatch = raw.match(/^rgba?\((.*)\)$/);
307
- if (!fnMatch) return null;
308
-
309
- const parts = fnMatch[1]
310
- .replace(/\s*\/\s*/g, ' ')
311
- .split(/[,\s]+/)
312
- .map(p => p.trim())
313
- .filter(Boolean);
314
-
315
- if (parts.length < 3) return null;
316
-
317
- const toChannel = (token: string): number => {
318
- if (token.endsWith('%')) {
319
- const percent = Number.parseFloat(token.slice(0, -1));
320
- return clampColorChannel((percent / 100) * 255);
321
- }
322
- const num = Number.parseFloat(token);
323
- return clampColorChannel(num);
324
- };
325
-
326
- const r = toChannel(parts[0]);
327
- const g = toChannel(parts[1]);
328
- const b = toChannel(parts[2]);
329
- return rgb2int([r, g, b]);
330
- }
331
-
332
- /**
333
- * Escape DXF line endings
334
- * @param text - Text to escape
335
- * @returns Escaped text
336
- */
337
- export function escapeDxfLineEndings(text: string): string {
338
- return text.replace(/\r\n|\r|\n/g, '\\P');
339
- }
340
-
341
- /**
342
- * Check if text contains inline formatting codes
343
- * @param text - Text to check
344
- * @returns True if text contains formatting codes
345
- */
346
- export function hasInlineFormattingCodes(text: string): boolean {
347
- return text.replace(/\\P/g, '').replace(/\\~/g, '').includes('\\');
348
- }
349
-
350
- /**
351
- * Extracts all unique font names used in an MText string.
352
- * This function searches for font commands in the format \f{fontname}| or \f{fontname}; and returns a set of unique font names.
353
- * Font names are converted to lowercase to ensure case-insensitive uniqueness.
354
- *
355
- * @param mtext - The MText string to analyze for font names
356
- * @param removeExtension - Whether to remove font file extensions (e.g., .ttf, .shx) from font names. Defaults to false.
357
- * @returns A Set containing all unique font names found in the MText string, converted to lowercase
358
- * @example
359
- * ```ts
360
- * const mtext = "\\fArial.ttf|Hello\\fTimes New Roman.otf|World";
361
- * const fonts = getFonts(mtext, true);
362
- * // Returns: Set(2) { "arial", "times new roman" }
363
- * ```
364
- */
365
- export function getFonts(mtext: string, removeExtension: boolean = false) {
366
- const fonts: Set<string> = new Set();
367
- const regex = /\\[fF](.*?)[;|]/g;
368
-
369
- [...mtext.matchAll(regex)].forEach(match => {
370
- let fontName = match[1].toLowerCase();
371
- if (removeExtension) {
372
- fontName = fontName.replace(/\.(ttf|otf|woff|shx)$/, '');
373
- }
374
- fonts.add(fontName);
375
- });
376
-
377
- return fonts;
378
- }
379
-
380
- /**
381
- * ContextStack manages a stack of MTextContext objects for character-level formatting.
382
- *
383
- * - Character-level formatting (underline, color, font, etc.) is scoped to `{}` blocks and managed by the stack.
384
- * - Paragraph-level formatting (\p) is not scoped, but when a block ends, any paragraph property changes are merged into the parent context.
385
- * - On pop, paragraph properties from the popped context are always merged into the new top context.
386
- */
387
- class ContextStack {
388
- private stack: MTextContext[] = [];
389
-
390
- /**
391
- * Creates a new ContextStack with an initial context.
392
- * @param initial The initial MTextContext to use as the base of the stack.
393
- */
394
- constructor(initial: MTextContext) {
395
- this.stack.push(initial);
396
- }
397
-
398
- /**
399
- * Pushes a copy of the given context onto the stack.
400
- * @param ctx The MTextContext to push (copied).
401
- */
402
- push(ctx: MTextContext) {
403
- this.stack.push(ctx);
404
- }
405
-
406
- /**
407
- * Pops the top context from the stack and merges its paragraph properties into the new top context.
408
- * If only one context remains, nothing is popped.
409
- * @returns The popped MTextContext, or undefined if the stack has only one context.
410
- */
411
- pop(): MTextContext | undefined {
412
- if (this.stack.length <= 1) return undefined;
413
- const popped = this.stack.pop()!;
414
- // Merge paragraph properties into the new top context
415
- const top = this.stack[this.stack.length - 1];
416
- if (JSON.stringify(top.paragraph) !== JSON.stringify(popped.paragraph)) {
417
- top.paragraph = { ...popped.paragraph };
418
- }
419
- return popped;
420
- }
421
-
422
- /**
423
- * Returns the current (top) context on the stack.
424
- */
425
- get current(): MTextContext {
426
- return this.stack[this.stack.length - 1];
427
- }
428
-
429
- /**
430
- * Returns the current stack depth (number of nested blocks), not counting the root context.
431
- */
432
- get depth(): number {
433
- return this.stack.length - 1;
434
- }
435
-
436
- /**
437
- * Returns the root (bottom) context, which represents the global formatting state.
438
- * Used for paragraph property application.
439
- */
440
- get root(): MTextContext {
441
- return this.stack[0];
442
- }
443
-
444
- /**
445
- * Replaces the current (top) context with the given context.
446
- * @param ctx The new context to set as the current context.
447
- */
448
- setCurrent(ctx: MTextContext) {
449
- this.stack[this.stack.length - 1] = ctx;
450
- }
451
- }
452
-
453
- /**
454
- * Configuration options for the MText parser.
455
- * These options control how the parser behaves during tokenization and property handling.
456
- */
457
- export interface MTextParserOptions {
458
- /**
459
- * Whether to yield PROPERTIES_CHANGED tokens when formatting properties change.
460
- * When true, the parser will emit tokens whenever properties like color, font, or alignment change.
461
- * When false, property changes are applied silently to the context without generating tokens.
462
- * @default false
463
- */
464
- yieldPropertyCommands?: boolean;
465
- /**
466
- * Whether to reset paragraph parameters when encountering a new paragraph token.
467
- * When true, paragraph properties (indent, margins, alignment, tab stops) are reset to defaults
468
- * at the start of each new paragraph.
469
- * @default false
470
- */
471
- resetParagraphParameters?: boolean;
472
- /**
473
- * Whether to emit {@link TokenType.PERCENT_SYMBOL} tokens for AutoCAD `%%` symbol
474
- * codes instead of expanding them into {@link TokenType.WORD} characters.
475
- * @default false
476
- */
477
- yieldPercentSymbols?: boolean;
478
- /**
479
- * Custom decoder function for MIF (Multibyte Interchange Format) codes.
480
- * If provided, this function will be used instead of the default decodeMultiByteChar.
481
- * The function receives the hex code string and should return the decoded character.
482
- * @param hex - Hex code string (e.g., "C4E3" or "1A2B3")
483
- * @returns Decoded character or empty square (▯) if invalid
484
- * @default undefined (uses default decoder)
485
- */
486
- mifDecoder?: (hex: string) => string;
487
- /**
488
- * The length of MIF hex codes to parse. MIF codes in AutoCAD can vary in length
489
- * depending on the specific SHX big font used (typically 4 or 5 digits).
490
- * If not specified, the parser will try to auto-detect the length by attempting
491
- * to match 4 digits first, then 5 digits if needed.
492
- * @default undefined (auto-detect)
493
- */
494
- mifCodeLength?: 4 | 5 | 'auto';
495
- }
496
-
497
- /**
498
- * Main parser class for MText content
499
- */
500
- export class MTextParser {
501
- private scanner: TextScanner;
502
- private ctxStack: ContextStack;
503
- private continueStroke: boolean = false;
504
- private yieldPropertyCommands: boolean;
505
- private resetParagraphParameters: boolean;
506
- private yieldPercentSymbols: boolean;
507
- private inStackContext: boolean = false;
508
- private mifDecoder: (hex: string) => string;
509
- private mifCodeLength: 4 | 5 | 'auto';
510
-
511
- /**
512
- * Creates a new MTextParser instance
513
- * @param content - The MText content to parse
514
- * @param ctx - Optional initial MText context
515
- * @param options - Parser options
516
- */
517
- constructor(content: string, ctx?: MTextContext, options: MTextParserOptions = {}) {
518
- this.scanner = new TextScanner(content);
519
- const initialCtx = ctx ?? new MTextContext();
520
- this.ctxStack = new ContextStack(initialCtx);
521
- this.yieldPropertyCommands = options.yieldPropertyCommands ?? false;
522
- this.resetParagraphParameters = options.resetParagraphParameters ?? false;
523
- this.yieldPercentSymbols = options.yieldPercentSymbols ?? false;
524
- this.mifDecoder = options.mifDecoder ?? this.decodeMultiByteChar.bind(this);
525
- this.mifCodeLength = options.mifCodeLength ?? 'auto';
526
- }
527
-
528
- /**
529
- * Decode multi-byte character from hex code
530
- * @param hex - Hex code string (e.g. "C4E3" or "1A2B3")
531
- * @returns Decoded character or empty square if invalid
532
- */
533
- private decodeMultiByteChar(hex: string): string {
534
- try {
535
- // For 5-digit codes, return placeholder directly
536
- if (hex.length === 5) {
537
- const prefix = hex[0];
538
-
539
- // Notes:
540
- // I know AutoCAD uses prefix 1 for Shift-JIS, 2 for big5, and 5 for gbk.
541
- // But I don't know whether there are other prefixes and their meanings.
542
- let encoding = 'gbk';
543
- if (prefix === '1') {
544
- encoding = 'shift-jis';
545
- } else if (prefix === '2') {
546
- encoding = 'big5';
547
- }
548
- const bytes = new Uint8Array([
549
- parseInt(hex.substr(1, 2), 16),
550
- parseInt(hex.substr(3, 2), 16),
551
- ]);
552
- const decoder = new TextDecoder(encoding);
553
- const result = decoder.decode(bytes);
554
- return result;
555
- } else if (hex.length === 4) {
556
- // For 4-digit hex codes, decode as 2-byte character
557
- const bytes = new Uint8Array([
558
- parseInt(hex.substr(0, 2), 16),
559
- parseInt(hex.substr(2, 2), 16),
560
- ]);
561
-
562
- // Try GBK first
563
- const gbkDecoder = new TextDecoder('gbk');
564
- const gbkResult = gbkDecoder.decode(bytes);
565
- if (gbkResult !== '▯') {
566
- return gbkResult;
567
- }
568
-
569
- // Try BIG5 if GBK fails
570
- const big5Decoder = new TextDecoder('big5');
571
- const big5Result = big5Decoder.decode(bytes);
572
- if (big5Result !== '▯') {
573
- return big5Result;
574
- }
575
- }
576
-
577
- return '▯';
578
- } catch {
579
- return '▯';
580
- }
581
- }
582
-
583
- /**
584
- * Extract MIF hex code from scanner
585
- * @param length - The length of the hex code to extract (4 or 5), or 'auto' to detect
586
- * @returns The extracted hex code, or null if not found
587
- */
588
- private extractMifCode(length: 4 | 5 | 'auto'): string | null {
589
- if (length === 'auto') {
590
- // Try 5 digits first if available, then fall back to 4 digits
591
- const code5 = this.scanner.tail.match(/^[0-9A-Fa-f]{5}/)?.[0];
592
- if (code5) {
593
- return code5;
594
- }
595
- const code4 = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/)?.[0];
596
- if (code4) {
597
- return code4;
598
- }
599
- return null;
600
- } else {
601
- const code = this.scanner.tail.match(new RegExp(`^[0-9A-Fa-f]{${length}}`))?.[0];
602
- return code ?? null;
603
- }
604
- }
605
-
606
- /**
607
- * Parse an AutoCAD `\U+nnnn` escape after the leading `U` has been consumed.
608
- *
609
- * @remarks
610
- * Exactly four hexadecimal digits are read. A high surrogate followed by
611
- * another `\U+nnnn` low surrogate is combined into one supplementary-plane
612
- * character (AutoCAD's encoding for code points above U+FFFF).
613
- *
614
- * @returns The decoded character, or `null` if the escape is incomplete
615
- */
616
- private parseUnicodeEscape(): string | null {
617
- if (this.scanner.peek() !== '+') {
618
- return null;
619
- }
620
-
621
- this.scanner.consume(1); // Consume the '+'
622
- const hexMatch = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/);
623
- if (!hexMatch) {
624
- this.scanner.consume(-1); // Rewind '+'
625
- return null;
626
- }
627
-
628
- const codeUnit = parseInt(hexMatch[0], 16);
629
- this.scanner.consume(hexMatch[0].length);
630
-
631
- // High surrogate: combine with a following \U+ low-surrogate escape
632
- if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
633
- const pairMatch = this.scanner.tail.match(/^\\U\+([0-9A-Fa-f]{4})/);
634
- if (pairMatch) {
635
- const lowUnit = parseInt(pairMatch[1], 16);
636
- if (lowUnit >= 0xdc00 && lowUnit <= 0xdfff) {
637
- this.scanner.consume(pairMatch[0].length);
638
- const codePoint =
639
- ((codeUnit - 0xd800) << 10) + (lowUnit - 0xdc00) + 0x10000;
640
- try {
641
- return String.fromCodePoint(codePoint);
642
- } catch {
643
- return '▯';
644
- }
645
- }
646
- }
647
- }
648
-
649
- // BMP code unit, or an unpaired surrogate kept as a UTF-16 unit
650
- return String.fromCharCode(codeUnit);
651
- }
652
-
653
- /**
654
- * Push current context onto the stack
655
- */
656
- private pushCtx(): void {
657
- this.ctxStack.push(this.ctxStack.current);
658
- }
659
-
660
- /**
661
- * Pop context from the stack
662
- */
663
- private popCtx(): void {
664
- this.ctxStack.pop();
665
- }
666
-
667
- /**
668
- * Parse stacking expression (numerator/denominator)
669
- * @returns Tuple of [TokenType.STACK, [numerator, denominator, type]]
670
- */
671
- private parseStacking(): [TokenType, [string, string, string]] {
672
- const scanner = new TextScanner(this.extractExpression(true));
673
- let numerator = '';
674
- let denominator = '';
675
- let stackingType = '';
676
-
677
- const getNextChar = (): [string, boolean] => {
678
- let c = scanner.peek();
679
- let escape = false;
680
- if (c.charCodeAt(0) < 32) {
681
- c = ' ';
682
- }
683
- if (c === '\\') {
684
- escape = true;
685
- scanner.consume(1);
686
- c = scanner.peek();
687
- }
688
- scanner.consume(1);
689
- return [c, escape];
690
- };
691
-
692
- const parseNumerator = (): [string, string] => {
693
- let word = '';
694
- while (scanner.hasData) {
695
- const [c, escape] = getNextChar();
696
- // Check for stacking operators first
697
- if (!escape && (c === '/' || c === '#' || c === '^')) {
698
- return [word, c];
699
- }
700
- word += c;
701
- }
702
- return [word, ''];
703
- };
704
-
705
- const parseDenominator = (skipLeadingSpace: boolean): string => {
706
- let word = '';
707
- let skipping = skipLeadingSpace;
708
- while (scanner.hasData) {
709
- const [c, escape] = getNextChar();
710
- if (skipping && c === ' ') {
711
- continue;
712
- }
713
- skipping = false;
714
- // Stop at terminator unless escaped
715
- if (!escape && c === ';') {
716
- break;
717
- }
718
- word += c;
719
- }
720
- return word;
721
- };
722
-
723
- [numerator, stackingType] = parseNumerator();
724
- if (stackingType) {
725
- // Only skip leading space for caret divider
726
- denominator = parseDenominator(stackingType === '^');
727
- }
728
-
729
- // Special case for \S^!/^?;
730
- if (numerator === '' && denominator.includes('I/')) {
731
- return [TokenType.STACK, [' ', ' ', '/']];
732
- }
733
-
734
- // Handle caret as a stacking operator
735
- if (stackingType === '^') {
736
- return [TokenType.STACK, [numerator, denominator, '^']];
737
- }
738
-
739
- return [TokenType.STACK, [numerator, denominator, stackingType]];
740
- }
741
-
742
- /**
743
- * Parse MText properties
744
- * @param cmd - The property command to parse
745
- * @returns Property changes if yieldPropertyCommands is true and changes occurred
746
- */
747
- private parseProperties(cmd: string): TokenData[TokenType.PROPERTIES_CHANGED] | void {
748
- const prevCtx = this.ctxStack.current.copy();
749
- const newCtx = this.ctxStack.current.copy();
750
- switch (cmd) {
751
- case 'L':
752
- newCtx.underline = true;
753
- this.continueStroke = true;
754
- break;
755
- case 'l':
756
- newCtx.underline = false;
757
- if (!newCtx.hasAnyStroke) {
758
- this.continueStroke = false;
759
- }
760
- break;
761
- case 'O':
762
- newCtx.overline = true;
763
- this.continueStroke = true;
764
- break;
765
- case 'o':
766
- newCtx.overline = false;
767
- if (!newCtx.hasAnyStroke) {
768
- this.continueStroke = false;
769
- }
770
- break;
771
- case 'K':
772
- newCtx.strikeThrough = true;
773
- this.continueStroke = true;
774
- break;
775
- case 'k':
776
- newCtx.strikeThrough = false;
777
- if (!newCtx.hasAnyStroke) {
778
- this.continueStroke = false;
779
- }
780
- break;
781
- case 'A':
782
- this.parseAlign(newCtx);
783
- break;
784
- case 'C':
785
- this.parseAciColor(newCtx);
786
- break;
787
- case 'c':
788
- this.parseRgbColor(newCtx);
789
- break;
790
- case 'H':
791
- this.parseHeight(newCtx);
792
- break;
793
- case 'W':
794
- this.parseWidth(newCtx);
795
- break;
796
- case 'Q':
797
- this.parseOblique(newCtx);
798
- break;
799
- case 'T':
800
- this.parseCharTracking(newCtx);
801
- break;
802
- case 'p':
803
- this.parseParagraphProperties(newCtx);
804
- break;
805
- case 'f':
806
- case 'F':
807
- this.parseFontProperties(newCtx);
808
- break;
809
- default:
810
- throw new Error(`Unknown command: ${cmd}`);
811
- }
812
-
813
- // Update continueStroke based on current stroke state
814
- this.continueStroke = newCtx.hasAnyStroke;
815
- newCtx.continueStroke = this.continueStroke;
816
- // Use setCurrent to replace the current context
817
- this.ctxStack.setCurrent(newCtx);
818
-
819
- if (this.yieldPropertyCommands) {
820
- const changes = this.getPropertyChanges(prevCtx, newCtx);
821
- if (Object.keys(changes).length > 0) {
822
- return {
823
- command: cmd,
824
- changes,
825
- depth: this.ctxStack.depth,
826
- };
827
- }
828
- }
829
- }
830
-
831
- /**
832
- * Get property changes between two contexts
833
- * @param oldCtx - The old context
834
- * @param newCtx - The new context
835
- * @returns Object containing changed properties
836
- */
837
- private getPropertyChanges(
838
- oldCtx: MTextContext,
839
- newCtx: MTextContext
840
- ): TokenData[TokenType.PROPERTIES_CHANGED]['changes'] {
841
- const changes: TokenData[TokenType.PROPERTIES_CHANGED]['changes'] = {};
842
-
843
- if (oldCtx.underline !== newCtx.underline) {
844
- changes.underline = newCtx.underline;
845
- }
846
- if (oldCtx.overline !== newCtx.overline) {
847
- changes.overline = newCtx.overline;
848
- }
849
- if (oldCtx.strikeThrough !== newCtx.strikeThrough) {
850
- changes.strikeThrough = newCtx.strikeThrough;
851
- }
852
- if (oldCtx.color.aci !== newCtx.color.aci) {
853
- changes.aci = newCtx.color.aci;
854
- }
855
- if (oldCtx.color.rgbValue !== newCtx.color.rgbValue) {
856
- changes.rgb = newCtx.color.rgb;
857
- }
858
- if (oldCtx.align !== newCtx.align) {
859
- changes.align = newCtx.align;
860
- }
861
- if (JSON.stringify(oldCtx.fontFace) !== JSON.stringify(newCtx.fontFace)) {
862
- changes.fontFace = newCtx.fontFace;
863
- }
864
- if (
865
- oldCtx.capHeight.value !== newCtx.capHeight.value ||
866
- oldCtx.capHeight.isRelative !== newCtx.capHeight.isRelative
867
- ) {
868
- changes.capHeight = newCtx.capHeight;
869
- }
870
- if (
871
- oldCtx.widthFactor.value !== newCtx.widthFactor.value ||
872
- oldCtx.widthFactor.isRelative !== newCtx.widthFactor.isRelative
873
- ) {
874
- changes.widthFactor = newCtx.widthFactor;
875
- }
876
- if (
877
- oldCtx.charTrackingFactor.value !== newCtx.charTrackingFactor.value ||
878
- oldCtx.charTrackingFactor.isRelative !== newCtx.charTrackingFactor.isRelative
879
- ) {
880
- changes.charTrackingFactor = newCtx.charTrackingFactor;
881
- }
882
- if (oldCtx.oblique !== newCtx.oblique) {
883
- changes.oblique = newCtx.oblique;
884
- }
885
- if (JSON.stringify(oldCtx.paragraph) !== JSON.stringify(newCtx.paragraph)) {
886
- // Only include changed paragraph properties
887
- const changedProps: Partial<ParagraphProperties> = {};
888
- if (oldCtx.paragraph.indent !== newCtx.paragraph.indent) {
889
- changedProps.indent = newCtx.paragraph.indent;
890
- }
891
- if (oldCtx.paragraph.align !== newCtx.paragraph.align) {
892
- changedProps.align = newCtx.paragraph.align;
893
- }
894
- if (oldCtx.paragraph.left !== newCtx.paragraph.left) {
895
- changedProps.left = newCtx.paragraph.left;
896
- }
897
- if (oldCtx.paragraph.right !== newCtx.paragraph.right) {
898
- changedProps.right = newCtx.paragraph.right;
899
- }
900
- if (JSON.stringify(oldCtx.paragraph.tabs) !== JSON.stringify(newCtx.paragraph.tabs)) {
901
- changedProps.tabs = newCtx.paragraph.tabs;
902
- }
903
- if (Object.keys(changedProps).length > 0) {
904
- changes.paragraph = changedProps;
905
- }
906
- }
907
-
908
- return changes;
909
- }
910
-
911
- /**
912
- * Parse alignment property
913
- * @param ctx - The context to update
914
- */
915
- private parseAlign(ctx: MTextContext): void {
916
- const char = this.scanner.get();
917
- if ('012'.includes(char)) {
918
- ctx.align = parseInt(char) as MTextLineAlignment;
919
- } else {
920
- ctx.align = MTextLineAlignment.BOTTOM;
921
- }
922
- this.consumeOptionalTerminator();
923
- }
924
-
925
- /**
926
- * Parse height property
927
- * @param ctx - The context to update
928
- */
929
- private parseHeight(ctx: MTextContext): void {
930
- const expr = this.extractFloatExpression(true);
931
- if (expr) {
932
- try {
933
- if (expr.endsWith('x')) {
934
- // For height command, treat x suffix as relative value
935
- ctx.capHeight = {
936
- value: parseFloat(expr.slice(0, -1)),
937
- isRelative: true,
938
- };
939
- } else {
940
- ctx.capHeight = {
941
- value: parseFloat(expr),
942
- isRelative: false,
943
- };
944
- }
945
- } catch {
946
- // If parsing fails, treat the entire command as literal text
947
- this.scanner.consume(-expr.length); // Rewind to before the expression
948
- return;
949
- }
950
- }
951
- this.consumeOptionalTerminator();
952
- }
953
-
954
- /**
955
- * Parse width property
956
- * @param ctx - The context to update
957
- */
958
- private parseWidth(ctx: MTextContext): void {
959
- const expr = this.extractFloatExpression(true);
960
- if (expr) {
961
- try {
962
- if (expr.endsWith('x')) {
963
- // For width command, treat x suffix as relative value
964
- ctx.widthFactor = {
965
- value: parseFloat(expr.slice(0, -1)),
966
- isRelative: true,
967
- };
968
- } else {
969
- ctx.widthFactor = {
970
- value: parseFloat(expr),
971
- isRelative: false,
972
- };
973
- }
974
- } catch {
975
- // If parsing fails, treat the entire command as literal text
976
- this.scanner.consume(-expr.length); // Rewind to before the expression
977
- return;
978
- }
979
- }
980
- this.consumeOptionalTerminator();
981
- }
982
-
983
- /**
984
- * Parse character tracking property
985
- * @param ctx - The context to update
986
- */
987
- private parseCharTracking(ctx: MTextContext): void {
988
- const expr = this.extractFloatExpression(true);
989
- if (expr) {
990
- try {
991
- if (expr.endsWith('x')) {
992
- // For tracking command, treat x suffix as relative value
993
- ctx.charTrackingFactor = {
994
- value: Math.abs(parseFloat(expr.slice(0, -1))),
995
- isRelative: true,
996
- };
997
- } else {
998
- ctx.charTrackingFactor = {
999
- value: Math.abs(parseFloat(expr)),
1000
- isRelative: false,
1001
- };
1002
- }
1003
- } catch {
1004
- // If parsing fails, treat the entire command as literal text
1005
- this.scanner.consume(-expr.length); // Rewind to before the expression
1006
- return;
1007
- }
1008
- }
1009
- this.consumeOptionalTerminator();
1010
- }
1011
-
1012
- /**
1013
- * Parse float value or factor
1014
- * @param value - Current value to apply factor to
1015
- * @returns New value
1016
- */
1017
- private parseFloatValueOrFactor(value: number): number {
1018
- const expr = this.extractFloatExpression(true);
1019
- if (expr) {
1020
- if (expr.endsWith('x')) {
1021
- const factor = parseFloat(expr.slice(0, -1));
1022
- value *= factor; // Allow negative factors
1023
- } else {
1024
- value = parseFloat(expr); // Allow negative values
1025
- }
1026
- }
1027
- return value;
1028
- }
1029
-
1030
- /**
1031
- * Parse oblique angle property
1032
- * @param ctx - The context to update
1033
- */
1034
- private parseOblique(ctx: MTextContext): void {
1035
- const obliqueExpr = this.extractFloatExpression(false);
1036
- if (obliqueExpr) {
1037
- ctx.oblique = parseFloat(obliqueExpr);
1038
- }
1039
- this.consumeOptionalTerminator();
1040
- }
1041
-
1042
- /**
1043
- * Parse ACI color property
1044
- * @param ctx - The context to update
1045
- */
1046
- private parseAciColor(ctx: MTextContext): void {
1047
- const aciExpr = this.extractIntExpression();
1048
- if (aciExpr) {
1049
- const aci = parseInt(aciExpr);
1050
- if (aci < 257) {
1051
- ctx.color.aci = aci;
1052
- }
1053
- }
1054
- this.consumeOptionalTerminator();
1055
- }
1056
-
1057
- /**
1058
- * Parse RGB color property
1059
- * @param ctx - The context to update
1060
- */
1061
- private parseRgbColor(ctx: MTextContext): void {
1062
- const rgbExpr = this.extractIntExpression();
1063
- if (rgbExpr) {
1064
- const value = parseInt(rgbExpr) & 0xffffff;
1065
- ctx.color.rgbValue = value;
1066
- }
1067
- this.consumeOptionalTerminator();
1068
- }
1069
-
1070
- /**
1071
- * Extract float expression from scanner
1072
- * @param relative - Whether to allow relative values (ending in 'x')
1073
- * @returns Extracted expression
1074
- */
1075
- private extractFloatExpression(relative: boolean = false): string {
1076
- const pattern = relative
1077
- ? /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?x?/
1078
- : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
1079
- const match = this.scanner.tail.match(pattern);
1080
- if (match) {
1081
- const result = match[0];
1082
- this.scanner.consume(result.length);
1083
- return result;
1084
- }
1085
- return '';
1086
- }
1087
-
1088
- /**
1089
- * Extract integer expression from scanner
1090
- * @returns Extracted expression
1091
- */
1092
- private extractIntExpression(): string {
1093
- const match = this.scanner.tail.match(/^\d+/);
1094
- if (match) {
1095
- const result = match[0];
1096
- this.scanner.consume(result.length);
1097
- return result;
1098
- }
1099
- return '';
1100
- }
1101
-
1102
- /**
1103
- * Extract expression until semicolon or end
1104
- * @param escape - Whether to handle escaped semicolons
1105
- * @returns Extracted expression
1106
- */
1107
- private extractExpression(escape: boolean = false): string {
1108
- const stop = this.scanner.find(';', escape);
1109
- if (stop < 0) {
1110
- const expr = this.scanner.tail;
1111
- this.scanner.consume(expr.length);
1112
- return expr;
1113
- }
1114
- // Check if the semicolon is escaped by looking at the previous character
1115
- const prevChar = this.scanner.peek(stop - this.scanner.currentIndex - 1);
1116
- const isEscaped = prevChar === '\\';
1117
- const expr = this.scanner.tail.slice(0, stop - this.scanner.currentIndex + (isEscaped ? 1 : 0));
1118
- this.scanner.consume(expr.length + 1);
1119
- return expr;
1120
- }
1121
-
1122
- /**
1123
- * Parse font properties
1124
- * @param ctx - The context to update
1125
- */
1126
- private parseFontProperties(ctx: MTextContext): void {
1127
- const parts = this.extractExpression().split('|');
1128
- if (parts.length > 0 && parts[0]) {
1129
- const name = parts[0];
1130
- let style: FontStyle = 'Regular';
1131
- let weight = 400;
1132
-
1133
- for (const part of parts.slice(1)) {
1134
- if (part.startsWith('b1')) {
1135
- weight = 700;
1136
- } else if (part === 'i' || part.startsWith('i1')) {
1137
- style = 'Italic';
1138
- } else if (part === 'i0' || part.startsWith('i0')) {
1139
- style = 'Regular';
1140
- }
1141
- }
1142
-
1143
- ctx.fontFace = {
1144
- family: name,
1145
- style,
1146
- weight,
1147
- };
1148
- }
1149
- }
1150
-
1151
- /**
1152
- * Parse paragraph properties from the MText content
1153
- * Handles properties like indentation, alignment, and tab stops
1154
- * @param ctx - The context to update
1155
- */
1156
- private parseParagraphProperties(ctx: MTextContext): void {
1157
- const scanner = new TextScanner(this.extractExpression());
1158
- /** Current indentation value */
1159
- let indent = ctx.paragraph.indent;
1160
- /** Left margin value */
1161
- let left = ctx.paragraph.left;
1162
- /** Right margin value */
1163
- let right = ctx.paragraph.right;
1164
- /** Current paragraph alignment */
1165
- let align = ctx.paragraph.align;
1166
- /** Array of tab stop positions and types */
1167
- let tabStops: (number | string)[] = [];
1168
-
1169
- /**
1170
- * Parse a floating point number from the scanner's current position
1171
- * Handles optional sign, decimal point, and scientific notation
1172
- * @returns The parsed float value, or 0 if no valid number is found
1173
- */
1174
- const parseFloatValue = (): number => {
1175
- const match = scanner.tail.match(/^[+-]?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/);
1176
- if (match) {
1177
- const value = parseFloat(match[0]);
1178
- scanner.consume(match[0].length);
1179
- while (scanner.peek() === ',') {
1180
- scanner.consume(1);
1181
- }
1182
- return value;
1183
- }
1184
- return 0;
1185
- };
1186
-
1187
- while (scanner.hasData) {
1188
- const cmd = scanner.get();
1189
- switch (cmd) {
1190
- case 'i': // Indentation
1191
- indent = parseFloatValue();
1192
- break;
1193
- case 'l': // Left margin
1194
- left = parseFloatValue();
1195
- break;
1196
- case 'r': // Right margin
1197
- right = parseFloatValue();
1198
- break;
1199
- case 'x': // Skip
1200
- break;
1201
- case 'q': {
1202
- // Alignment
1203
- const adjustment = scanner.get();
1204
- align = CHAR_TO_ALIGN[adjustment] || MTextParagraphAlignment.DEFAULT;
1205
- while (scanner.peek() === ',') {
1206
- scanner.consume(1);
1207
- }
1208
- break;
1209
- }
1210
- case 't': // Tab stops
1211
- tabStops = [];
1212
- while (scanner.hasData) {
1213
- const type = scanner.peek();
1214
- if (type === 'r' || type === 'c') {
1215
- scanner.consume(1);
1216
- const value = parseFloatValue();
1217
- tabStops.push(type + value.toString());
1218
- } else {
1219
- const value = parseFloatValue();
1220
- if (!isNaN(value)) {
1221
- tabStops.push(value);
1222
- } else {
1223
- scanner.consume(1);
1224
- }
1225
- }
1226
- }
1227
- break;
1228
- }
1229
- }
1230
-
1231
- ctx.paragraph = {
1232
- indent,
1233
- left,
1234
- right,
1235
- align,
1236
- tabs: tabStops,
1237
- };
1238
- }
1239
-
1240
- /**
1241
- * Consume optional terminator (semicolon)
1242
- */
1243
- private consumeOptionalTerminator(): void {
1244
- if (this.scanner.peek() === ';') {
1245
- this.scanner.consume(1);
1246
- }
1247
- }
1248
-
1249
- /**
1250
- * Builds {@link PercentSymbolData} for a recognized `%%` code letter.
1251
- */
1252
- private buildPercentSymbolData(
1253
- code: string,
1254
- specialChar: string
1255
- ): PercentSymbolData | null {
1256
- if (code === 'c' || code === 'd' || code === 'p') {
1257
- return { kind: 'named', code, char: specialChar };
1258
- }
1259
- if (code === '%') {
1260
- return { kind: 'literal', char: '%' };
1261
- }
1262
- return null;
1263
- }
1264
-
1265
- /**
1266
- * Parse MText content into tokens
1267
- * @yields MTextToken objects
1268
- */
1269
- *parse(): Generator<MTextToken> {
1270
- const wordToken = TokenType.WORD;
1271
- const spaceToken = TokenType.SPACE;
1272
- let followupToken: TokenType | null = null;
1273
- let followupData: TokenData[TokenType] | undefined;
1274
-
1275
- function resetParagraph(ctx: MTextContext): Partial<ParagraphProperties> {
1276
- const prev = { ...ctx.paragraph };
1277
- ctx.paragraph = {
1278
- indent: 0,
1279
- left: 0,
1280
- right: 0,
1281
- align: MTextParagraphAlignment.DEFAULT,
1282
- tabs: [],
1283
- };
1284
- const changed: Partial<ParagraphProperties> = {};
1285
- if (prev.indent !== 0) changed.indent = 0;
1286
- if (prev.left !== 0) changed.left = 0;
1287
- if (prev.right !== 0) changed.right = 0;
1288
- if (prev.align !== MTextParagraphAlignment.DEFAULT)
1289
- changed.align = MTextParagraphAlignment.DEFAULT;
1290
- if (JSON.stringify(prev.tabs) !== JSON.stringify([])) changed.tabs = [];
1291
- return changed;
1292
- }
1293
-
1294
- const nextToken = (): [TokenType, TokenData[TokenType]] => {
1295
- let word = '';
1296
- while (this.scanner.hasData) {
1297
- let escape = false;
1298
- let letter = this.scanner.peek();
1299
- const cmdStartIndex = this.scanner.currentIndex;
1300
-
1301
- // Handle control characters first
1302
- if (letter.charCodeAt(0) < 32) {
1303
- this.scanner.consume(1); // Always consume the control character
1304
- if (letter === '\t') {
1305
- return [TokenType.TABULATOR, null];
1306
- }
1307
- if (letter === '\n') {
1308
- return [TokenType.NEW_PARAGRAPH, null];
1309
- }
1310
- letter = ' ';
1311
- }
1312
-
1313
- if (letter === '\\') {
1314
- if ('\\{}'.includes(this.scanner.peek(1))) {
1315
- escape = true;
1316
- this.scanner.consume(1);
1317
- letter = this.scanner.peek();
1318
- } else {
1319
- if (word) {
1320
- return [wordToken, word];
1321
- }
1322
- this.scanner.consume(1);
1323
- const cmd = this.scanner.get();
1324
- switch (cmd) {
1325
- case '~':
1326
- return [TokenType.NBSP, null];
1327
- case 'P':
1328
- return [TokenType.NEW_PARAGRAPH, null];
1329
- case 'N':
1330
- return [TokenType.NEW_COLUMN, null];
1331
- case 'X':
1332
- return [TokenType.WRAP_AT_DIMLINE, null];
1333
- case 'S': {
1334
- this.inStackContext = true;
1335
- const result = this.parseStacking();
1336
- this.inStackContext = false;
1337
- return result;
1338
- }
1339
- case 'm':
1340
- case 'M':
1341
- // Handle multi-byte character encoding (MIF)
1342
- if (this.scanner.peek() === '+') {
1343
- this.scanner.consume(1); // Consume the '+'
1344
- const hexCode = this.extractMifCode(this.mifCodeLength);
1345
- if (hexCode) {
1346
- this.scanner.consume(hexCode.length);
1347
- const decodedChar = this.mifDecoder(hexCode);
1348
- if (word) {
1349
- return [wordToken, word];
1350
- }
1351
- return [wordToken, decodedChar];
1352
- }
1353
- // If no valid hex code found, rewind the '+' character
1354
- this.scanner.consume(-1);
1355
- }
1356
- // If not a valid multi-byte code, treat as literal text
1357
- word += '\\M';
1358
- continue;
1359
- case 'U': {
1360
- // AutoCAD \U+nnnn uses exactly four hex digits. Characters
1361
- // outside the BMP are encoded as two consecutive \U+XXXX
1362
- // UTF-16 surrogate escapes.
1363
- const decodedChar = this.parseUnicodeEscape();
1364
- if (decodedChar !== null) {
1365
- if (word) {
1366
- return [wordToken, word];
1367
- }
1368
- return [wordToken, decodedChar];
1369
- }
1370
- // If not a valid Unicode code, treat as literal text
1371
- word += '\\U';
1372
- continue;
1373
- }
1374
- default:
1375
- if (cmd) {
1376
- try {
1377
- const propertyChanges = this.parseProperties(cmd);
1378
- if (this.yieldPropertyCommands && propertyChanges) {
1379
- return [TokenType.PROPERTIES_CHANGED, propertyChanges];
1380
- }
1381
- // After processing a property command, continue with normal parsing
1382
- continue;
1383
- } catch {
1384
- const commandText = this.scanner.tail.slice(
1385
- cmdStartIndex,
1386
- this.scanner.currentIndex
1387
- );
1388
- word += commandText;
1389
- }
1390
- }
1391
- }
1392
- continue;
1393
- }
1394
- }
1395
-
1396
- if (letter === '%' && this.scanner.peek(1) === '%') {
1397
- const code = this.scanner.peek(2).toLowerCase();
1398
- const specialChar = SPECIAL_CHAR_ENCODING[code];
1399
- if (specialChar) {
1400
- this.scanner.consume(3);
1401
- if (this.yieldPercentSymbols) {
1402
- const symbolData = this.buildPercentSymbolData(code, specialChar);
1403
- if (symbolData) {
1404
- if (word) {
1405
- followupToken = TokenType.PERCENT_SYMBOL;
1406
- followupData = symbolData;
1407
- return [wordToken, word];
1408
- }
1409
- return [TokenType.PERCENT_SYMBOL, symbolData];
1410
- }
1411
- }
1412
- word += specialChar;
1413
- continue;
1414
- } else {
1415
- /**
1416
- * Supports Control Codes: `%%nnn`, where nnn is 1–3 decimal digits
1417
- * for the character's Unicode/ASCII code point (e.g. `%%34` → `"`,
1418
- * `%%176` → `°`). Digit count is not fixed at three.
1419
- *
1420
- * Reference: https://help.autodesk.com/view/ACD/2026/ENU/?guid=GUID-968CBC1D-BA99-4519-ABDD-88419EB2BF92
1421
- */
1422
- const digitChars: string[] = [];
1423
- for (let i = 0; i < 3; i++) {
1424
- const d = this.scanner.peek(2 + i);
1425
- if (d >= '0' && d <= '9') {
1426
- digitChars.push(d);
1427
- } else {
1428
- break;
1429
- }
1430
- }
1431
-
1432
- if (digitChars.length > 0) {
1433
- const charCode = Number.parseInt(digitChars.join(''), 10);
1434
- this.scanner.consume(2 + digitChars.length);
1435
- if (this.yieldPercentSymbols) {
1436
- const symbolData: PercentSymbolData = {
1437
- kind: 'numeric',
1438
- charCode,
1439
- char: String.fromCharCode(charCode),
1440
- };
1441
- if (word) {
1442
- followupToken = TokenType.PERCENT_SYMBOL;
1443
- followupData = symbolData;
1444
- return [wordToken, word];
1445
- }
1446
- return [TokenType.PERCENT_SYMBOL, symbolData];
1447
- }
1448
- word += String.fromCharCode(charCode);
1449
- } else {
1450
- // Skip invalid special character codes (`%%` + non-digit)
1451
- this.scanner.consume(3);
1452
- }
1453
-
1454
- continue;
1455
- }
1456
- }
1457
-
1458
- if (letter === ' ') {
1459
- if (word) {
1460
- this.scanner.consume(1);
1461
- followupToken = spaceToken;
1462
- return [wordToken, word];
1463
- }
1464
- this.scanner.consume(1);
1465
- return [spaceToken, null];
1466
- }
1467
-
1468
- if (!escape) {
1469
- if (letter === '{') {
1470
- if (word) {
1471
- return [wordToken, word];
1472
- }
1473
- this.scanner.consume(1);
1474
- this.pushCtx();
1475
- continue;
1476
- } else if (letter === '}') {
1477
- if (word) {
1478
- return [wordToken, word];
1479
- }
1480
- this.scanner.consume(1);
1481
- // Context restoration with yieldPropertyCommands
1482
- if (this.yieldPropertyCommands) {
1483
- const prevCtx = this.ctxStack.current;
1484
- this.popCtx();
1485
- const changes = this.getPropertyChanges(prevCtx, this.ctxStack.current);
1486
- if (Object.keys(changes).length > 0) {
1487
- return [
1488
- TokenType.PROPERTIES_CHANGED,
1489
- { command: undefined, changes, depth: this.ctxStack.depth },
1490
- ];
1491
- }
1492
- } else {
1493
- this.popCtx();
1494
- }
1495
- continue;
1496
- }
1497
- }
1498
-
1499
- // Handle caret-encoded characters only when not in stack context
1500
- if (!this.inStackContext && letter === '^') {
1501
- const nextChar = this.scanner.peek(1);
1502
- if (nextChar) {
1503
- const code = nextChar.charCodeAt(0);
1504
- this.scanner.consume(2); // Consume both ^ and the next character
1505
- if (code === 32) {
1506
- // Space
1507
- word += '^';
1508
- } else if (code === 73) {
1509
- // Tab
1510
- if (word) {
1511
- return [wordToken, word];
1512
- }
1513
- return [TokenType.TABULATOR, null];
1514
- } else if (code === 74) {
1515
- // Line feed
1516
- if (word) {
1517
- return [wordToken, word];
1518
- }
1519
- return [TokenType.NEW_PARAGRAPH, null];
1520
- } else if (code === 77) {
1521
- // Carriage return
1522
- // Ignore carriage return
1523
- continue;
1524
- } else {
1525
- word += '▯';
1526
- }
1527
- continue;
1528
- }
1529
- }
1530
-
1531
- this.scanner.consume(1);
1532
- if (letter.charCodeAt(0) >= 32) {
1533
- word += letter;
1534
- }
1535
- }
1536
-
1537
- if (word) {
1538
- return [wordToken, word];
1539
- }
1540
- return [TokenType.NONE, null];
1541
- };
1542
-
1543
- while (true) {
1544
- const [type, data] = nextToken.call(this);
1545
- if (type) {
1546
- yield new MTextToken(type, this.ctxStack.current.copy(), data);
1547
- if (type === TokenType.NEW_PARAGRAPH && this.resetParagraphParameters) {
1548
- // Reset paragraph properties and emit PROPERTIES_CHANGED if needed
1549
- const ctx = this.ctxStack.current;
1550
- const changed = resetParagraph(ctx);
1551
- if (this.yieldPropertyCommands && Object.keys(changed).length > 0) {
1552
- yield new MTextToken(TokenType.PROPERTIES_CHANGED, ctx.copy(), {
1553
- command: undefined,
1554
- changes: { paragraph: changed },
1555
- depth: this.ctxStack.depth,
1556
- });
1557
- }
1558
- }
1559
- if (followupToken) {
1560
- yield new MTextToken(
1561
- followupToken,
1562
- this.ctxStack.current.copy(),
1563
- followupData ?? null
1564
- );
1565
- followupToken = null;
1566
- followupData = undefined;
1567
- }
1568
- } else {
1569
- break;
1570
- }
1571
- }
1572
- }
1573
- }
1574
-
1575
- /**
1576
- * Text scanner for parsing MText content
1577
- */
1578
- export class TextScanner {
1579
- private text: string;
1580
- private textLen: number;
1581
- private _index: number;
1582
-
1583
- /**
1584
- * Create a new text scanner
1585
- * @param text - The text to scan
1586
- */
1587
- constructor(text: string) {
1588
- this.text = text;
1589
- this.textLen = text.length;
1590
- this._index = 0;
1591
- }
1592
-
1593
- /**
1594
- * Get the current index in the text
1595
- */
1596
- get currentIndex(): number {
1597
- return this._index;
1598
- }
1599
-
1600
- /**
1601
- * Check if the scanner has reached the end of the text
1602
- */
1603
- get isEmpty(): boolean {
1604
- return this._index >= this.textLen;
1605
- }
1606
-
1607
- /**
1608
- * Check if there is more text to scan
1609
- */
1610
- get hasData(): boolean {
1611
- return this._index < this.textLen;
1612
- }
1613
-
1614
- /**
1615
- * Get the next character and advance the index
1616
- * @returns The next character, or empty string if at end
1617
- */
1618
- get(): string {
1619
- if (this.isEmpty) {
1620
- return '';
1621
- }
1622
- const char = this.text[this._index];
1623
- this._index++;
1624
- return char;
1625
- }
1626
-
1627
- /**
1628
- * Advance the index by the specified count
1629
- * @param count - Number of characters to advance
1630
- */
1631
- consume(count: number = 1): void {
1632
- this._index = Math.max(0, Math.min(this._index + count, this.textLen));
1633
- }
1634
-
1635
- /**
1636
- * Look at a character without advancing the index
1637
- * @param offset - Offset from current position
1638
- * @returns The character at the offset position, or empty string if out of bounds
1639
- */
1640
- peek(offset: number = 0): string {
1641
- const index = this._index + offset;
1642
- if (index >= this.textLen || index < 0) {
1643
- return '';
1644
- }
1645
- return this.text[index];
1646
- }
1647
-
1648
- /**
1649
- * Find the next occurrence of a character
1650
- * @param char - The character to find
1651
- * @param escape - Whether to handle escaped characters
1652
- * @returns Index of the character, or -1 if not found
1653
- */
1654
- find(char: string, escape: boolean = false): number {
1655
- let index = this._index;
1656
- while (index < this.textLen) {
1657
- if (escape && this.text[index] === '\\') {
1658
- if (index + 1 < this.textLen) {
1659
- if (this.text[index + 1] === char) {
1660
- return index + 1;
1661
- }
1662
- index += 2;
1663
- continue;
1664
- }
1665
- index++;
1666
- continue;
1667
- }
1668
- if (this.text[index] === char) {
1669
- return index;
1670
- }
1671
- index++;
1672
- }
1673
- return -1;
1674
- }
1675
-
1676
- /**
1677
- * Get the remaining text from the current position
1678
- */
1679
- get tail(): string {
1680
- return this.text.slice(this._index);
1681
- }
1682
-
1683
- /**
1684
- * Check if the next character is a space
1685
- */
1686
- isNextSpace(): boolean {
1687
- return this.peek() === ' ';
1688
- }
1689
-
1690
- /**
1691
- * Consume spaces until a non-space character is found
1692
- * @returns Number of spaces consumed
1693
- */
1694
- consumeSpaces(): number {
1695
- let count = 0;
1696
- while (this.isNextSpace()) {
1697
- this.consume();
1698
- count++;
1699
- }
1700
- return count;
1701
- }
1702
- }
1703
-
1704
- /**
1705
- * Class to handle ACI and RGB color logic for MText.
1706
- *
1707
- * This class encapsulates color state for MText, supporting both AutoCAD Color Index (ACI) and RGB color.
1708
- * Only one color mode is active at a time: setting an RGB color disables ACI, and vice versa.
1709
- * RGB is stored as a single 24-bit integer (0xRRGGBB) for efficient comparison and serialization.
1710
- *
1711
- * Example usage:
1712
- * ```ts
1713
- * const color1 = new MTextColor(1); // ACI color
1714
- * const color2 = new MTextColor([255, 0, 0]); // RGB color
1715
- * const color3 = new MTextColor(); // Default (ACI=256, "by layer")
1716
- * ```
1717
- */
1718
- export class MTextColor {
1719
- /**
1720
- * The AutoCAD Color Index (ACI) value. Only used if no RGB color is set.
1721
- * @default 256 ("by layer")
1722
- */
1723
- private _aci: number | null = 256;
1724
- /**
1725
- * The RGB color value as a single 24-bit integer (0xRRGGBB), or null if not set.
1726
- * @default null
1727
- */
1728
- private _rgbValue: number | null = null; // Store as 0xRRGGBB or null
1729
-
1730
- /**
1731
- * Create a new MTextColor instance.
1732
- * @param color The initial color: number for ACI, [r,g,b] for RGB, or null/undefined for default (ACI=256).
1733
- */
1734
- constructor(color?: number | RGB | null) {
1735
- if (Array.isArray(color)) {
1736
- this.rgb = color;
1737
- } else if (typeof color === 'number') {
1738
- this.aci = color;
1739
- } else {
1740
- this.aci = 256;
1741
- }
1742
- }
1743
-
1744
- /**
1745
- * Get the current ACI color value.
1746
- * @returns The ACI color (0-256), or null if using RGB.
1747
- */
1748
- get aci(): number | null {
1749
- return this._aci;
1750
- }
1751
-
1752
- /**
1753
- * Set the ACI color value. Setting this disables any RGB color.
1754
- * @param value The ACI color (0-256), or null to unset.
1755
- * @throws Error if value is out of range.
1756
- */
1757
- set aci(value: number | null) {
1758
- if (value === null) {
1759
- this._aci = null;
1760
- } else if (value >= 0 && value <= 256) {
1761
- this._aci = value;
1762
- this._rgbValue = null;
1763
- } else {
1764
- throw new Error('ACI not in range [0, 256]');
1765
- }
1766
- }
1767
-
1768
- /**
1769
- * Get the current RGB color as a tuple [r, g, b], or null if not set.
1770
- * @returns The RGB color tuple, or null if using ACI.
1771
- */
1772
- get rgb(): RGB | null {
1773
- if (this._rgbValue === null) return null;
1774
- // Extract R, G, B from 0xRRGGBB
1775
- const r = (this._rgbValue >> 16) & 0xff;
1776
- const g = (this._rgbValue >> 8) & 0xff;
1777
- const b = this._rgbValue & 0xff;
1778
- return [r, g, b];
1779
- }
1780
-
1781
- /**
1782
- * Set the RGB color. Setting this disables ACI color.
1783
- * @param value The RGB color tuple [r, g, b], or null to use ACI.
1784
- */
1785
- set rgb(value: RGB | null) {
1786
- if (value) {
1787
- const [r, g, b] = value;
1788
- this._rgbValue = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff);
1789
- this._aci = null;
1790
- } else {
1791
- this._rgbValue = null;
1792
- }
1793
- }
1794
-
1795
- /**
1796
- * Returns true if the color is set by RGB, false if by ACI.
1797
- */
1798
- get isRgb(): boolean {
1799
- return this._rgbValue !== null;
1800
- }
1801
-
1802
- /**
1803
- * Returns true if the color is set by ACI, false if by RGB.
1804
- */
1805
- get isAci(): boolean {
1806
- return this._rgbValue === null && this._aci !== null;
1807
- }
1808
-
1809
- /**
1810
- * Get or set the internal RGB value as a number (0xRRGGBB), or null if not set.
1811
- * Setting this will switch to RGB mode and set ACI to null.
1812
- */
1813
- get rgbValue(): number | null {
1814
- return this._rgbValue;
1815
- }
1816
-
1817
- set rgbValue(val: number | null) {
1818
- if (val === null) {
1819
- this._rgbValue = null;
1820
- } else {
1821
- this._rgbValue = val & 0xffffff;
1822
- this._aci = null;
1823
- }
1824
- }
1825
-
1826
- /**
1827
- * Returns a deep copy of this color.
1828
- * @returns A new MTextColor instance with the same color state.
1829
- */
1830
- copy(): MTextColor {
1831
- const c = new MTextColor();
1832
- c._aci = this._aci;
1833
- c._rgbValue = this._rgbValue;
1834
- return c;
1835
- }
1836
-
1837
- /**
1838
- * Returns a plain object for serialization.
1839
- * @returns An object with aci, rgb (tuple), and rgbValue (number or null).
1840
- */
1841
- toObject(): { aci: number | null; rgb: RGB | null; rgbValue: number | null } {
1842
- return { aci: this._aci, rgb: this.rgb, rgbValue: this._rgbValue };
1843
- }
1844
-
1845
- /**
1846
- * Convert the current color to a CSS hex color string (#rrggbb).
1847
- * Returns null if the color is ACI-based and has no RGB value.
1848
- */
1849
- toCssColor(): string | null {
1850
- if (this._rgbValue !== null) {
1851
- return colorNumberToHex(this._rgbValue);
1852
- }
1853
- return null;
1854
- }
1855
-
1856
- /**
1857
- * Create an MTextColor from a CSS color string.
1858
- * Supports #rgb, #rrggbb, rgb(...), rgba(...). Returns null if invalid or transparent.
1859
- */
1860
- static fromCssColor(value: string | null | undefined): MTextColor | null {
1861
- const rgbValue = cssColorToRgbValue(value);
1862
- if (rgbValue === null) return null;
1863
- const color = new MTextColor();
1864
- color.rgbValue = rgbValue;
1865
- return color;
1866
- }
1867
-
1868
- /**
1869
- * Equality check for color.
1870
- * @param other The other MTextColor to compare.
1871
- * @returns True if both ACI and RGB values are equal.
1872
- */
1873
- equals(other: MTextColor): boolean {
1874
- return this._aci === other._aci && this._rgbValue === other._rgbValue;
1875
- }
1876
- }
1877
-
1878
- /**
1879
- * MText context class for managing text formatting state
1880
- */
1881
- export class MTextContext {
1882
- private _stroke: number = 0;
1883
- /** Whether to continue stroke formatting */
1884
- continueStroke: boolean = false;
1885
- /** Color (ACI or RGB) */
1886
- color: MTextColor = new MTextColor();
1887
- /** Line alignment */
1888
- align: MTextLineAlignment = MTextLineAlignment.BOTTOM;
1889
- /** Font face properties */
1890
- fontFace: FontFace = { family: '', style: 'Regular', weight: 400 };
1891
- /** Capital letter height */
1892
- private _capHeight: FactorValue = { value: 1.0, isRelative: false };
1893
- /** Character width factor */
1894
- private _widthFactor: FactorValue = { value: 1.0, isRelative: false };
1895
- /**
1896
- * Character tracking factor a multiplier applied to the default spacing between characters in the MText object.
1897
- * - Value = 1.0 → Normal spacing.
1898
- * - Value < 1.0 → Characters are closer together.
1899
- * - Value > 1.0 → Characters are spaced farther apart.
1900
- */
1901
- private _charTrackingFactor: FactorValue = { value: 1.0, isRelative: false };
1902
- /** Oblique angle */
1903
- oblique: number = 0.0;
1904
- /** Paragraph properties */
1905
- paragraph: ParagraphProperties = {
1906
- indent: 0,
1907
- left: 0,
1908
- right: 0,
1909
- align: MTextParagraphAlignment.DEFAULT,
1910
- tabs: [],
1911
- };
1912
-
1913
- /**
1914
- * Get the capital letter height
1915
- */
1916
- get capHeight(): FactorValue {
1917
- return this._capHeight;
1918
- }
1919
-
1920
- /**
1921
- * Set the capital letter height
1922
- * @param value - Height value
1923
- */
1924
- set capHeight(value: FactorValue) {
1925
- this._capHeight = {
1926
- value: Math.abs(value.value),
1927
- isRelative: value.isRelative,
1928
- };
1929
- }
1930
-
1931
- /**
1932
- * Get the character width factor
1933
- */
1934
- get widthFactor(): FactorValue {
1935
- return this._widthFactor;
1936
- }
1937
-
1938
- /**
1939
- * Set the character width factor
1940
- * @param value - Width factor value
1941
- */
1942
- set widthFactor(value: FactorValue) {
1943
- this._widthFactor = {
1944
- value: Math.abs(value.value),
1945
- isRelative: value.isRelative,
1946
- };
1947
- }
1948
-
1949
- /**
1950
- * Get the character tracking factor
1951
- */
1952
- get charTrackingFactor(): FactorValue {
1953
- return this._charTrackingFactor;
1954
- }
1955
-
1956
- /**
1957
- * Set the character tracking factor
1958
- * @param value - Tracking factor value
1959
- */
1960
- set charTrackingFactor(value: FactorValue) {
1961
- this._charTrackingFactor = {
1962
- value: Math.abs(value.value),
1963
- isRelative: value.isRelative,
1964
- };
1965
- }
1966
-
1967
- /**
1968
- * Get the ACI color value
1969
- */
1970
- get aci(): number | null {
1971
- return this.color.aci;
1972
- }
1973
-
1974
- /**
1975
- * Set the ACI color value
1976
- * @param value - ACI color value (0-256)
1977
- * @throws Error if value is out of range
1978
- */
1979
- set aci(value: number) {
1980
- this.color.aci = value;
1981
- }
1982
-
1983
- /**
1984
- * Get the RGB color value
1985
- */
1986
- get rgb(): RGB | null {
1987
- return this.color.rgb;
1988
- }
1989
-
1990
- /**
1991
- * Set the RGB color value
1992
- */
1993
- set rgb(value: RGB | null) {
1994
- this.color.rgb = value;
1995
- }
1996
-
1997
- /**
1998
- * Gets whether the current text should be rendered in italic style.
1999
- * @returns {boolean} True if the font style is 'Italic', otherwise false.
2000
- */
2001
- get italic(): boolean {
2002
- return this.fontFace.style === 'Italic';
2003
- }
2004
- /**
2005
- * Sets whether the current text should be rendered in italic style.
2006
- * @param value - If true, sets the font style to 'Italic'; if false, sets it to 'Regular'.
2007
- */
2008
- set italic(value: boolean) {
2009
- this.fontFace.style = value ? 'Italic' : 'Regular';
2010
- }
2011
-
2012
- /**
2013
- * Gets whether the current text should be rendered in bold style.
2014
- * This is primarily used for mesh fonts and affects font selection.
2015
- * @returns {boolean} True if the font weight is 700 or higher, otherwise false.
2016
- */
2017
- get bold(): boolean {
2018
- return (this.fontFace.weight || 400) >= 700;
2019
- }
2020
- /**
2021
- * Sets whether the current text should be rendered in bold style.
2022
- * This is primarily used for mesh fonts and affects font selection.
2023
- * @param value - If true, sets the font weight to 700; if false, sets it to 400.
2024
- */
2025
- set bold(value: boolean) {
2026
- this.fontFace.weight = value ? 700 : 400;
2027
- }
2028
-
2029
- /**
2030
- * Get whether text is underlined
2031
- */
2032
- get underline(): boolean {
2033
- return Boolean(this._stroke & MTextStroke.UNDERLINE);
2034
- }
2035
-
2036
- /**
2037
- * Set whether text is underlined
2038
- * @param value - Whether to underline
2039
- */
2040
- set underline(value: boolean) {
2041
- this._setStrokeState(MTextStroke.UNDERLINE, value);
2042
- }
2043
-
2044
- /**
2045
- * Get whether text has strike-through
2046
- */
2047
- get strikeThrough(): boolean {
2048
- return Boolean(this._stroke & MTextStroke.STRIKE_THROUGH);
2049
- }
2050
-
2051
- /**
2052
- * Set whether text has strike-through
2053
- * @param value - Whether to strike through
2054
- */
2055
- set strikeThrough(value: boolean) {
2056
- this._setStrokeState(MTextStroke.STRIKE_THROUGH, value);
2057
- }
2058
-
2059
- /**
2060
- * Get whether text has overline
2061
- */
2062
- get overline(): boolean {
2063
- return Boolean(this._stroke & MTextStroke.OVERLINE);
2064
- }
2065
-
2066
- /**
2067
- * Set whether text has overline
2068
- * @param value - Whether to overline
2069
- */
2070
- set overline(value: boolean) {
2071
- this._setStrokeState(MTextStroke.OVERLINE, value);
2072
- }
2073
-
2074
- /**
2075
- * Check if any stroke formatting is active
2076
- */
2077
- get hasAnyStroke(): boolean {
2078
- return Boolean(this._stroke);
2079
- }
2080
-
2081
- /**
2082
- * Set the state of a stroke type
2083
- * @param stroke - The stroke type to set
2084
- * @param state - Whether to enable or disable the stroke
2085
- */
2086
- private _setStrokeState(stroke: MTextStroke, state: boolean = true): void {
2087
- if (state) {
2088
- this._stroke |= stroke;
2089
- } else {
2090
- this._stroke &= ~stroke;
2091
- }
2092
- }
2093
-
2094
- /**
2095
- * Create a copy of this context
2096
- * @returns A new context with the same properties
2097
- */
2098
- copy(): MTextContext {
2099
- const ctx = new MTextContext();
2100
- ctx._stroke = this._stroke;
2101
- ctx.continueStroke = this.continueStroke;
2102
- ctx.color = this.color.copy();
2103
- ctx.align = this.align;
2104
- ctx.fontFace = { ...this.fontFace };
2105
- ctx._capHeight = { ...this._capHeight };
2106
- ctx._widthFactor = { ...this._widthFactor };
2107
- ctx._charTrackingFactor = { ...this._charTrackingFactor };
2108
- ctx.oblique = this.oblique;
2109
- ctx.paragraph = { ...this.paragraph };
2110
- return ctx;
2111
- }
2112
- }
2113
-
2114
- /**
2115
- * Token class for MText parsing
2116
- */
2117
- export class MTextToken {
2118
- /**
2119
- * Create a new MText token
2120
- * @param type - The token type
2121
- * @param ctx - The text context at this token
2122
- * @param data - Optional token data
2123
- */
2124
- constructor(
2125
- public type: TokenType,
2126
- public ctx: MTextContext,
2127
- public data: TokenData[TokenType]
2128
- ) {}
2129
- }
1
+ /**
2
+ * Token types used in MText parsing
3
+ */
4
+ export enum TokenType {
5
+ /** No token */
6
+ NONE = 0,
7
+ /** Word token with string data */
8
+ WORD = 1,
9
+ /** Stack token with [numerator, denominator, type] data */
10
+ STACK = 2,
11
+ /** Space token with no data */
12
+ SPACE = 3,
13
+ /** Non-breaking space token with no data */
14
+ NBSP = 4,
15
+ /** Tab token with no data */
16
+ TABULATOR = 5,
17
+ /** New paragraph token with no data */
18
+ NEW_PARAGRAPH = 6,
19
+ /** New column token with no data */
20
+ NEW_COLUMN = 7,
21
+ /** Wrap at dimension line token with no data */
22
+ WRAP_AT_DIMLINE = 8,
23
+ /** Properties changed token with string data (full command) */
24
+ PROPERTIES_CHANGED = 9,
25
+ /** AutoCAD percent-sign symbol code (`%%c`, `%%d`, `%%p`, `%%nnn`, `%%%`) */
26
+ PERCENT_SYMBOL = 10,
27
+ }
28
+
29
+ /**
30
+ * AutoCAD percent-sign symbol emitted when {@link MTextParserOptions.yieldPercentSymbols}
31
+ * is enabled.
32
+ */
33
+ export type PercentSymbolData =
34
+ | {
35
+ kind: 'named';
36
+ /** Percent code letter: `%%c`, `%%d`, or `%%p`. */
37
+ code: 'c' | 'd' | 'p';
38
+ /** Unicode expansion used for display (e.g. `%%c` → `Ø`). */
39
+ char: string;
40
+ }
41
+ | {
42
+ kind: 'numeric';
43
+ /**
44
+ * Decimal code from `%%nnn`, where `nnn` is 1–3 digits (Unicode/ASCII
45
+ * code point, e.g. `%%34` → `"`, `%%176` → `°`).
46
+ */
47
+ charCode: number;
48
+ /** `String.fromCharCode(charCode)`. */
49
+ char: string;
50
+ }
51
+ | {
52
+ kind: 'literal';
53
+ /** Literal percent from `%%%`. */
54
+ char: '%';
55
+ };
56
+
57
+ /**
58
+ * Represents a factor value that can be either absolute or relative.
59
+ * Used for properties like height, width, and character tracking in MText formatting.
60
+ */
61
+ export interface FactorValue {
62
+ /** The numeric value of the factor */
63
+ value: number;
64
+ /** Whether the value is relative (true) or absolute (false) */
65
+ isRelative: boolean;
66
+ }
67
+
68
+ /**
69
+ * Format properties of MText word tokens.
70
+ * This interface defines all the formatting properties that can be applied to MText content,
71
+ * including text styling, colors, alignment, font properties, and paragraph formatting.
72
+ */
73
+ export interface Properties {
74
+ /** Whether text is underlined */
75
+ underline?: boolean;
76
+ /** Whether text has an overline */
77
+ overline?: boolean;
78
+ /** Whether text has strike-through */
79
+ strikeThrough?: boolean;
80
+ /** AutoCAD Color Index (ACI) color value (0-256), or null if not set */
81
+ aci?: number | null;
82
+ /** RGB color tuple [r, g, b], or null if not set */
83
+ rgb?: RGB | null;
84
+ /** Line alignment for the text */
85
+ align?: MTextLineAlignment;
86
+ /** Font face properties including family, style, and weight */
87
+ fontFace?: FontFace;
88
+ /** Capital letter height factor (can be relative or absolute) */
89
+ capHeight?: FactorValue;
90
+ /** Character width factor (can be relative or absolute) */
91
+ widthFactor?: FactorValue;
92
+ /** Character tracking factor for spacing between characters (can be relative or absolute) */
93
+ charTrackingFactor?: FactorValue;
94
+ /** Oblique angle in degrees for text slant */
95
+ oblique?: number;
96
+ /** Paragraph formatting properties (partial to allow selective updates) */
97
+ paragraph?: Partial<ParagraphProperties>;
98
+ }
99
+
100
+ /**
101
+ * Represents a change in MText properties, including the command, the changed properties, and the context depth.
102
+ */
103
+ export interface ChangedProperties {
104
+ /**
105
+ * The property command that triggered the change (e.g., 'L', 'C', 'f').
106
+ * The command will be undefined if it is to restore context.
107
+ */
108
+ command: string | undefined;
109
+ /**
110
+ * The set of properties that have changed as a result of the command.
111
+ */
112
+ changes: Properties;
113
+ /**
114
+ * The current context stack depth when the property change occurs.
115
+ * - 0: The change is global (applies outside of any `{}` block).
116
+ * - >0: The change is local (applies within one or more nested `{}` blocks).
117
+ */
118
+ depth: number; // 0 = global, >0 = local
119
+ }
120
+
121
+ /**
122
+ * Type for token data based on token type
123
+ */
124
+ export type TokenData = {
125
+ [TokenType.NONE]: null;
126
+ [TokenType.WORD]: string;
127
+ [TokenType.STACK]: [string, string, string];
128
+ [TokenType.SPACE]: null;
129
+ [TokenType.NBSP]: null;
130
+ [TokenType.TABULATOR]: null;
131
+ [TokenType.NEW_PARAGRAPH]: null;
132
+ [TokenType.NEW_COLUMN]: null;
133
+ [TokenType.WRAP_AT_DIMLINE]: null;
134
+ [TokenType.PROPERTIES_CHANGED]: ChangedProperties;
135
+ [TokenType.PERCENT_SYMBOL]: PercentSymbolData;
136
+ };
137
+
138
+ /**
139
+ * Line alignment options for MText
140
+ */
141
+ export enum MTextLineAlignment {
142
+ /** Align text to bottom */
143
+ BOTTOM = 0,
144
+ /** Align text to middle */
145
+ MIDDLE = 1,
146
+ /** Align text to top */
147
+ TOP = 2,
148
+ }
149
+
150
+ /**
151
+ * Paragraph alignment options for MText
152
+ */
153
+ export enum MTextParagraphAlignment {
154
+ /** Default alignment */
155
+ DEFAULT = 0,
156
+ /** Left alignment */
157
+ LEFT = 1,
158
+ /** Right alignment */
159
+ RIGHT = 2,
160
+ /** Center alignment */
161
+ CENTER = 3,
162
+ /** Justified alignment */
163
+ JUSTIFIED = 4,
164
+ /** Distributed alignment */
165
+ DISTRIBUTED = 5,
166
+ }
167
+
168
+ /**
169
+ * Text stroke options for MText
170
+ */
171
+ export enum MTextStroke {
172
+ /** No stroke */
173
+ NONE = 0,
174
+ /** Underline stroke */
175
+ UNDERLINE = 1,
176
+ /** Overline stroke */
177
+ OVERLINE = 2,
178
+ /** Strike-through stroke */
179
+ STRIKE_THROUGH = 4,
180
+ }
181
+
182
+ /**
183
+ * RGB color tuple
184
+ */
185
+ export type RGB = [number, number, number];
186
+
187
+ /**
188
+ * Font style type
189
+ */
190
+ export type FontStyle = 'Regular' | 'Italic';
191
+
192
+ /**
193
+ * Font face properties
194
+ */
195
+ export interface FontFace {
196
+ /** Font family name */
197
+ family: string;
198
+ /** Font style (e.g., 'Regular', 'Italic') */
199
+ style: FontStyle;
200
+ /** Font weight (e.g., 400 for normal, 700 for bold) */
201
+ weight: number;
202
+ }
203
+
204
+ /**
205
+ * Paragraph properties
206
+ */
207
+ export interface ParagraphProperties {
208
+ /** Indentation value */
209
+ indent: number;
210
+ /** Left margin value */
211
+ left: number;
212
+ /** Right margin value */
213
+ right: number;
214
+ /** Paragraph alignment */
215
+ align: MTextParagraphAlignment;
216
+ /** Tab stop positions and types */
217
+ tabs: (number | string)[];
218
+ }
219
+
220
+ /**
221
+ * Special character encoding mapping
222
+ */
223
+ const SPECIAL_CHAR_ENCODING: Record<string, string> = {
224
+ c: 'Ø',
225
+ d: '°',
226
+ p: '±',
227
+ '%': '%',
228
+ };
229
+
230
+ /**
231
+ * Character to paragraph alignment mapping
232
+ */
233
+ const CHAR_TO_ALIGN: Record<string, MTextParagraphAlignment> = {
234
+ l: MTextParagraphAlignment.LEFT,
235
+ r: MTextParagraphAlignment.RIGHT,
236
+ c: MTextParagraphAlignment.CENTER,
237
+ j: MTextParagraphAlignment.JUSTIFIED,
238
+ d: MTextParagraphAlignment.DISTRIBUTED,
239
+ };
240
+
241
+ /**
242
+ * Convert RGB tuple to integer color value
243
+ * @param rgb - RGB color tuple
244
+ * @returns Integer color value
245
+ */
246
+ export function rgb2int(rgb: RGB): number {
247
+ const [r, g, b] = rgb;
248
+ return (r << 16) | (g << 8) | b;
249
+ }
250
+
251
+ /**
252
+ * Convert integer color value to RGB tuple
253
+ * @param value - Integer color value
254
+ * @returns RGB color tuple
255
+ */
256
+ export function int2rgb(value: number): RGB {
257
+ const r = (value >> 16) & 0xff;
258
+ const g = (value >> 8) & 0xff;
259
+ const b = value & 0xff;
260
+ return [r, g, b];
261
+ }
262
+
263
+ function clampColorChannel(value: number): number {
264
+ return Math.max(0, Math.min(255, Math.round(value)));
265
+ }
266
+
267
+ function normalizeColorNumber(color: number): number {
268
+ return Math.max(0, Math.min(0xffffff, Math.round(color)));
269
+ }
270
+
271
+ function colorNumberToHex(color: number | null): string | null {
272
+ if (color === null) return null;
273
+ return `#${normalizeColorNumber(color).toString(16).padStart(6, '0')}`;
274
+ }
275
+
276
+ function normalizeHexColor(value: string | null | undefined): string | null {
277
+ if (!value) return null;
278
+ const normalized = value.trim().toLowerCase();
279
+ if (/^#[0-9a-f]{6}$/.test(normalized)) return normalized;
280
+ if (/^[0-9a-f]{6}$/.test(normalized)) return `#${normalized}`;
281
+ if (/^#[0-9a-f]{3}$/.test(normalized)) {
282
+ const r = normalized[1];
283
+ const g = normalized[2];
284
+ const b = normalized[3];
285
+ return `#${r}${r}${g}${g}${b}${b}`;
286
+ }
287
+ if (/^[0-9a-f]{3}$/.test(normalized)) {
288
+ const r = normalized[0];
289
+ const g = normalized[1];
290
+ const b = normalized[2];
291
+ return `#${r}${r}${g}${g}${b}${b}`;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ function cssColorToRgbValue(value: string | null | undefined): number | null {
297
+ if (!value) return null;
298
+ const raw = value.trim().toLowerCase();
299
+ if (raw === 'transparent') return null;
300
+
301
+ const hex = normalizeHexColor(raw);
302
+ if (hex) {
303
+ return normalizeColorNumber(Number.parseInt(hex.slice(1), 16));
304
+ }
305
+
306
+ const fnMatch = raw.match(/^rgba?\((.*)\)$/);
307
+ if (!fnMatch) return null;
308
+
309
+ const parts = fnMatch[1]
310
+ .replace(/\s*\/\s*/g, ' ')
311
+ .split(/[,\s]+/)
312
+ .map(p => p.trim())
313
+ .filter(Boolean);
314
+
315
+ if (parts.length < 3) return null;
316
+
317
+ const toChannel = (token: string): number => {
318
+ if (token.endsWith('%')) {
319
+ const percent = Number.parseFloat(token.slice(0, -1));
320
+ return clampColorChannel((percent / 100) * 255);
321
+ }
322
+ const num = Number.parseFloat(token);
323
+ return clampColorChannel(num);
324
+ };
325
+
326
+ const r = toChannel(parts[0]);
327
+ const g = toChannel(parts[1]);
328
+ const b = toChannel(parts[2]);
329
+ return rgb2int([r, g, b]);
330
+ }
331
+
332
+ /**
333
+ * Escape DXF line endings
334
+ * @param text - Text to escape
335
+ * @returns Escaped text
336
+ */
337
+ export function escapeDxfLineEndings(text: string): string {
338
+ return text.replace(/\r\n|\r|\n/g, '\\P');
339
+ }
340
+
341
+ /**
342
+ * Check if text contains inline formatting codes
343
+ * @param text - Text to check
344
+ * @returns True if text contains formatting codes
345
+ */
346
+ export function hasInlineFormattingCodes(text: string): boolean {
347
+ return text.replace(/\\P/g, '').replace(/\\~/g, '').includes('\\');
348
+ }
349
+
350
+ /**
351
+ * Extracts all unique font names used in an MText string.
352
+ * This function searches for font commands in the format \f{fontname}| or \f{fontname}; and returns a set of unique font names.
353
+ * Font names are converted to lowercase to ensure case-insensitive uniqueness.
354
+ *
355
+ * @param mtext - The MText string to analyze for font names
356
+ * @param removeExtension - Whether to remove font file extensions (e.g., .ttf, .shx) from font names. Defaults to false.
357
+ * @returns A Set containing all unique font names found in the MText string, converted to lowercase
358
+ * @example
359
+ * ```ts
360
+ * const mtext = "\\fArial.ttf|Hello\\fTimes New Roman.otf|World";
361
+ * const fonts = getFonts(mtext, true);
362
+ * // Returns: Set(2) { "arial", "times new roman" }
363
+ * ```
364
+ */
365
+ export function getFonts(mtext: string, removeExtension: boolean = false) {
366
+ const fonts: Set<string> = new Set();
367
+ const regex = /\\[fF](.*?)[;|]/g;
368
+
369
+ [...mtext.matchAll(regex)].forEach(match => {
370
+ let fontName = match[1].toLowerCase();
371
+ if (removeExtension) {
372
+ fontName = fontName.replace(/\.(ttf|otf|woff|shx)$/, '');
373
+ }
374
+ fonts.add(fontName);
375
+ });
376
+
377
+ return fonts;
378
+ }
379
+
380
+ /**
381
+ * ContextStack manages a stack of MTextContext objects for character-level formatting.
382
+ *
383
+ * - Character-level formatting (underline, color, font, etc.) is scoped to `{}` blocks and managed by the stack.
384
+ * - Paragraph-level formatting (\p) is not scoped, but when a block ends, any paragraph property changes are merged into the parent context.
385
+ * - On pop, paragraph properties from the popped context are always merged into the new top context.
386
+ */
387
+ class ContextStack {
388
+ private stack: MTextContext[] = [];
389
+
390
+ /**
391
+ * Creates a new ContextStack with an initial context.
392
+ * @param initial The initial MTextContext to use as the base of the stack.
393
+ */
394
+ constructor(initial: MTextContext) {
395
+ this.stack.push(initial);
396
+ }
397
+
398
+ /**
399
+ * Pushes a copy of the given context onto the stack.
400
+ * @param ctx The MTextContext to push (copied).
401
+ */
402
+ push(ctx: MTextContext) {
403
+ this.stack.push(ctx);
404
+ }
405
+
406
+ /**
407
+ * Pops the top context from the stack and merges its paragraph properties into the new top context.
408
+ * If only one context remains, nothing is popped.
409
+ * @returns The popped MTextContext, or undefined if the stack has only one context.
410
+ */
411
+ pop(): MTextContext | undefined {
412
+ if (this.stack.length <= 1) return undefined;
413
+ const popped = this.stack.pop()!;
414
+ // Merge paragraph properties into the new top context
415
+ const top = this.stack[this.stack.length - 1];
416
+ if (JSON.stringify(top.paragraph) !== JSON.stringify(popped.paragraph)) {
417
+ top.paragraph = { ...popped.paragraph };
418
+ }
419
+ return popped;
420
+ }
421
+
422
+ /**
423
+ * Returns the current (top) context on the stack.
424
+ */
425
+ get current(): MTextContext {
426
+ return this.stack[this.stack.length - 1];
427
+ }
428
+
429
+ /**
430
+ * Returns the current stack depth (number of nested blocks), not counting the root context.
431
+ */
432
+ get depth(): number {
433
+ return this.stack.length - 1;
434
+ }
435
+
436
+ /**
437
+ * Returns the root (bottom) context, which represents the global formatting state.
438
+ * Used for paragraph property application.
439
+ */
440
+ get root(): MTextContext {
441
+ return this.stack[0];
442
+ }
443
+
444
+ /**
445
+ * Replaces the current (top) context with the given context.
446
+ * @param ctx The new context to set as the current context.
447
+ */
448
+ setCurrent(ctx: MTextContext) {
449
+ this.stack[this.stack.length - 1] = ctx;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Configuration options for the MText parser.
455
+ * These options control how the parser behaves during tokenization and property handling.
456
+ */
457
+ export interface MTextParserOptions {
458
+ /**
459
+ * Whether to yield PROPERTIES_CHANGED tokens when formatting properties change.
460
+ * When true, the parser will emit tokens whenever properties like color, font, or alignment change.
461
+ * When false, property changes are applied silently to the context without generating tokens.
462
+ * @default false
463
+ */
464
+ yieldPropertyCommands?: boolean;
465
+ /**
466
+ * Whether to reset paragraph parameters when encountering a new paragraph token.
467
+ * When true, paragraph properties (indent, margins, alignment, tab stops) are reset to defaults
468
+ * at the start of each new paragraph.
469
+ * @default false
470
+ */
471
+ resetParagraphParameters?: boolean;
472
+ /**
473
+ * Whether to emit {@link TokenType.PERCENT_SYMBOL} tokens for AutoCAD `%%` symbol
474
+ * codes instead of expanding them into {@link TokenType.WORD} characters.
475
+ * @default false
476
+ */
477
+ yieldPercentSymbols?: boolean;
478
+ /**
479
+ * Custom decoder function for MIF (Multibyte Interchange Format) codes.
480
+ * If provided, this function will be used instead of the default decodeMultiByteChar.
481
+ * The function receives the hex code string and should return the decoded character.
482
+ * @param hex - Hex code string (e.g., "C4E3" or "1A2B3")
483
+ * @returns Decoded character or empty square (▯) if invalid
484
+ * @default undefined (uses default decoder)
485
+ */
486
+ mifDecoder?: (hex: string) => string;
487
+ /**
488
+ * The length of MIF hex codes to parse. MIF codes in AutoCAD can vary in length
489
+ * depending on the specific SHX big font used (typically 4 or 5 digits).
490
+ * If not specified, the parser will try to auto-detect the length by attempting
491
+ * to match 4 digits first, then 5 digits if needed.
492
+ * @default undefined (auto-detect)
493
+ */
494
+ mifCodeLength?: 4 | 5 | 'auto';
495
+ }
496
+
497
+ /**
498
+ * Main parser class for MText content
499
+ */
500
+ export class MTextParser {
501
+ private scanner: TextScanner;
502
+ private ctxStack: ContextStack;
503
+ private continueStroke: boolean = false;
504
+ private yieldPropertyCommands: boolean;
505
+ private resetParagraphParameters: boolean;
506
+ private yieldPercentSymbols: boolean;
507
+ private inStackContext: boolean = false;
508
+ private mifDecoder: (hex: string) => string;
509
+ private mifCodeLength: 4 | 5 | 'auto';
510
+
511
+ /**
512
+ * Creates a new MTextParser instance
513
+ * @param content - The MText content to parse
514
+ * @param ctx - Optional initial MText context
515
+ * @param options - Parser options
516
+ */
517
+ constructor(content: string, ctx?: MTextContext, options: MTextParserOptions = {}) {
518
+ this.scanner = new TextScanner(content);
519
+ const initialCtx = ctx ?? new MTextContext();
520
+ this.ctxStack = new ContextStack(initialCtx);
521
+ this.yieldPropertyCommands = options.yieldPropertyCommands ?? false;
522
+ this.resetParagraphParameters = options.resetParagraphParameters ?? false;
523
+ this.yieldPercentSymbols = options.yieldPercentSymbols ?? false;
524
+ this.mifDecoder = options.mifDecoder ?? this.decodeMultiByteChar.bind(this);
525
+ this.mifCodeLength = options.mifCodeLength ?? 'auto';
526
+ }
527
+
528
+ /**
529
+ * Decode multi-byte character from hex code
530
+ * @param hex - Hex code string (e.g. "C4E3" or "1A2B3")
531
+ * @returns Decoded character or empty square if invalid
532
+ */
533
+ private decodeMultiByteChar(hex: string): string {
534
+ try {
535
+ // For 5-digit codes, return placeholder directly
536
+ if (hex.length === 5) {
537
+ const prefix = hex[0];
538
+
539
+ // Notes:
540
+ // I know AutoCAD uses prefix 1 for Shift-JIS, 2 for big5, and 5 for gbk.
541
+ // But I don't know whether there are other prefixes and their meanings.
542
+ let encoding = 'gbk';
543
+ if (prefix === '1') {
544
+ encoding = 'shift-jis';
545
+ } else if (prefix === '2') {
546
+ encoding = 'big5';
547
+ }
548
+ const bytes = new Uint8Array([
549
+ parseInt(hex.substr(1, 2), 16),
550
+ parseInt(hex.substr(3, 2), 16),
551
+ ]);
552
+ const decoder = new TextDecoder(encoding);
553
+ const result = decoder.decode(bytes);
554
+ return result;
555
+ } else if (hex.length === 4) {
556
+ // For 4-digit hex codes, decode as 2-byte character
557
+ const bytes = new Uint8Array([
558
+ parseInt(hex.substr(0, 2), 16),
559
+ parseInt(hex.substr(2, 2), 16),
560
+ ]);
561
+
562
+ // Try GBK first
563
+ const gbkDecoder = new TextDecoder('gbk');
564
+ const gbkResult = gbkDecoder.decode(bytes);
565
+ if (gbkResult !== '▯') {
566
+ return gbkResult;
567
+ }
568
+
569
+ // Try BIG5 if GBK fails
570
+ const big5Decoder = new TextDecoder('big5');
571
+ const big5Result = big5Decoder.decode(bytes);
572
+ if (big5Result !== '▯') {
573
+ return big5Result;
574
+ }
575
+ }
576
+
577
+ return '▯';
578
+ } catch {
579
+ return '▯';
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Extract MIF hex code from scanner
585
+ * @param length - The length of the hex code to extract (4 or 5), or 'auto' to detect
586
+ * @returns The extracted hex code, or null if not found
587
+ */
588
+ private extractMifCode(length: 4 | 5 | 'auto'): string | null {
589
+ if (length === 'auto') {
590
+ // Try 5 digits first if available, then fall back to 4 digits
591
+ const code5 = this.scanner.tail.match(/^[0-9A-Fa-f]{5}/)?.[0];
592
+ if (code5) {
593
+ return code5;
594
+ }
595
+ const code4 = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/)?.[0];
596
+ if (code4) {
597
+ return code4;
598
+ }
599
+ return null;
600
+ } else {
601
+ const code = this.scanner.tail.match(new RegExp(`^[0-9A-Fa-f]{${length}}`))?.[0];
602
+ return code ?? null;
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Parse an AutoCAD `\U+nnnn` escape after the leading `U` has been consumed.
608
+ *
609
+ * @remarks
610
+ * Exactly four hexadecimal digits are read. A high surrogate followed by
611
+ * another `\U+nnnn` low surrogate is combined into one supplementary-plane
612
+ * character (AutoCAD's encoding for code points above U+FFFF).
613
+ *
614
+ * @returns The decoded character, or `null` if the escape is incomplete
615
+ */
616
+ private parseUnicodeEscape(): string | null {
617
+ if (this.scanner.peek() !== '+') {
618
+ return null;
619
+ }
620
+
621
+ this.scanner.consume(1); // Consume the '+'
622
+ const hexMatch = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/);
623
+ if (!hexMatch) {
624
+ this.scanner.consume(-1); // Rewind '+'
625
+ return null;
626
+ }
627
+
628
+ const codeUnit = parseInt(hexMatch[0], 16);
629
+ this.scanner.consume(hexMatch[0].length);
630
+
631
+ // High surrogate: combine with a following \U+ low-surrogate escape
632
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
633
+ const pairMatch = this.scanner.tail.match(/^\\U\+([0-9A-Fa-f]{4})/);
634
+ if (pairMatch) {
635
+ const lowUnit = parseInt(pairMatch[1], 16);
636
+ if (lowUnit >= 0xdc00 && lowUnit <= 0xdfff) {
637
+ this.scanner.consume(pairMatch[0].length);
638
+ const codePoint = ((codeUnit - 0xd800) << 10) + (lowUnit - 0xdc00) + 0x10000;
639
+ try {
640
+ return String.fromCodePoint(codePoint);
641
+ } catch {
642
+ return '▯';
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ // BMP code unit, or an unpaired surrogate kept as a UTF-16 unit
649
+ return String.fromCharCode(codeUnit);
650
+ }
651
+
652
+ /**
653
+ * Push current context onto the stack
654
+ */
655
+ private pushCtx(): void {
656
+ this.ctxStack.push(this.ctxStack.current);
657
+ }
658
+
659
+ /**
660
+ * Pop context from the stack
661
+ */
662
+ private popCtx(): void {
663
+ this.ctxStack.pop();
664
+ }
665
+
666
+ /**
667
+ * Parse stacking expression (numerator/denominator)
668
+ * @returns Tuple of [TokenType.STACK, [numerator, denominator, type]]
669
+ */
670
+ private parseStacking(): [TokenType, [string, string, string]] {
671
+ const scanner = new TextScanner(this.extractExpression(true));
672
+ let numerator = '';
673
+ let denominator = '';
674
+ let stackingType = '';
675
+
676
+ const getNextChar = (): [string, boolean] => {
677
+ let c = scanner.peek();
678
+ let escape = false;
679
+ if (c.charCodeAt(0) < 32) {
680
+ c = ' ';
681
+ }
682
+ if (c === '\\') {
683
+ escape = true;
684
+ scanner.consume(1);
685
+ c = scanner.peek();
686
+ }
687
+ scanner.consume(1);
688
+ return [c, escape];
689
+ };
690
+
691
+ const parseNumerator = (): [string, string] => {
692
+ let word = '';
693
+ while (scanner.hasData) {
694
+ const [c, escape] = getNextChar();
695
+ // Check for stacking operators first
696
+ if (!escape && (c === '/' || c === '#' || c === '^')) {
697
+ return [word, c];
698
+ }
699
+ word += c;
700
+ }
701
+ return [word, ''];
702
+ };
703
+
704
+ const parseDenominator = (skipLeadingSpace: boolean): string => {
705
+ let word = '';
706
+ let skipping = skipLeadingSpace;
707
+ while (scanner.hasData) {
708
+ const [c, escape] = getNextChar();
709
+ if (skipping && c === ' ') {
710
+ continue;
711
+ }
712
+ skipping = false;
713
+ // Stop at terminator unless escaped
714
+ if (!escape && c === ';') {
715
+ break;
716
+ }
717
+ word += c;
718
+ }
719
+ return word;
720
+ };
721
+
722
+ [numerator, stackingType] = parseNumerator();
723
+ if (stackingType) {
724
+ // Only skip leading space for caret divider
725
+ denominator = parseDenominator(stackingType === '^');
726
+ }
727
+
728
+ // Special case for \S^!/^?;
729
+ if (numerator === '' && denominator.includes('I/')) {
730
+ return [TokenType.STACK, [' ', ' ', '/']];
731
+ }
732
+
733
+ // Handle caret as a stacking operator
734
+ if (stackingType === '^') {
735
+ return [TokenType.STACK, [numerator, denominator, '^']];
736
+ }
737
+
738
+ return [TokenType.STACK, [numerator, denominator, stackingType]];
739
+ }
740
+
741
+ /**
742
+ * Parse MText properties
743
+ * @param cmd - The property command to parse
744
+ * @returns Property changes if yieldPropertyCommands is true and changes occurred
745
+ */
746
+ private parseProperties(cmd: string): TokenData[TokenType.PROPERTIES_CHANGED] | void {
747
+ const prevCtx = this.ctxStack.current.copy();
748
+ const newCtx = this.ctxStack.current.copy();
749
+ switch (cmd) {
750
+ case 'L':
751
+ newCtx.underline = true;
752
+ this.continueStroke = true;
753
+ break;
754
+ case 'l':
755
+ newCtx.underline = false;
756
+ if (!newCtx.hasAnyStroke) {
757
+ this.continueStroke = false;
758
+ }
759
+ break;
760
+ case 'O':
761
+ newCtx.overline = true;
762
+ this.continueStroke = true;
763
+ break;
764
+ case 'o':
765
+ newCtx.overline = false;
766
+ if (!newCtx.hasAnyStroke) {
767
+ this.continueStroke = false;
768
+ }
769
+ break;
770
+ case 'K':
771
+ newCtx.strikeThrough = true;
772
+ this.continueStroke = true;
773
+ break;
774
+ case 'k':
775
+ newCtx.strikeThrough = false;
776
+ if (!newCtx.hasAnyStroke) {
777
+ this.continueStroke = false;
778
+ }
779
+ break;
780
+ case 'A':
781
+ this.parseAlign(newCtx);
782
+ break;
783
+ case 'C':
784
+ this.parseAciColor(newCtx);
785
+ break;
786
+ case 'c':
787
+ this.parseRgbColor(newCtx);
788
+ break;
789
+ case 'H':
790
+ this.parseHeight(newCtx);
791
+ break;
792
+ case 'W':
793
+ this.parseWidth(newCtx);
794
+ break;
795
+ case 'Q':
796
+ this.parseOblique(newCtx);
797
+ break;
798
+ case 'T':
799
+ this.parseCharTracking(newCtx);
800
+ break;
801
+ case 'p':
802
+ this.parseParagraphProperties(newCtx);
803
+ break;
804
+ case 'f':
805
+ case 'F':
806
+ this.parseFontProperties(newCtx);
807
+ break;
808
+ default:
809
+ throw new Error(`Unknown command: ${cmd}`);
810
+ }
811
+
812
+ // Update continueStroke based on current stroke state
813
+ this.continueStroke = newCtx.hasAnyStroke;
814
+ newCtx.continueStroke = this.continueStroke;
815
+ // Use setCurrent to replace the current context
816
+ this.ctxStack.setCurrent(newCtx);
817
+
818
+ if (this.yieldPropertyCommands) {
819
+ const changes = this.getPropertyChanges(prevCtx, newCtx);
820
+ if (Object.keys(changes).length > 0) {
821
+ return {
822
+ command: cmd,
823
+ changes,
824
+ depth: this.ctxStack.depth,
825
+ };
826
+ }
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Get property changes between two contexts
832
+ * @param oldCtx - The old context
833
+ * @param newCtx - The new context
834
+ * @returns Object containing changed properties
835
+ */
836
+ private getPropertyChanges(
837
+ oldCtx: MTextContext,
838
+ newCtx: MTextContext
839
+ ): TokenData[TokenType.PROPERTIES_CHANGED]['changes'] {
840
+ const changes: TokenData[TokenType.PROPERTIES_CHANGED]['changes'] = {};
841
+
842
+ if (oldCtx.underline !== newCtx.underline) {
843
+ changes.underline = newCtx.underline;
844
+ }
845
+ if (oldCtx.overline !== newCtx.overline) {
846
+ changes.overline = newCtx.overline;
847
+ }
848
+ if (oldCtx.strikeThrough !== newCtx.strikeThrough) {
849
+ changes.strikeThrough = newCtx.strikeThrough;
850
+ }
851
+ if (oldCtx.color.aci !== newCtx.color.aci) {
852
+ changes.aci = newCtx.color.aci;
853
+ }
854
+ if (oldCtx.color.rgbValue !== newCtx.color.rgbValue) {
855
+ changes.rgb = newCtx.color.rgb;
856
+ }
857
+ if (oldCtx.align !== newCtx.align) {
858
+ changes.align = newCtx.align;
859
+ }
860
+ if (JSON.stringify(oldCtx.fontFace) !== JSON.stringify(newCtx.fontFace)) {
861
+ changes.fontFace = newCtx.fontFace;
862
+ }
863
+ if (
864
+ oldCtx.capHeight.value !== newCtx.capHeight.value ||
865
+ oldCtx.capHeight.isRelative !== newCtx.capHeight.isRelative
866
+ ) {
867
+ changes.capHeight = newCtx.capHeight;
868
+ }
869
+ if (
870
+ oldCtx.widthFactor.value !== newCtx.widthFactor.value ||
871
+ oldCtx.widthFactor.isRelative !== newCtx.widthFactor.isRelative
872
+ ) {
873
+ changes.widthFactor = newCtx.widthFactor;
874
+ }
875
+ if (
876
+ oldCtx.charTrackingFactor.value !== newCtx.charTrackingFactor.value ||
877
+ oldCtx.charTrackingFactor.isRelative !== newCtx.charTrackingFactor.isRelative
878
+ ) {
879
+ changes.charTrackingFactor = newCtx.charTrackingFactor;
880
+ }
881
+ if (oldCtx.oblique !== newCtx.oblique) {
882
+ changes.oblique = newCtx.oblique;
883
+ }
884
+ if (JSON.stringify(oldCtx.paragraph) !== JSON.stringify(newCtx.paragraph)) {
885
+ // Only include changed paragraph properties
886
+ const changedProps: Partial<ParagraphProperties> = {};
887
+ if (oldCtx.paragraph.indent !== newCtx.paragraph.indent) {
888
+ changedProps.indent = newCtx.paragraph.indent;
889
+ }
890
+ if (oldCtx.paragraph.align !== newCtx.paragraph.align) {
891
+ changedProps.align = newCtx.paragraph.align;
892
+ }
893
+ if (oldCtx.paragraph.left !== newCtx.paragraph.left) {
894
+ changedProps.left = newCtx.paragraph.left;
895
+ }
896
+ if (oldCtx.paragraph.right !== newCtx.paragraph.right) {
897
+ changedProps.right = newCtx.paragraph.right;
898
+ }
899
+ if (JSON.stringify(oldCtx.paragraph.tabs) !== JSON.stringify(newCtx.paragraph.tabs)) {
900
+ changedProps.tabs = newCtx.paragraph.tabs;
901
+ }
902
+ if (Object.keys(changedProps).length > 0) {
903
+ changes.paragraph = changedProps;
904
+ }
905
+ }
906
+
907
+ return changes;
908
+ }
909
+
910
+ /**
911
+ * Parse alignment property
912
+ * @param ctx - The context to update
913
+ */
914
+ private parseAlign(ctx: MTextContext): void {
915
+ const char = this.scanner.get();
916
+ if ('012'.includes(char)) {
917
+ ctx.align = parseInt(char) as MTextLineAlignment;
918
+ } else {
919
+ ctx.align = MTextLineAlignment.BOTTOM;
920
+ }
921
+ this.consumeOptionalTerminator();
922
+ }
923
+
924
+ /**
925
+ * Parse height property
926
+ * @param ctx - The context to update
927
+ */
928
+ private parseHeight(ctx: MTextContext): void {
929
+ const expr = this.extractFloatExpression(true);
930
+ if (expr) {
931
+ try {
932
+ if (expr.endsWith('x')) {
933
+ // For height command, treat x suffix as relative value
934
+ ctx.capHeight = {
935
+ value: parseFloat(expr.slice(0, -1)),
936
+ isRelative: true,
937
+ };
938
+ } else {
939
+ ctx.capHeight = {
940
+ value: parseFloat(expr),
941
+ isRelative: false,
942
+ };
943
+ }
944
+ } catch {
945
+ // If parsing fails, treat the entire command as literal text
946
+ this.scanner.consume(-expr.length); // Rewind to before the expression
947
+ return;
948
+ }
949
+ }
950
+ this.consumeOptionalTerminator();
951
+ }
952
+
953
+ /**
954
+ * Parse width property
955
+ * @param ctx - The context to update
956
+ */
957
+ private parseWidth(ctx: MTextContext): void {
958
+ const expr = this.extractFloatExpression(true);
959
+ if (expr) {
960
+ try {
961
+ if (expr.endsWith('x')) {
962
+ // For width command, treat x suffix as relative value
963
+ ctx.widthFactor = {
964
+ value: parseFloat(expr.slice(0, -1)),
965
+ isRelative: true,
966
+ };
967
+ } else {
968
+ ctx.widthFactor = {
969
+ value: parseFloat(expr),
970
+ isRelative: false,
971
+ };
972
+ }
973
+ } catch {
974
+ // If parsing fails, treat the entire command as literal text
975
+ this.scanner.consume(-expr.length); // Rewind to before the expression
976
+ return;
977
+ }
978
+ }
979
+ this.consumeOptionalTerminator();
980
+ }
981
+
982
+ /**
983
+ * Parse character tracking property
984
+ * @param ctx - The context to update
985
+ */
986
+ private parseCharTracking(ctx: MTextContext): void {
987
+ const expr = this.extractFloatExpression(true);
988
+ if (expr) {
989
+ try {
990
+ if (expr.endsWith('x')) {
991
+ // For tracking command, treat x suffix as relative value
992
+ ctx.charTrackingFactor = {
993
+ value: Math.abs(parseFloat(expr.slice(0, -1))),
994
+ isRelative: true,
995
+ };
996
+ } else {
997
+ ctx.charTrackingFactor = {
998
+ value: Math.abs(parseFloat(expr)),
999
+ isRelative: false,
1000
+ };
1001
+ }
1002
+ } catch {
1003
+ // If parsing fails, treat the entire command as literal text
1004
+ this.scanner.consume(-expr.length); // Rewind to before the expression
1005
+ return;
1006
+ }
1007
+ }
1008
+ this.consumeOptionalTerminator();
1009
+ }
1010
+
1011
+ /**
1012
+ * Parse float value or factor
1013
+ * @param value - Current value to apply factor to
1014
+ * @returns New value
1015
+ */
1016
+ private parseFloatValueOrFactor(value: number): number {
1017
+ const expr = this.extractFloatExpression(true);
1018
+ if (expr) {
1019
+ if (expr.endsWith('x')) {
1020
+ const factor = parseFloat(expr.slice(0, -1));
1021
+ value *= factor; // Allow negative factors
1022
+ } else {
1023
+ value = parseFloat(expr); // Allow negative values
1024
+ }
1025
+ }
1026
+ return value;
1027
+ }
1028
+
1029
+ /**
1030
+ * Parse oblique angle property
1031
+ * @param ctx - The context to update
1032
+ */
1033
+ private parseOblique(ctx: MTextContext): void {
1034
+ const obliqueExpr = this.extractFloatExpression(false);
1035
+ if (obliqueExpr) {
1036
+ ctx.oblique = parseFloat(obliqueExpr);
1037
+ }
1038
+ this.consumeOptionalTerminator();
1039
+ }
1040
+
1041
+ /**
1042
+ * Parse ACI color property
1043
+ * @param ctx - The context to update
1044
+ */
1045
+ private parseAciColor(ctx: MTextContext): void {
1046
+ const aciExpr = this.extractIntExpression();
1047
+ if (aciExpr) {
1048
+ const aci = parseInt(aciExpr);
1049
+ if (aci < 257) {
1050
+ ctx.color.aci = aci;
1051
+ }
1052
+ }
1053
+ this.consumeOptionalTerminator();
1054
+ }
1055
+
1056
+ /**
1057
+ * Parse RGB color property
1058
+ * @param ctx - The context to update
1059
+ */
1060
+ private parseRgbColor(ctx: MTextContext): void {
1061
+ const rgbExpr = this.extractIntExpression();
1062
+ if (rgbExpr) {
1063
+ const value = parseInt(rgbExpr) & 0xffffff;
1064
+ ctx.color.rgbValue = value;
1065
+ }
1066
+ this.consumeOptionalTerminator();
1067
+ }
1068
+
1069
+ /**
1070
+ * Extract float expression from scanner
1071
+ * @param relative - Whether to allow relative values (ending in 'x')
1072
+ * @returns Extracted expression
1073
+ */
1074
+ private extractFloatExpression(relative: boolean = false): string {
1075
+ const pattern = relative
1076
+ ? /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?x?/
1077
+ : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/;
1078
+ const match = this.scanner.tail.match(pattern);
1079
+ if (match) {
1080
+ const result = match[0];
1081
+ this.scanner.consume(result.length);
1082
+ return result;
1083
+ }
1084
+ return '';
1085
+ }
1086
+
1087
+ /**
1088
+ * Extract integer expression from scanner
1089
+ * @returns Extracted expression
1090
+ */
1091
+ private extractIntExpression(): string {
1092
+ const match = this.scanner.tail.match(/^\d+/);
1093
+ if (match) {
1094
+ const result = match[0];
1095
+ this.scanner.consume(result.length);
1096
+ return result;
1097
+ }
1098
+ return '';
1099
+ }
1100
+
1101
+ /**
1102
+ * Extract expression until semicolon or end
1103
+ * @param escape - Whether to handle escaped semicolons
1104
+ * @returns Extracted expression
1105
+ */
1106
+ private extractExpression(escape: boolean = false): string {
1107
+ const stop = this.scanner.find(';', escape);
1108
+ if (stop < 0) {
1109
+ const expr = this.scanner.tail;
1110
+ this.scanner.consume(expr.length);
1111
+ return expr;
1112
+ }
1113
+ // Check if the semicolon is escaped by looking at the previous character
1114
+ const prevChar = this.scanner.peek(stop - this.scanner.currentIndex - 1);
1115
+ const isEscaped = prevChar === '\\';
1116
+ const expr = this.scanner.tail.slice(0, stop - this.scanner.currentIndex + (isEscaped ? 1 : 0));
1117
+ this.scanner.consume(expr.length + 1);
1118
+ return expr;
1119
+ }
1120
+
1121
+ /**
1122
+ * Parse font properties
1123
+ * @param ctx - The context to update
1124
+ */
1125
+ private parseFontProperties(ctx: MTextContext): void {
1126
+ const parts = this.extractExpression().split('|');
1127
+ if (parts.length > 0 && parts[0]) {
1128
+ const name = parts[0];
1129
+ let style: FontStyle = 'Regular';
1130
+ let weight = 400;
1131
+
1132
+ for (const part of parts.slice(1)) {
1133
+ if (part.startsWith('b1')) {
1134
+ weight = 700;
1135
+ } else if (part === 'i' || part.startsWith('i1')) {
1136
+ style = 'Italic';
1137
+ } else if (part === 'i0' || part.startsWith('i0')) {
1138
+ style = 'Regular';
1139
+ }
1140
+ }
1141
+
1142
+ ctx.fontFace = {
1143
+ family: name,
1144
+ style,
1145
+ weight,
1146
+ };
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * Parse paragraph properties from the MText content
1152
+ * Handles properties like indentation, alignment, and tab stops
1153
+ * @param ctx - The context to update
1154
+ */
1155
+ private parseParagraphProperties(ctx: MTextContext): void {
1156
+ const scanner = new TextScanner(this.extractExpression());
1157
+ /** Current indentation value */
1158
+ let indent = ctx.paragraph.indent;
1159
+ /** Left margin value */
1160
+ let left = ctx.paragraph.left;
1161
+ /** Right margin value */
1162
+ let right = ctx.paragraph.right;
1163
+ /** Current paragraph alignment */
1164
+ let align = ctx.paragraph.align;
1165
+ /** Array of tab stop positions and types */
1166
+ let tabStops: (number | string)[] = [];
1167
+
1168
+ /**
1169
+ * Parse a floating point number from the scanner's current position.
1170
+ * Accepts an optional sign, a leading decimal point, and scientific notation,
1171
+ * matching {@link extractFloatExpression}.
1172
+ * On a match, consumes the number and any immediately following commas.
1173
+ * @returns The parsed value, or 0 when the scanner is not on a number.
1174
+ * A miss consumes nothing, so looping callers must advance themselves.
1175
+ */
1176
+ const parseFloatValue = (): number => {
1177
+ const match = scanner.tail.match(/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
1178
+ if (match) {
1179
+ const value = parseFloat(match[0]);
1180
+ scanner.consume(match[0].length);
1181
+ while (scanner.peek() === ',') {
1182
+ scanner.consume(1);
1183
+ }
1184
+ return value;
1185
+ }
1186
+ return 0;
1187
+ };
1188
+
1189
+ while (scanner.hasData) {
1190
+ const cmd = scanner.get();
1191
+ switch (cmd) {
1192
+ case 'i': // Indentation
1193
+ indent = parseFloatValue();
1194
+ break;
1195
+ case 'l': // Left margin
1196
+ left = parseFloatValue();
1197
+ break;
1198
+ case 'r': // Right margin
1199
+ right = parseFloatValue();
1200
+ break;
1201
+ case 'x': // Skip
1202
+ break;
1203
+ case 'q': {
1204
+ // Alignment
1205
+ const adjustment = scanner.get();
1206
+ align = CHAR_TO_ALIGN[adjustment] || MTextParagraphAlignment.DEFAULT;
1207
+ while (scanner.peek() === ',') {
1208
+ scanner.consume(1);
1209
+ }
1210
+ break;
1211
+ }
1212
+ case 't': // Tab stops
1213
+ tabStops = [];
1214
+ while (scanner.hasData) {
1215
+ const indexBefore = scanner.currentIndex;
1216
+ const type = scanner.peek();
1217
+ if (type === 'r' || type === 'c') {
1218
+ scanner.consume(1);
1219
+ const valueIndex = scanner.currentIndex;
1220
+ const value = parseFloatValue();
1221
+ // 0 is also the miss result, so record a right/center stop only
1222
+ // when a number was consumed. Otherwise `\ptrz` becomes a fake
1223
+ // right tab at 0.
1224
+ if (scanner.currentIndex !== valueIndex) {
1225
+ tabStops.push(type + value.toString());
1226
+ }
1227
+ } else {
1228
+ const value = parseFloatValue();
1229
+ if (scanner.currentIndex !== indexBefore) {
1230
+ tabStops.push(value);
1231
+ }
1232
+ }
1233
+ // AutoCAD clears tab stops with `\pi0,l0,tz;`. `z` matches no
1234
+ // number and leaves the scanner in place; consume it or this
1235
+ // loop pushes zeros until it runs out of memory.
1236
+ if (scanner.currentIndex === indexBefore) {
1237
+ scanner.consume(1);
1238
+ }
1239
+ }
1240
+ break;
1241
+ }
1242
+ }
1243
+
1244
+ ctx.paragraph = {
1245
+ indent,
1246
+ left,
1247
+ right,
1248
+ align,
1249
+ tabs: tabStops,
1250
+ };
1251
+ }
1252
+
1253
+ /**
1254
+ * Consume optional terminator (semicolon)
1255
+ */
1256
+ private consumeOptionalTerminator(): void {
1257
+ if (this.scanner.peek() === ';') {
1258
+ this.scanner.consume(1);
1259
+ }
1260
+ }
1261
+
1262
+ /**
1263
+ * Builds {@link PercentSymbolData} for a recognized `%%` code letter.
1264
+ */
1265
+ private buildPercentSymbolData(code: string, specialChar: string): PercentSymbolData | null {
1266
+ if (code === 'c' || code === 'd' || code === 'p') {
1267
+ return { kind: 'named', code, char: specialChar };
1268
+ }
1269
+ if (code === '%') {
1270
+ return { kind: 'literal', char: '%' };
1271
+ }
1272
+ return null;
1273
+ }
1274
+
1275
+ /**
1276
+ * Parse MText content into tokens
1277
+ * @yields MTextToken objects
1278
+ */
1279
+ *parse(): Generator<MTextToken> {
1280
+ const wordToken = TokenType.WORD;
1281
+ const spaceToken = TokenType.SPACE;
1282
+ let followupToken: TokenType | null = null;
1283
+ let followupData: TokenData[TokenType] | undefined;
1284
+
1285
+ function resetParagraph(ctx: MTextContext): Partial<ParagraphProperties> {
1286
+ const prev = { ...ctx.paragraph };
1287
+ ctx.paragraph = {
1288
+ indent: 0,
1289
+ left: 0,
1290
+ right: 0,
1291
+ align: MTextParagraphAlignment.DEFAULT,
1292
+ tabs: [],
1293
+ };
1294
+ const changed: Partial<ParagraphProperties> = {};
1295
+ if (prev.indent !== 0) changed.indent = 0;
1296
+ if (prev.left !== 0) changed.left = 0;
1297
+ if (prev.right !== 0) changed.right = 0;
1298
+ if (prev.align !== MTextParagraphAlignment.DEFAULT)
1299
+ changed.align = MTextParagraphAlignment.DEFAULT;
1300
+ if (JSON.stringify(prev.tabs) !== JSON.stringify([])) changed.tabs = [];
1301
+ return changed;
1302
+ }
1303
+
1304
+ const nextToken = (): [TokenType, TokenData[TokenType]] => {
1305
+ let word = '';
1306
+ while (this.scanner.hasData) {
1307
+ let escape = false;
1308
+ let letter = this.scanner.peek();
1309
+ const cmdStartIndex = this.scanner.currentIndex;
1310
+
1311
+ // Handle control characters first. Flush any pending word before emitting
1312
+ // paragraph/tab tokens — the same pattern used for spaces and `\P` — so
1313
+ // LibreDWG/DXF converters that expand `\P` to raw `\n` do not drop text.
1314
+ if (letter.charCodeAt(0) < 32) {
1315
+ if (letter === '\t') {
1316
+ this.scanner.consume(1);
1317
+ if (word) {
1318
+ followupToken = TokenType.TABULATOR;
1319
+ followupData = null;
1320
+ return [wordToken, word];
1321
+ }
1322
+ return [TokenType.TABULATOR, null];
1323
+ }
1324
+ if (letter === '\n') {
1325
+ this.scanner.consume(1);
1326
+ if (word) {
1327
+ followupToken = TokenType.NEW_PARAGRAPH;
1328
+ followupData = null;
1329
+ return [wordToken, word];
1330
+ }
1331
+ return [TokenType.NEW_PARAGRAPH, null];
1332
+ }
1333
+ // Other C0 controls behave like a space delimiter.
1334
+ this.scanner.consume(1);
1335
+ if (word) {
1336
+ followupToken = spaceToken;
1337
+ return [wordToken, word];
1338
+ }
1339
+ return [spaceToken, null];
1340
+ }
1341
+
1342
+ if (letter === '\\') {
1343
+ if ('\\{}'.includes(this.scanner.peek(1))) {
1344
+ escape = true;
1345
+ this.scanner.consume(1);
1346
+ letter = this.scanner.peek();
1347
+ } else {
1348
+ if (word) {
1349
+ return [wordToken, word];
1350
+ }
1351
+ this.scanner.consume(1);
1352
+ const cmd = this.scanner.get();
1353
+ switch (cmd) {
1354
+ case '~':
1355
+ return [TokenType.NBSP, null];
1356
+ case 'P':
1357
+ return [TokenType.NEW_PARAGRAPH, null];
1358
+ case 'N':
1359
+ return [TokenType.NEW_COLUMN, null];
1360
+ case 'X':
1361
+ return [TokenType.WRAP_AT_DIMLINE, null];
1362
+ case 'S': {
1363
+ this.inStackContext = true;
1364
+ const result = this.parseStacking();
1365
+ this.inStackContext = false;
1366
+ return result;
1367
+ }
1368
+ case 'm':
1369
+ case 'M':
1370
+ // Handle multi-byte character encoding (MIF)
1371
+ if (this.scanner.peek() === '+') {
1372
+ this.scanner.consume(1); // Consume the '+'
1373
+ const hexCode = this.extractMifCode(this.mifCodeLength);
1374
+ if (hexCode) {
1375
+ this.scanner.consume(hexCode.length);
1376
+ const decodedChar = this.mifDecoder(hexCode);
1377
+ if (word) {
1378
+ return [wordToken, word];
1379
+ }
1380
+ return [wordToken, decodedChar];
1381
+ }
1382
+ // If no valid hex code found, rewind the '+' character
1383
+ this.scanner.consume(-1);
1384
+ }
1385
+ // If not a valid multi-byte code, treat as literal text
1386
+ word += '\\M';
1387
+ continue;
1388
+ case 'U': {
1389
+ // AutoCAD \U+nnnn uses exactly four hex digits. Characters
1390
+ // outside the BMP are encoded as two consecutive \U+XXXX
1391
+ // UTF-16 surrogate escapes.
1392
+ const decodedChar = this.parseUnicodeEscape();
1393
+ if (decodedChar !== null) {
1394
+ if (word) {
1395
+ return [wordToken, word];
1396
+ }
1397
+ return [wordToken, decodedChar];
1398
+ }
1399
+ // If not a valid Unicode code, treat as literal text
1400
+ word += '\\U';
1401
+ continue;
1402
+ }
1403
+ default:
1404
+ if (cmd) {
1405
+ try {
1406
+ const propertyChanges = this.parseProperties(cmd);
1407
+ if (this.yieldPropertyCommands && propertyChanges) {
1408
+ return [TokenType.PROPERTIES_CHANGED, propertyChanges];
1409
+ }
1410
+ // After processing a property command, continue with normal parsing
1411
+ continue;
1412
+ } catch {
1413
+ const commandText = this.scanner.tail.slice(
1414
+ cmdStartIndex,
1415
+ this.scanner.currentIndex
1416
+ );
1417
+ word += commandText;
1418
+ }
1419
+ }
1420
+ }
1421
+ continue;
1422
+ }
1423
+ }
1424
+
1425
+ if (letter === '%' && this.scanner.peek(1) === '%') {
1426
+ const code = this.scanner.peek(2).toLowerCase();
1427
+ const specialChar = SPECIAL_CHAR_ENCODING[code];
1428
+ if (specialChar) {
1429
+ this.scanner.consume(3);
1430
+ if (this.yieldPercentSymbols) {
1431
+ const symbolData = this.buildPercentSymbolData(code, specialChar);
1432
+ if (symbolData) {
1433
+ if (word) {
1434
+ followupToken = TokenType.PERCENT_SYMBOL;
1435
+ followupData = symbolData;
1436
+ return [wordToken, word];
1437
+ }
1438
+ return [TokenType.PERCENT_SYMBOL, symbolData];
1439
+ }
1440
+ }
1441
+ word += specialChar;
1442
+ continue;
1443
+ } else {
1444
+ /**
1445
+ * Supports Control Codes: `%%nnn`, where nnn is 1–3 decimal digits
1446
+ * for the character's Unicode/ASCII code point (e.g. `%%34` → `"`,
1447
+ * `%%176` → `°`). Digit count is not fixed at three.
1448
+ *
1449
+ * Reference: https://help.autodesk.com/view/ACD/2026/ENU/?guid=GUID-968CBC1D-BA99-4519-ABDD-88419EB2BF92
1450
+ */
1451
+ const digitChars: string[] = [];
1452
+ for (let i = 0; i < 3; i++) {
1453
+ const d = this.scanner.peek(2 + i);
1454
+ if (d >= '0' && d <= '9') {
1455
+ digitChars.push(d);
1456
+ } else {
1457
+ break;
1458
+ }
1459
+ }
1460
+
1461
+ if (digitChars.length > 0) {
1462
+ const charCode = Number.parseInt(digitChars.join(''), 10);
1463
+ this.scanner.consume(2 + digitChars.length);
1464
+ if (this.yieldPercentSymbols) {
1465
+ const symbolData: PercentSymbolData = {
1466
+ kind: 'numeric',
1467
+ charCode,
1468
+ char: String.fromCharCode(charCode),
1469
+ };
1470
+ if (word) {
1471
+ followupToken = TokenType.PERCENT_SYMBOL;
1472
+ followupData = symbolData;
1473
+ return [wordToken, word];
1474
+ }
1475
+ return [TokenType.PERCENT_SYMBOL, symbolData];
1476
+ }
1477
+ word += String.fromCharCode(charCode);
1478
+ } else {
1479
+ // Skip invalid special character codes (`%%` + non-digit)
1480
+ this.scanner.consume(3);
1481
+ }
1482
+
1483
+ continue;
1484
+ }
1485
+ }
1486
+
1487
+ if (letter === ' ') {
1488
+ if (word) {
1489
+ this.scanner.consume(1);
1490
+ followupToken = spaceToken;
1491
+ return [wordToken, word];
1492
+ }
1493
+ this.scanner.consume(1);
1494
+ return [spaceToken, null];
1495
+ }
1496
+
1497
+ if (!escape) {
1498
+ if (letter === '{') {
1499
+ if (word) {
1500
+ return [wordToken, word];
1501
+ }
1502
+ this.scanner.consume(1);
1503
+ this.pushCtx();
1504
+ continue;
1505
+ } else if (letter === '}') {
1506
+ if (word) {
1507
+ return [wordToken, word];
1508
+ }
1509
+ this.scanner.consume(1);
1510
+ // Context restoration with yieldPropertyCommands
1511
+ if (this.yieldPropertyCommands) {
1512
+ const prevCtx = this.ctxStack.current;
1513
+ this.popCtx();
1514
+ const changes = this.getPropertyChanges(prevCtx, this.ctxStack.current);
1515
+ if (Object.keys(changes).length > 0) {
1516
+ return [
1517
+ TokenType.PROPERTIES_CHANGED,
1518
+ { command: undefined, changes, depth: this.ctxStack.depth },
1519
+ ];
1520
+ }
1521
+ } else {
1522
+ this.popCtx();
1523
+ }
1524
+ continue;
1525
+ }
1526
+ }
1527
+
1528
+ // Handle caret-encoded characters only when not in stack context
1529
+ if (!this.inStackContext && letter === '^') {
1530
+ const nextChar = this.scanner.peek(1);
1531
+ if (nextChar) {
1532
+ const code = nextChar.charCodeAt(0);
1533
+ this.scanner.consume(2); // Consume both ^ and the next character
1534
+ if (code === 32) {
1535
+ // Space
1536
+ word += '^';
1537
+ } else if (code === 73) {
1538
+ // Tab
1539
+ if (word) {
1540
+ return [wordToken, word];
1541
+ }
1542
+ return [TokenType.TABULATOR, null];
1543
+ } else if (code === 74) {
1544
+ // Line feed
1545
+ if (word) {
1546
+ return [wordToken, word];
1547
+ }
1548
+ return [TokenType.NEW_PARAGRAPH, null];
1549
+ } else if (code === 77) {
1550
+ // Carriage return
1551
+ // Ignore carriage return
1552
+ continue;
1553
+ } else {
1554
+ word += '▯';
1555
+ }
1556
+ continue;
1557
+ }
1558
+ }
1559
+
1560
+ this.scanner.consume(1);
1561
+ if (letter.charCodeAt(0) >= 32) {
1562
+ word += letter;
1563
+ }
1564
+ }
1565
+
1566
+ if (word) {
1567
+ return [wordToken, word];
1568
+ }
1569
+ return [TokenType.NONE, null];
1570
+ };
1571
+
1572
+ const maybeResetAfterParagraph = (tokenType: TokenType) => {
1573
+ if (tokenType !== TokenType.NEW_PARAGRAPH || !this.resetParagraphParameters) {
1574
+ return;
1575
+ }
1576
+ // Reset paragraph properties and emit PROPERTIES_CHANGED if needed
1577
+ const ctx = this.ctxStack.current;
1578
+ const changed = resetParagraph(ctx);
1579
+ if (this.yieldPropertyCommands && Object.keys(changed).length > 0) {
1580
+ return new MTextToken(TokenType.PROPERTIES_CHANGED, ctx.copy(), {
1581
+ command: undefined,
1582
+ changes: { paragraph: changed },
1583
+ depth: this.ctxStack.depth,
1584
+ });
1585
+ }
1586
+ return;
1587
+ };
1588
+
1589
+ while (true) {
1590
+ const [type, data] = nextToken.call(this);
1591
+ if (type) {
1592
+ yield new MTextToken(type, this.ctxStack.current.copy(), data);
1593
+ const resetToken = maybeResetAfterParagraph(type);
1594
+ if (resetToken) {
1595
+ yield resetToken;
1596
+ }
1597
+ if (followupToken) {
1598
+ yield new MTextToken(followupToken, this.ctxStack.current.copy(), followupData ?? null);
1599
+ const followupResetToken = maybeResetAfterParagraph(followupToken);
1600
+ if (followupResetToken) {
1601
+ yield followupResetToken;
1602
+ }
1603
+ followupToken = null;
1604
+ followupData = undefined;
1605
+ }
1606
+ } else {
1607
+ break;
1608
+ }
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ /**
1614
+ * Text scanner for parsing MText content
1615
+ */
1616
+ export class TextScanner {
1617
+ private text: string;
1618
+ private textLen: number;
1619
+ private _index: number;
1620
+
1621
+ /**
1622
+ * Create a new text scanner
1623
+ * @param text - The text to scan
1624
+ */
1625
+ constructor(text: string) {
1626
+ this.text = text;
1627
+ this.textLen = text.length;
1628
+ this._index = 0;
1629
+ }
1630
+
1631
+ /**
1632
+ * Get the current index in the text
1633
+ */
1634
+ get currentIndex(): number {
1635
+ return this._index;
1636
+ }
1637
+
1638
+ /**
1639
+ * Check if the scanner has reached the end of the text
1640
+ */
1641
+ get isEmpty(): boolean {
1642
+ return this._index >= this.textLen;
1643
+ }
1644
+
1645
+ /**
1646
+ * Check if there is more text to scan
1647
+ */
1648
+ get hasData(): boolean {
1649
+ return this._index < this.textLen;
1650
+ }
1651
+
1652
+ /**
1653
+ * Get the next character and advance the index
1654
+ * @returns The next character, or empty string if at end
1655
+ */
1656
+ get(): string {
1657
+ if (this.isEmpty) {
1658
+ return '';
1659
+ }
1660
+ const char = this.text[this._index];
1661
+ this._index++;
1662
+ return char;
1663
+ }
1664
+
1665
+ /**
1666
+ * Advance the index by the specified count
1667
+ * @param count - Number of characters to advance
1668
+ */
1669
+ consume(count: number = 1): void {
1670
+ this._index = Math.max(0, Math.min(this._index + count, this.textLen));
1671
+ }
1672
+
1673
+ /**
1674
+ * Look at a character without advancing the index
1675
+ * @param offset - Offset from current position
1676
+ * @returns The character at the offset position, or empty string if out of bounds
1677
+ */
1678
+ peek(offset: number = 0): string {
1679
+ const index = this._index + offset;
1680
+ if (index >= this.textLen || index < 0) {
1681
+ return '';
1682
+ }
1683
+ return this.text[index];
1684
+ }
1685
+
1686
+ /**
1687
+ * Find the next occurrence of a character
1688
+ * @param char - The character to find
1689
+ * @param escape - Whether to handle escaped characters
1690
+ * @returns Index of the character, or -1 if not found
1691
+ */
1692
+ find(char: string, escape: boolean = false): number {
1693
+ let index = this._index;
1694
+ while (index < this.textLen) {
1695
+ if (escape && this.text[index] === '\\') {
1696
+ if (index + 1 < this.textLen) {
1697
+ if (this.text[index + 1] === char) {
1698
+ return index + 1;
1699
+ }
1700
+ index += 2;
1701
+ continue;
1702
+ }
1703
+ index++;
1704
+ continue;
1705
+ }
1706
+ if (this.text[index] === char) {
1707
+ return index;
1708
+ }
1709
+ index++;
1710
+ }
1711
+ return -1;
1712
+ }
1713
+
1714
+ /**
1715
+ * Get the remaining text from the current position
1716
+ */
1717
+ get tail(): string {
1718
+ return this.text.slice(this._index);
1719
+ }
1720
+
1721
+ /**
1722
+ * Check if the next character is a space
1723
+ */
1724
+ isNextSpace(): boolean {
1725
+ return this.peek() === ' ';
1726
+ }
1727
+
1728
+ /**
1729
+ * Consume spaces until a non-space character is found
1730
+ * @returns Number of spaces consumed
1731
+ */
1732
+ consumeSpaces(): number {
1733
+ let count = 0;
1734
+ while (this.isNextSpace()) {
1735
+ this.consume();
1736
+ count++;
1737
+ }
1738
+ return count;
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * Class to handle ACI and RGB color logic for MText.
1744
+ *
1745
+ * This class encapsulates color state for MText, supporting both AutoCAD Color Index (ACI) and RGB color.
1746
+ * Only one color mode is active at a time: setting an RGB color disables ACI, and vice versa.
1747
+ * RGB is stored as a single 24-bit integer (0xRRGGBB) for efficient comparison and serialization.
1748
+ *
1749
+ * Example usage:
1750
+ * ```ts
1751
+ * const color1 = new MTextColor(1); // ACI color
1752
+ * const color2 = new MTextColor([255, 0, 0]); // RGB color
1753
+ * const color3 = new MTextColor(); // Default (ACI=256, "by layer")
1754
+ * ```
1755
+ */
1756
+ export class MTextColor {
1757
+ /**
1758
+ * The AutoCAD Color Index (ACI) value. Only used if no RGB color is set.
1759
+ * @default 256 ("by layer")
1760
+ */
1761
+ private _aci: number | null = 256;
1762
+ /**
1763
+ * The RGB color value as a single 24-bit integer (0xRRGGBB), or null if not set.
1764
+ * @default null
1765
+ */
1766
+ private _rgbValue: number | null = null; // Store as 0xRRGGBB or null
1767
+
1768
+ /**
1769
+ * Create a new MTextColor instance.
1770
+ * @param color The initial color: number for ACI, [r,g,b] for RGB, or null/undefined for default (ACI=256).
1771
+ */
1772
+ constructor(color?: number | RGB | null) {
1773
+ if (Array.isArray(color)) {
1774
+ this.rgb = color;
1775
+ } else if (typeof color === 'number') {
1776
+ this.aci = color;
1777
+ } else {
1778
+ this.aci = 256;
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * Get the current ACI color value.
1784
+ * @returns The ACI color (0-256), or null if using RGB.
1785
+ */
1786
+ get aci(): number | null {
1787
+ return this._aci;
1788
+ }
1789
+
1790
+ /**
1791
+ * Set the ACI color value. Setting this disables any RGB color.
1792
+ * @param value The ACI color (0-256), or null to unset.
1793
+ * @throws Error if value is out of range.
1794
+ */
1795
+ set aci(value: number | null) {
1796
+ if (value === null) {
1797
+ this._aci = null;
1798
+ } else if (value >= 0 && value <= 256) {
1799
+ this._aci = value;
1800
+ this._rgbValue = null;
1801
+ } else {
1802
+ throw new Error('ACI not in range [0, 256]');
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Get the current RGB color as a tuple [r, g, b], or null if not set.
1808
+ * @returns The RGB color tuple, or null if using ACI.
1809
+ */
1810
+ get rgb(): RGB | null {
1811
+ if (this._rgbValue === null) return null;
1812
+ // Extract R, G, B from 0xRRGGBB
1813
+ const r = (this._rgbValue >> 16) & 0xff;
1814
+ const g = (this._rgbValue >> 8) & 0xff;
1815
+ const b = this._rgbValue & 0xff;
1816
+ return [r, g, b];
1817
+ }
1818
+
1819
+ /**
1820
+ * Set the RGB color. Setting this disables ACI color.
1821
+ * @param value The RGB color tuple [r, g, b], or null to use ACI.
1822
+ */
1823
+ set rgb(value: RGB | null) {
1824
+ if (value) {
1825
+ const [r, g, b] = value;
1826
+ this._rgbValue = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff);
1827
+ this._aci = null;
1828
+ } else {
1829
+ this._rgbValue = null;
1830
+ }
1831
+ }
1832
+
1833
+ /**
1834
+ * Returns true if the color is set by RGB, false if by ACI.
1835
+ */
1836
+ get isRgb(): boolean {
1837
+ return this._rgbValue !== null;
1838
+ }
1839
+
1840
+ /**
1841
+ * Returns true if the color is set by ACI, false if by RGB.
1842
+ */
1843
+ get isAci(): boolean {
1844
+ return this._rgbValue === null && this._aci !== null;
1845
+ }
1846
+
1847
+ /**
1848
+ * Get or set the internal RGB value as a number (0xRRGGBB), or null if not set.
1849
+ * Setting this will switch to RGB mode and set ACI to null.
1850
+ */
1851
+ get rgbValue(): number | null {
1852
+ return this._rgbValue;
1853
+ }
1854
+
1855
+ set rgbValue(val: number | null) {
1856
+ if (val === null) {
1857
+ this._rgbValue = null;
1858
+ } else {
1859
+ this._rgbValue = val & 0xffffff;
1860
+ this._aci = null;
1861
+ }
1862
+ }
1863
+
1864
+ /**
1865
+ * Returns a deep copy of this color.
1866
+ * @returns A new MTextColor instance with the same color state.
1867
+ */
1868
+ copy(): MTextColor {
1869
+ const c = new MTextColor();
1870
+ c._aci = this._aci;
1871
+ c._rgbValue = this._rgbValue;
1872
+ return c;
1873
+ }
1874
+
1875
+ /**
1876
+ * Returns a plain object for serialization.
1877
+ * @returns An object with aci, rgb (tuple), and rgbValue (number or null).
1878
+ */
1879
+ toObject(): { aci: number | null; rgb: RGB | null; rgbValue: number | null } {
1880
+ return { aci: this._aci, rgb: this.rgb, rgbValue: this._rgbValue };
1881
+ }
1882
+
1883
+ /**
1884
+ * Convert the current color to a CSS hex color string (#rrggbb).
1885
+ * Returns null if the color is ACI-based and has no RGB value.
1886
+ */
1887
+ toCssColor(): string | null {
1888
+ if (this._rgbValue !== null) {
1889
+ return colorNumberToHex(this._rgbValue);
1890
+ }
1891
+ return null;
1892
+ }
1893
+
1894
+ /**
1895
+ * Create an MTextColor from a CSS color string.
1896
+ * Supports #rgb, #rrggbb, rgb(...), rgba(...). Returns null if invalid or transparent.
1897
+ */
1898
+ static fromCssColor(value: string | null | undefined): MTextColor | null {
1899
+ const rgbValue = cssColorToRgbValue(value);
1900
+ if (rgbValue === null) return null;
1901
+ const color = new MTextColor();
1902
+ color.rgbValue = rgbValue;
1903
+ return color;
1904
+ }
1905
+
1906
+ /**
1907
+ * Equality check for color.
1908
+ * @param other The other MTextColor to compare.
1909
+ * @returns True if both ACI and RGB values are equal.
1910
+ */
1911
+ equals(other: MTextColor): boolean {
1912
+ return this._aci === other._aci && this._rgbValue === other._rgbValue;
1913
+ }
1914
+ }
1915
+
1916
+ /**
1917
+ * MText context class for managing text formatting state
1918
+ */
1919
+ export class MTextContext {
1920
+ private _stroke: number = 0;
1921
+ /** Whether to continue stroke formatting */
1922
+ continueStroke: boolean = false;
1923
+ /** Color (ACI or RGB) */
1924
+ color: MTextColor = new MTextColor();
1925
+ /** Line alignment */
1926
+ align: MTextLineAlignment = MTextLineAlignment.BOTTOM;
1927
+ /** Font face properties */
1928
+ fontFace: FontFace = { family: '', style: 'Regular', weight: 400 };
1929
+ /** Capital letter height */
1930
+ private _capHeight: FactorValue = { value: 1.0, isRelative: false };
1931
+ /** Character width factor */
1932
+ private _widthFactor: FactorValue = { value: 1.0, isRelative: false };
1933
+ /**
1934
+ * Character tracking factor a multiplier applied to the default spacing between characters in the MText object.
1935
+ * - Value = 1.0 → Normal spacing.
1936
+ * - Value < 1.0 → Characters are closer together.
1937
+ * - Value > 1.0 → Characters are spaced farther apart.
1938
+ */
1939
+ private _charTrackingFactor: FactorValue = { value: 1.0, isRelative: false };
1940
+ /** Oblique angle */
1941
+ oblique: number = 0.0;
1942
+ /** Paragraph properties */
1943
+ paragraph: ParagraphProperties = {
1944
+ indent: 0,
1945
+ left: 0,
1946
+ right: 0,
1947
+ align: MTextParagraphAlignment.DEFAULT,
1948
+ tabs: [],
1949
+ };
1950
+
1951
+ /**
1952
+ * Get the capital letter height
1953
+ */
1954
+ get capHeight(): FactorValue {
1955
+ return this._capHeight;
1956
+ }
1957
+
1958
+ /**
1959
+ * Set the capital letter height
1960
+ * @param value - Height value
1961
+ */
1962
+ set capHeight(value: FactorValue) {
1963
+ this._capHeight = {
1964
+ value: Math.abs(value.value),
1965
+ isRelative: value.isRelative,
1966
+ };
1967
+ }
1968
+
1969
+ /**
1970
+ * Get the character width factor
1971
+ */
1972
+ get widthFactor(): FactorValue {
1973
+ return this._widthFactor;
1974
+ }
1975
+
1976
+ /**
1977
+ * Set the character width factor
1978
+ * @param value - Width factor value
1979
+ */
1980
+ set widthFactor(value: FactorValue) {
1981
+ this._widthFactor = {
1982
+ value: Math.abs(value.value),
1983
+ isRelative: value.isRelative,
1984
+ };
1985
+ }
1986
+
1987
+ /**
1988
+ * Get the character tracking factor
1989
+ */
1990
+ get charTrackingFactor(): FactorValue {
1991
+ return this._charTrackingFactor;
1992
+ }
1993
+
1994
+ /**
1995
+ * Set the character tracking factor
1996
+ * @param value - Tracking factor value
1997
+ */
1998
+ set charTrackingFactor(value: FactorValue) {
1999
+ this._charTrackingFactor = {
2000
+ value: Math.abs(value.value),
2001
+ isRelative: value.isRelative,
2002
+ };
2003
+ }
2004
+
2005
+ /**
2006
+ * Get the ACI color value
2007
+ */
2008
+ get aci(): number | null {
2009
+ return this.color.aci;
2010
+ }
2011
+
2012
+ /**
2013
+ * Set the ACI color value
2014
+ * @param value - ACI color value (0-256)
2015
+ * @throws Error if value is out of range
2016
+ */
2017
+ set aci(value: number) {
2018
+ this.color.aci = value;
2019
+ }
2020
+
2021
+ /**
2022
+ * Get the RGB color value
2023
+ */
2024
+ get rgb(): RGB | null {
2025
+ return this.color.rgb;
2026
+ }
2027
+
2028
+ /**
2029
+ * Set the RGB color value
2030
+ */
2031
+ set rgb(value: RGB | null) {
2032
+ this.color.rgb = value;
2033
+ }
2034
+
2035
+ /**
2036
+ * Gets whether the current text should be rendered in italic style.
2037
+ * @returns {boolean} True if the font style is 'Italic', otherwise false.
2038
+ */
2039
+ get italic(): boolean {
2040
+ return this.fontFace.style === 'Italic';
2041
+ }
2042
+ /**
2043
+ * Sets whether the current text should be rendered in italic style.
2044
+ * @param value - If true, sets the font style to 'Italic'; if false, sets it to 'Regular'.
2045
+ */
2046
+ set italic(value: boolean) {
2047
+ this.fontFace.style = value ? 'Italic' : 'Regular';
2048
+ }
2049
+
2050
+ /**
2051
+ * Gets whether the current text should be rendered in bold style.
2052
+ * This is primarily used for mesh fonts and affects font selection.
2053
+ * @returns {boolean} True if the font weight is 700 or higher, otherwise false.
2054
+ */
2055
+ get bold(): boolean {
2056
+ return (this.fontFace.weight || 400) >= 700;
2057
+ }
2058
+ /**
2059
+ * Sets whether the current text should be rendered in bold style.
2060
+ * This is primarily used for mesh fonts and affects font selection.
2061
+ * @param value - If true, sets the font weight to 700; if false, sets it to 400.
2062
+ */
2063
+ set bold(value: boolean) {
2064
+ this.fontFace.weight = value ? 700 : 400;
2065
+ }
2066
+
2067
+ /**
2068
+ * Get whether text is underlined
2069
+ */
2070
+ get underline(): boolean {
2071
+ return Boolean(this._stroke & MTextStroke.UNDERLINE);
2072
+ }
2073
+
2074
+ /**
2075
+ * Set whether text is underlined
2076
+ * @param value - Whether to underline
2077
+ */
2078
+ set underline(value: boolean) {
2079
+ this._setStrokeState(MTextStroke.UNDERLINE, value);
2080
+ }
2081
+
2082
+ /**
2083
+ * Get whether text has strike-through
2084
+ */
2085
+ get strikeThrough(): boolean {
2086
+ return Boolean(this._stroke & MTextStroke.STRIKE_THROUGH);
2087
+ }
2088
+
2089
+ /**
2090
+ * Set whether text has strike-through
2091
+ * @param value - Whether to strike through
2092
+ */
2093
+ set strikeThrough(value: boolean) {
2094
+ this._setStrokeState(MTextStroke.STRIKE_THROUGH, value);
2095
+ }
2096
+
2097
+ /**
2098
+ * Get whether text has overline
2099
+ */
2100
+ get overline(): boolean {
2101
+ return Boolean(this._stroke & MTextStroke.OVERLINE);
2102
+ }
2103
+
2104
+ /**
2105
+ * Set whether text has overline
2106
+ * @param value - Whether to overline
2107
+ */
2108
+ set overline(value: boolean) {
2109
+ this._setStrokeState(MTextStroke.OVERLINE, value);
2110
+ }
2111
+
2112
+ /**
2113
+ * Check if any stroke formatting is active
2114
+ */
2115
+ get hasAnyStroke(): boolean {
2116
+ return Boolean(this._stroke);
2117
+ }
2118
+
2119
+ /**
2120
+ * Set the state of a stroke type
2121
+ * @param stroke - The stroke type to set
2122
+ * @param state - Whether to enable or disable the stroke
2123
+ */
2124
+ private _setStrokeState(stroke: MTextStroke, state: boolean = true): void {
2125
+ if (state) {
2126
+ this._stroke |= stroke;
2127
+ } else {
2128
+ this._stroke &= ~stroke;
2129
+ }
2130
+ }
2131
+
2132
+ /**
2133
+ * Create a copy of this context
2134
+ * @returns A new context with the same properties
2135
+ */
2136
+ copy(): MTextContext {
2137
+ const ctx = new MTextContext();
2138
+ ctx._stroke = this._stroke;
2139
+ ctx.continueStroke = this.continueStroke;
2140
+ ctx.color = this.color.copy();
2141
+ ctx.align = this.align;
2142
+ ctx.fontFace = { ...this.fontFace };
2143
+ ctx._capHeight = { ...this._capHeight };
2144
+ ctx._widthFactor = { ...this._widthFactor };
2145
+ ctx._charTrackingFactor = { ...this._charTrackingFactor };
2146
+ ctx.oblique = this.oblique;
2147
+ ctx.paragraph = { ...this.paragraph };
2148
+ return ctx;
2149
+ }
2150
+ }
2151
+
2152
+ /**
2153
+ * Token class for MText parsing
2154
+ */
2155
+ export class MTextToken {
2156
+ /**
2157
+ * Create a new MText token
2158
+ * @param type - The token type
2159
+ * @param ctx - The text context at this token
2160
+ * @param data - Optional token data
2161
+ */
2162
+ constructor(
2163
+ public type: TokenType,
2164
+ public ctx: MTextContext,
2165
+ public data: TokenData[TokenType]
2166
+ ) {}
2167
+ }