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