diff --git a/package.json b/package.json index 4d1c247..ea89059 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,51 @@ -{ - "name": "@mlightcad/mtext-parser", - "version": "1.4.1", - "description": "AutoCAD MText parser written in TypeScript", - "type": "module", - "main": "dist/parser.js", - "module": "dist/parser.js", - "types": "dist/types/parser.d.ts", - "author": "MLight Lee ", - "repository": { - "type": "git", - "url": "https://github.com/mlightcad/mtext-parser" - }, - "homepage": "https://github.com/mlightcad/mtext-parser", - "scripts": { - "build": "tsc -p tsconfig.build.json", - "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json}\"", - "lint": "eslint ./src --ext .ts", - "lint:fix": "eslint ./src --ext .ts --fix", - "release": "node tools/release.mjs", - "test": "jest" - }, - "keywords": [ - "autocad", - "dxf", - "mtext", - "parser" - ], - "license": "MIT", - "files": [ - "dist", - "src", - "README.md", - "package.json" - ], - "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^20.11.24", - "@typescript-eslint/eslint-plugin": "^8.57.0", - "@typescript-eslint/parser": "^8.57.0", - "eslint": "^9.26.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-prettier": "^5.4.0", - "jest": "^29.7.0", - "prettier": "^3.2.5", - "ts-jest": "^29.3.2", - "ts-node": "^10.9.2", - "typescript": "^5.3.3" - } -} +{ + "name": "@mlightcad/mtext-parser", + "version": "1.5.0", + "description": "AutoCAD MText parser written in TypeScript", + "type": "module", + "main": "dist/parser.js", + "module": "dist/parser.js", + "types": "dist/types/parser.d.ts", + "author": "MLight Lee ", + "repository": { + "type": "git", + "url": "https://github.com/mlightcad/mtext-parser" + }, + "homepage": "https://github.com/mlightcad/mtext-parser", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json}\"", + "lint": "eslint ./src --ext .ts", + "lint:fix": "eslint ./src --ext .ts --fix", + "release": "node tools/release.mjs", + "test": "jest" + }, + "keywords": [ + "autocad", + "dxf", + "mtext", + "parser" + ], + "license": "MIT", + "files": [ + "dist", + "src", + "README.md", + "package.json" + ], + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.11.24", + "@typescript-eslint/eslint-plugin": "^8.57.0", + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^9.26.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.4.0", + "jest": "^29.7.0", + "prettier": "^3.2.5", + "ts-jest": "^29.3.2", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/src/parser.test.ts b/src/parser.test.ts index eb26d76..91d915f 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -1,1897 +1,1978 @@ -import { - MTextParser, - MTextContext, - TokenType, - MTextLineAlignment, - MTextParagraphAlignment, - rgb2int, - int2rgb, - escapeDxfLineEndings, - hasInlineFormattingCodes, - TextScanner, - getFonts, - MTextColor, -} from './parser'; - -describe('Utility Functions', () => { - describe('rgb2int', () => { - it('converts RGB tuple to integer', () => { - expect(rgb2int([255, 0, 0])).toBe(0xff0000); - expect(rgb2int([0, 255, 0])).toBe(0x00ff00); - expect(rgb2int([0, 0, 255])).toBe(0x0000ff); - }); - }); - - describe('int2rgb', () => { - it('converts integer to RGB tuple', () => { - expect(int2rgb(0xff0000)).toEqual([255, 0, 0]); - expect(int2rgb(0x00ff00)).toEqual([0, 255, 0]); - expect(int2rgb(0x0000ff)).toEqual([0, 0, 255]); - }); - }); - - describe('escapeDxfLineEndings', () => { - it('escapes line endings', () => { - expect(escapeDxfLineEndings('line1\r\nline2')).toBe('line1\\Pline2'); - expect(escapeDxfLineEndings('line1\nline2')).toBe('line1\\Pline2'); - expect(escapeDxfLineEndings('line1\rline2')).toBe('line1\\Pline2'); - }); - }); - - describe('hasInlineFormattingCodes', () => { - it('detects inline formatting codes', () => { - expect(hasInlineFormattingCodes('\\L')).toBe(true); - expect(hasInlineFormattingCodes('\\P')).toBe(false); - expect(hasInlineFormattingCodes('\\~')).toBe(false); - expect(hasInlineFormattingCodes('normal text')).toBe(false); - }); - }); -}); - -describe('MTextContext', () => { - let ctx: MTextContext; - - beforeEach(() => { - ctx = new MTextContext(); - }); - - it('initializes with default values', () => { - expect(ctx.aci).toBe(256); - expect(ctx.rgb).toBeNull(); - expect(ctx.align).toBe(MTextLineAlignment.BOTTOM); - expect(ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); - expect(ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); - expect(ctx.widthFactor).toEqual({ value: 1.0, isRelative: false }); - expect(ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); - expect(ctx.oblique).toBe(0.0); - expect(ctx.paragraph).toEqual({ - indent: 0, - left: 0, - right: 0, - align: MTextParagraphAlignment.DEFAULT, - tabs: [], - }); - expect(ctx.bold).toBe(false); - expect(ctx.italic).toBe(false); - }); - - describe('italic and bold properties', () => { - it('should default to italic = false and bold = false', () => { - expect(ctx.italic).toBe(false); - expect(ctx.bold).toBe(false); - }); - - it('should set and get italic property', () => { - ctx.italic = true; - expect(ctx.italic).toBe(true); - expect(ctx.fontFace.style).toBe('Italic'); - ctx.italic = false; - expect(ctx.italic).toBe(false); - expect(ctx.fontFace.style).toBe('Regular'); - }); - - it('should set and get bold property', () => { - ctx.bold = true; - expect(ctx.bold).toBe(true); - expect(ctx.fontFace.weight).toBe(700); - ctx.bold = false; - expect(ctx.bold).toBe(false); - expect(ctx.fontFace.weight).toBe(400); - }); - - it('should reflect changes to fontFace.style and fontFace.weight', () => { - ctx.fontFace.style = 'Italic'; - expect(ctx.italic).toBe(true); - ctx.fontFace.style = 'Regular'; - expect(ctx.italic).toBe(false); - ctx.fontFace.weight = 700; - expect(ctx.bold).toBe(true); - ctx.fontFace.weight = 400; - expect(ctx.bold).toBe(false); - }); - }); - - describe('stroke properties', () => { - it('handles underline', () => { - ctx.underline = true; - expect(ctx.underline).toBe(true); - expect(ctx.hasAnyStroke).toBe(true); - ctx.underline = false; - expect(ctx.underline).toBe(false); - expect(ctx.hasAnyStroke).toBe(false); - }); - - it('handles overline', () => { - ctx.overline = true; - expect(ctx.overline).toBe(true); - expect(ctx.hasAnyStroke).toBe(true); - ctx.overline = false; - expect(ctx.overline).toBe(false); - expect(ctx.hasAnyStroke).toBe(false); - }); - - it('handles strike-through', () => { - ctx.strikeThrough = true; - expect(ctx.strikeThrough).toBe(true); - expect(ctx.hasAnyStroke).toBe(true); - ctx.strikeThrough = false; - expect(ctx.strikeThrough).toBe(false); - expect(ctx.hasAnyStroke).toBe(false); - }); - - it('handles multiple strokes', () => { - ctx.underline = true; - ctx.overline = true; - expect(ctx.hasAnyStroke).toBe(true); - ctx.underline = false; - expect(ctx.hasAnyStroke).toBe(true); - ctx.overline = false; - expect(ctx.hasAnyStroke).toBe(false); - }); - }); - - describe('color properties', () => { - it('handles ACI color', () => { - ctx.aci = 1; - expect(ctx.aci).toBe(1); - expect(ctx.rgb).toBeNull(); - expect(ctx.color.aci).toBe(1); - expect(ctx.color.rgb).toBeNull(); - expect(ctx.color.rgbValue).toBeNull(); - expect(() => (ctx.aci = 257)).toThrow('ACI not in range [0, 256]'); - }); - - it('handles RGB color', () => { - ctx.rgb = [255, 0, 0]; - expect(ctx.rgb).toEqual([255, 0, 0]); - expect(ctx.color.rgb).toEqual([255, 0, 0]); - expect(ctx.color.aci).toBeNull(); - expect(ctx.color.rgbValue).toBe(0xff0000); - }); - - it('switches from RGB to ACI', () => { - ctx.rgb = [255, 0, 0]; - ctx.aci = 2; - expect(ctx.rgb).toBeNull(); - expect(ctx.aci).toBe(2); - expect(ctx.color.rgb).toBeNull(); - expect(ctx.color.aci).toBe(2); - expect(ctx.color.rgbValue).toBeNull(); - }); - - it('handles RGB value set directly', () => { - ctx.color.rgbValue = 0x00ff00; - expect(ctx.rgb).toEqual([0, 255, 0]); - expect(ctx.color.rgb).toEqual([0, 255, 0]); - expect(ctx.color.rgbValue).toBe(0x00ff00); - expect(ctx.color.aci).toBeNull(); - }); - }); - - describe('copy', () => { - it('creates a deep copy', () => { - ctx.underline = true; - ctx.rgb = [255, 0, 0]; - const copy = ctx.copy(); - expect(copy).not.toBe(ctx); - expect(copy.underline).toBe(ctx.underline); - expect(copy.rgb).toEqual(ctx.rgb); - expect(copy.color.rgb).toEqual(ctx.color.rgb); - expect(copy.color.aci).toBe(ctx.color.aci); - expect(copy.color.rgbValue).toBe(ctx.color.rgbValue); - expect(copy.fontFace).toEqual(ctx.fontFace); - expect(copy.paragraph).toEqual(ctx.paragraph); - // Changing the copy's color should not affect the original - copy.rgb = [0, 255, 0]; - expect(ctx.rgb).toEqual([255, 0, 0]); - expect(copy.rgb).toEqual([0, 255, 0]); - expect(copy.color.rgbValue).toBe(0x00ff00); - expect(ctx.color.rgbValue).toBe(0xff0000); - }); - }); - - describe('factor properties', () => { - it('handles charTrackingFactor absolute values', () => { - ctx.charTrackingFactor = { value: 2.0, isRelative: false }; - expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false }); - - ctx.charTrackingFactor = { value: 0.5, isRelative: false }; - expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: false }); - }); - - it('handles charTrackingFactor relative values', () => { - ctx.charTrackingFactor = { value: 2.0, isRelative: true }; - expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: true }); - - ctx.charTrackingFactor = { value: 0.5, isRelative: true }; - expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); - }); - - it('converts negative values to positive for charTrackingFactor', () => { - ctx.charTrackingFactor = { value: -2.0, isRelative: false }; - expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false }); - - ctx.charTrackingFactor = { value: -0.5, isRelative: true }; - expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); - }); - }); -}); - -describe('MTextParser', () => { - describe('basic parsing', () => { - it('parses plain text', () => { - const parser = new MTextParser('Hello World'); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(3); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Hello'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('World'); - }); - - it('parses spaces', () => { - const parser = new MTextParser('Hello World'); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(3); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Hello'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('World'); - }); - - it('parses text starting with control characters', () => { - // Test with newline - let parser = new MTextParser('\nHello World'); - let tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(4); - expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Hello'); - expect(tokens[2].type).toBe(TokenType.SPACE); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('World'); - - // Test with tab - parser = new MTextParser('\tHello World'); - tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(4); - expect(tokens[0].type).toBe(TokenType.TABULATOR); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Hello'); - expect(tokens[2].type).toBe(TokenType.SPACE); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('World'); - - // Test with multiple control characters - parser = new MTextParser('\n\tHello World'); - tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(5); - expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[1].type).toBe(TokenType.TABULATOR); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Hello'); - expect(tokens[3].type).toBe(TokenType.SPACE); - expect(tokens[4].type).toBe(TokenType.WORD); - expect(tokens[4].data).toBe('World'); - }); - - it('parses new paragraphs', () => { - const parser = new MTextParser('Line 1\\PLine 2'); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(7); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Line'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('1'); - expect(tokens[3].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[4].type).toBe(TokenType.WORD); - expect(tokens[4].data).toBe('Line'); - expect(tokens[5].type).toBe(TokenType.SPACE); - expect(tokens[6].type).toBe(TokenType.WORD); - expect(tokens[6].data).toBe('2'); - }); - }); - - describe('formatting', () => { - it('parses underline', () => { - const parser = new MTextParser('\\LUnderlined\\l'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Underlined'); - expect(tokens[0].ctx.underline).toBe(true); - }); - - it('parses color', () => { - const parser = new MTextParser('\\C1Red Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Red'); - expect(tokens[0].ctx.aci).toBe(1); - }); - - it('parses font properties', () => { - const parser = new MTextParser('\\FArial|b1|i1;Bold Italic'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Bold'); - expect(tokens[0].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - }); - - describe('height command', () => { - it('parses absolute height values', () => { - const parser = new MTextParser('\\H2.5;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - }); - - it('parses relative height values with x suffix', () => { - const parser = new MTextParser('\\H2.5x;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: true }); - }); - - it('handles optional terminator', () => { - const parser = new MTextParser('\\H2.5Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles leading signs', () => { - let parser = new MTextParser('\\H-2.5;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored - - parser = new MTextParser('\\H+2.5;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles decimal values without leading zero', () => { - let parser = new MTextParser('\\H.5x;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); - - parser = new MTextParser('\\H-.5x;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored - - parser = new MTextParser('\\H+.5x;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); - }); - - it('handles exponential notation', () => { - let parser = new MTextParser('\\H1e2;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 100, isRelative: false }); - - parser = new MTextParser('\\H1e-2;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.01, isRelative: false }); - - parser = new MTextParser('\\H.5e2;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: false }); - - parser = new MTextParser('\\H.5e-2;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.005, isRelative: false }); - }); - - it('handles invalid floating point values', () => { - let parser = new MTextParser('\\H1..5;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('.5;Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value - - parser = new MTextParser('\\H1e;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('e;Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value - - parser = new MTextParser('\\H1e+;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('e+;Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value - }); - - it('handles complex height expressions', () => { - let parser = new MTextParser('\\H+1.5e-1x;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 0.15, isRelative: true }); - - parser = new MTextParser('\\H-.5e+2x;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: true }); // Negative values are ignored - }); - - it('handles multiple height commands', () => { - const parser = new MTextParser('\\H2.5;First\\H.5x;Second'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('First'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Second'); - expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); - }); - - it('handles height command with no value', () => { - const parser = new MTextParser('\\H;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value - }); - }); - - describe('width command', () => { - it('parses absolute width values', () => { - const parser = new MTextParser('\\W2.5;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('parses relative width values with x suffix', () => { - const parser = new MTextParser('\\W2.5x;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: true }); - }); - - it('handles optional terminator', () => { - const parser = new MTextParser('\\W2.5Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles leading signs', () => { - let parser = new MTextParser('\\W-2.5;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored - - parser = new MTextParser('\\W+2.5;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles decimal values without leading zero', () => { - let parser = new MTextParser('\\W.5x;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); - - parser = new MTextParser('\\W-.5x;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored - }); - - it('handles multiple width commands', () => { - const parser = new MTextParser('\\W2.5;First\\W.5x;Second'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('First'); - expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Second'); - expect(tokens[1].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); - }); - }); - - describe('character tracking command', () => { - it('parses absolute tracking values', () => { - const parser = new MTextParser('\\T2.5;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('parses relative tracking values with x suffix', () => { - const parser = new MTextParser('\\T2.5x;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: true }); - }); - - it('handles optional terminator', () => { - const parser = new MTextParser('\\T2.5Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles leading signs', () => { - let parser = new MTextParser('\\T-2.5;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored - - parser = new MTextParser('\\T+2.5;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); - }); - - it('handles decimal values without leading zero', () => { - let parser = new MTextParser('\\T.5x;Text'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); - - parser = new MTextParser('\\T-.5x;Text'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored - }); - - it('handles multiple tracking commands', () => { - const parser = new MTextParser('\\T2.5;First\\T.5x;Second'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('First'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Second'); - expect(tokens[1].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); - }); - - it('handles tracking command with no value', () => { - const parser = new MTextParser('\\T;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); // Default value - }); - }); - - describe('oblique command', () => { - it('parses positive oblique angle', () => { - const parser = new MTextParser('\\Q15;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.oblique).toBe(15); - }); - - it('parses negative oblique angle', () => { - const parser = new MTextParser('\\Q-15;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.oblique).toBe(-15); - }); - - it('handles optional terminator', () => { - const parser = new MTextParser('\\Q15Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.oblique).toBe(15); - }); - - it('handles decimal values', () => { - const parser = new MTextParser('\\Q15.5;Text'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - expect(tokens[0].ctx.oblique).toBe(15.5); - }); - - it('handles multiple oblique commands', () => { - const parser = new MTextParser('\\Q15;First\\Q-30;Second'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('First'); - expect(tokens[0].ctx.oblique).toBe(15); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Second'); - expect(tokens[1].ctx.oblique).toBe(-30); - }); - }); - - describe('special encoded characters', () => { - it('renders diameter symbol (%%c)', () => { - let parser = new MTextParser('%%cText'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('ØText'); - - parser = new MTextParser('%%CText'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('ØText'); - }); - - it('renders degree symbol (%%d)', () => { - let parser = new MTextParser('%%dText'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('°Text'); - - parser = new MTextParser('%%DText'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('°Text'); - }); - - it('renders plus-minus symbol (%%p)', () => { - let parser = new MTextParser('%%pText'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('±Text'); - - parser = new MTextParser('%%PText'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('±Text'); - }); - - it('handles multiple special characters in sequence', () => { - const parser = new MTextParser('%%c%%d%%pText'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('ذ±Text'); - }); - - it('handles special characters with spaces', () => { - const parser = new MTextParser('%%c %%d %%p Text'); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(7); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Ø'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('°'); - expect(tokens[3].type).toBe(TokenType.SPACE); - expect(tokens[4].type).toBe(TokenType.WORD); - expect(tokens[4].data).toBe('±'); - expect(tokens[5].type).toBe(TokenType.SPACE); - expect(tokens[6].type).toBe(TokenType.WORD); - expect(tokens[6].data).toBe('Text'); - }); - - it('handles special characters with formatting', () => { - const parser = new MTextParser('\\H2.5;%%c\\H.5x;%%d%%p'); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(2); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Ø'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('°±'); - expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); - }); - - it('handles invalid special character codes', () => { - const parser = new MTextParser('%%x%%y%%zText'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Text'); - }); - }); - }); - - describe('GBK character encoding', () => { - it('decodes GBK hex codes', () => { - // Test "你" (C4E3 in GBK) - let parser = new MTextParser('\\M+C4E3', undefined, { yieldPropertyCommands: false }); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - - // Test "好" (BAC3 in GBK) - parser = new MTextParser('\\M+BAC3', undefined, { yieldPropertyCommands: false }); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('好'); - - // Test multiple GBK characters - parser = new MTextParser('\\M+C4E3\\M+BAC3', undefined, { yieldPropertyCommands: false }); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('好'); - }); - - it('handles invalid GBK codes', () => { - // Test invalid hex code - let parser = new MTextParser('\\M+XXXX', undefined, { yieldPropertyCommands: false }); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('\\M+XXXX'); - - // Test incomplete hex code - parser = new MTextParser('\\M+C4', undefined, { yieldPropertyCommands: false }); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('\\M+C4'); - - // Test missing plus sign - parser = new MTextParser('\\MC4E3', undefined, { yieldPropertyCommands: false }); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('\\MC4E3'); - }); - - it('handles GBK characters with other formatting', () => { - // Test GBK characters with height command - const parser = new MTextParser('\\H2.5;\\M+C4E3\\H.5x;\\M+BAC3', undefined, { - yieldPropertyCommands: false, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(2); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('好'); - expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); - }); - - it('handles GBK characters with font properties', () => { - const parser = new MTextParser('{\\fgbcbig.shx|b0|i0|c0|p0;\\M+C4E3\\M+BAC3}', undefined, { - yieldPropertyCommands: false, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('好'); - expect(tokens[0].ctx.fontFace).toEqual({ - family: 'gbcbig.shx', - style: 'Regular', - weight: 400, - }); - expect(tokens[1].ctx.fontFace).toEqual({ - family: 'gbcbig.shx', - style: 'Regular', - weight: 400, - }); - }); - }); - - describe('MIF (Multibyte Interchange Format) with custom decoder', () => { - it('uses custom decoder when provided', () => { - const customDecoder = (hex: string) => { - // Custom decoder that reverses the hex and converts to char - const reversed = hex.split('').reverse().join(''); - const codePoint = parseInt(reversed, 16); - return String.fromCodePoint(codePoint); - }; - - const parser = new MTextParser('\\M+C4E3', undefined, { - mifDecoder: customDecoder, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - // The custom decoder will produce different output - expect(tokens[0].data).not.toBe('\\M+C4E3'); - }); - - it('parses 5-digit MIF codes with auto-detect', () => { - // Use default decoder with auto-detect - const parser = new MTextParser('\\M+1A2B3', undefined, { - mifCodeLength: 'auto', - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - // Should successfully parse 5-digit code - expect(tokens[0].data).not.toBe('\\M+1A2B3'); - }); - - it('parses 5-digit MIF codes when specified', () => { - const parser = new MTextParser('\\M+1A2B3', undefined, { - mifCodeLength: 5, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).not.toBe('\\M+1A2B3'); - }); - - it('parses 4-digit MIF codes when specified', () => { - const parser = new MTextParser('\\M+C4E3', undefined, { - mifCodeLength: 4, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - }); - - it('falls back to 4-digit when 5-digit not available', () => { - const parser = new MTextParser('\\M+C4E3', undefined, { - mifCodeLength: 'auto', - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('你'); - }); - - it('uses custom decoder with specific code length', () => { - const customDecoder = (hex: string) => `[DECODED:${hex}]`; - const parser = new MTextParser('\\M+C4E3', undefined, { - mifDecoder: customDecoder, - mifCodeLength: 4, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('[DECODED:C4E3]'); - }); - - it('parses complex MText with 5-digit MIF codes and Unicode', () => { - // Test data: \M+1928D:\M+18DD1\M+197702.0t\U+9540\U+950C\M+194C2\M+190A7\M+18DEC\M+18142 - // According to user, \M+19770 should be parsed as 5-digit code, leaving "2.0t" as separate characters - // But the parser's auto-detect logic tries 5 digits first, which consumes "19770" - // So "2" becomes part of the next sequence - const mtext = - '\\M+1928D:\\M+18DD1\\M+197702.0t\\U+9540\\U+950C\\M+194C2\\M+190A7\\M+18DEC\\M+18142'; - const parser = new MTextParser(mtext, undefined, { - mifCodeLength: 'auto', - }); - const tokens = Array.from(parser.parse()); - - // Should parse without errors and generate tokens - // The parser produces 11 tokens: each MIF code and Unicode becomes one token, and "2.0t" becomes a single token - expect(tokens.length).toBe(11); - - // Verify the decoded characters are valid - const wordTokens = tokens.filter(t => t.type === TokenType.WORD); - expect(wordTokens.length).toBeGreaterThan(0); - - // Note: 5-digit MIF codes return placeholder character '▯' as per decodeMultiByteChar implementation - // Only verify that Unicode characters (4-digit hex) are decoded properly - const unicodeTokens = wordTokens.filter(t => t.data && t.data !== '▯'); - expect(unicodeTokens.length).toBeGreaterThan(0); - }); - - it('decodes specific 5-digit MIF codes correctly', () => { - // Test individual 5-digit MIF codes from the provided test data - const testCases = [ - { code: '1928D', decoded: '注' }, - { code: '18DD1', decoded: '采' }, - { code: '19770', decoded: '用' }, - { code: '194C2', decoded: '板' }, - { code: '190A7', decoded: '制' }, - { code: '18DEC', decoded: '作' }, - { code: '18142', decoded: '。' }, - ]; - - testCases.forEach(({ code, decoded }) => { - const parser = new MTextParser(`\\M+${code}`, undefined, { - mifCodeLength: 5, - }); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - // Note: decodeMultiByteChar returns '▯' for 5-digit codes - if (tokens[0].data && typeof tokens[0].data === 'string') { - expect(tokens[0].data).toBe(decoded); - } - }); - }); - }); - - describe('Unicode (\\U+XXXX) escape sequences', () => { - it('decodes Unicode BMP and supplementary plane code points', () => { - // Greek capital omega: \U+03A9 - let parser = new MTextParser('Omega: \\U+03A9'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Omega:'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Ω'); - - // Emoji: \U+1F600 (grinning face) - parser = new MTextParser('Smile: \\U+1F600'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Smile:'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('😀'); - }); - - it('handles invalid or incomplete Unicode escapes as literal text', () => { - // Not enough hex digits - let parser = new MTextParser('Test: \\U+12'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Test:'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('\\U+12'); - - // Invalid hex - parser = new MTextParser('Test: \\U+ZZZZ'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Test:'); - expect(tokens[1].type).toBe(TokenType.SPACE); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('\\U+ZZZZ'); - }); - }); - - describe('stacking', () => { - it('parses basic fractions with different dividers', () => { - let parser = new MTextParser('\\S1/2;'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2', '/']); - - parser = new MTextParser('\\S1#2;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2', '#']); - }); - - it('handles caret for baseline alignment', () => { - let parser = new MTextParser('\\S1^2;'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2', '^']); - - // Test with spaces - parser = new MTextParser('\\S1 2^3 4;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1 2', '3 4', '^']); - - // Test with escaped characters - parser = new MTextParser('\\S1^2\\;;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2;', '^']); - }); - - it('handles spaces in numerator and denominator', () => { - const parser = new MTextParser('\\S1 2/3 4;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1 2', '3 4', '/']); - }); - - it('handles spaces after / and # dividers', () => { - let parser = new MTextParser('\\S1/ 2;'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', ' 2', '/']); - - parser = new MTextParser('\\S1# 2;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', ' 2', '#']); - }); - - it('handles escaped terminator', () => { - const parser = new MTextParser('\\S1/2\\;;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2;', '/']); - }); - - it('ignores backslashes except for escaped terminator', () => { - const parser = new MTextParser('\\S\\N^ \\P;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['N', 'P', '^']); - }); - - it('renders grouping chars as simple braces', () => { - const parser = new MTextParser('\\S{1}/2;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['{1}', '2', '/']); - }); - - it('treats carets in stack formatting as literal text', () => { - let parser = new MTextParser('\\S^I/^J;'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual([' ', ' ', '/']); - - parser = new MTextParser('\\Sabc^def;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['abc', 'def', '^']); - }); - - it('handles subscript and superscript', () => { - // Subscript - let parser = new MTextParser('abc\\S^ 1;'); - let tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toEqual('abc'); - expect(tokens[1].type).toBe(TokenType.STACK); - expect(tokens[1].data).toEqual(['', '1', '^']); - - // Superscript - parser = new MTextParser('abc\\S1^ ;'); - tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toEqual('abc'); - expect(tokens[1].type).toBe(TokenType.STACK); - expect(tokens[1].data).toEqual(['1', '', '^']); - }); - - it('handles multiple divider chars', () => { - const parser = new MTextParser('\\S1/2/3;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2/3', '/']); - }); - - it('requires terminator for command end', () => { - const parser = new MTextParser('\\S1/2'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1', '2', '/']); - }); - - it('handles complex fractions', () => { - const parser = new MTextParser('\\S1 2/3 4^ 5 6;'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.STACK); - expect(tokens[0].data).toEqual(['1 2', '3 4^ 5 6', '/']); - }); - }); - - describe('paragraph properties', () => { - it('parses indentation', () => { - const parser = new MTextParser('\\pi2;Indented'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Indented'); - expect(tokens[0].ctx.paragraph.indent).toBe(2); - }); - - it('parses alignment', () => { - const parser = new MTextParser('\\pqc;Centered'); - const tokens = Array.from(parser.parse()); - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Centered'); - expect(tokens[0].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); - }); - - it('switches alignment', () => { - const mtext = - 'Line1: {\\pql;Left aligned paragraph.}\\PLine2: {\\pqc;Center aligned paragraph.} Middle\\PLine3: {\\pql;Back to left.} {End}'; - const ctx = new MTextContext(); - ctx.fontFace.family = 'simkai'; - ctx.capHeight = { value: 0.1, isRelative: true }; - ctx.widthFactor = { value: 1.0, isRelative: true }; - ctx.align = MTextLineAlignment.BOTTOM; - ctx.paragraph.align = MTextParagraphAlignment.LEFT; - const parser = new MTextParser(mtext, ctx, { yieldPropertyCommands: true }); - const tokens = Array.from(parser.parse()); - // Filter for word tokens - const wordTokens = tokens.filter(t => t.type === TokenType.WORD); - const expected = [ - // Paragraph 1 (LEFT) - { data: 'Line1:', align: MTextParagraphAlignment.LEFT }, - { data: 'Left', align: MTextParagraphAlignment.LEFT }, - { data: 'aligned', align: MTextParagraphAlignment.LEFT }, - { data: 'paragraph.', align: MTextParagraphAlignment.LEFT }, - // Paragraph 2 (CENTER) - { data: 'Line2:', align: MTextParagraphAlignment.LEFT }, - { data: 'Center', align: MTextParagraphAlignment.CENTER }, - { data: 'aligned', align: MTextParagraphAlignment.CENTER }, - { data: 'paragraph.', align: MTextParagraphAlignment.CENTER }, - { data: 'Middle', align: MTextParagraphAlignment.CENTER }, - // Paragraph 3 (LEFT) - { data: 'Line3:', align: MTextParagraphAlignment.CENTER }, - { data: 'Back', align: MTextParagraphAlignment.LEFT }, - { data: 'to', align: MTextParagraphAlignment.LEFT }, - { data: 'left.', align: MTextParagraphAlignment.LEFT }, - { data: 'End', align: MTextParagraphAlignment.LEFT }, - ]; - expect(wordTokens).toHaveLength(expected.length); - for (let i = 0; i < expected.length; i++) { - console.log(wordTokens[i]); - expect(wordTokens[i].data).toBe(expected[i].data); - expect(wordTokens[i].ctx.paragraph.align).toBe(expected[i].align); - } - }); - }); - - describe('property commands with yieldPropertyCommands', () => { - it('yields property change tokens for formatting commands', () => { - const parser = new MTextParser('\\LUnderlined\\l', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(3); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'L', - changes: { - underline: true, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Underlined'); - expect(tokens[1].ctx.underline).toBe(true); - expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[2].data).toEqual({ - command: 'l', - changes: { - underline: false, - }, - depth: 0, - }); - expect(tokens[2].ctx.underline).toBe(false); - }); - - it('yields property change tokens for color commands', () => { - const parser = new MTextParser('\\C1Red Text', undefined, { yieldPropertyCommands: true }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(4); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'C', - changes: { - aci: 1, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Red'); - expect(tokens[1].ctx.aci).toBe(1); - expect(tokens[2].type).toBe(TokenType.SPACE); - expect(tokens[2].ctx.aci).toBe(1); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Text'); - expect(tokens[3].ctx.aci).toBe(1); - }); - - it('yields property change tokens for font properties', () => { - const parser = new MTextParser('\\FArial|b1|i1;Bold Italic', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(4); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'F', - changes: { - fontFace: { - family: 'Arial', - style: 'Italic', - weight: 700, - }, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Bold'); - expect(tokens[1].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - expect(tokens[2].type).toBe(TokenType.SPACE); - expect(tokens[2].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Italic'); - expect(tokens[3].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - }); - - it('yields property change tokens for height command', () => { - const parser = new MTextParser('\\H2.5;Text', undefined, { yieldPropertyCommands: true }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(2); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'H', - changes: { - capHeight: { value: 2.5, isRelative: false }, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Text'); - expect(tokens[1].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - }); - - it('yields property change tokens for multiple commands', () => { - const parser = new MTextParser('\\H2.5;\\C1;\\LText\\l', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(5); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'H', - changes: { - capHeight: { value: 2.5, isRelative: false }, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[1].data).toEqual({ - command: 'C', - changes: { - aci: 1, - }, - depth: 0, - }); - expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[2].data).toEqual({ - command: 'L', - changes: { - underline: true, - }, - depth: 0, - }); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Text'); - expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[3].ctx.underline).toBe(true); - expect(tokens[4].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[4].data).toEqual({ - command: 'l', - changes: { - underline: false, - }, - depth: 0, - }); - }); - - it('yields property change tokens for paragraph properties', () => { - const parser = new MTextParser('\\pi2;\\pqc;Indented Centered', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(5); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'p', - changes: { - paragraph: { - indent: 2, - }, - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[1].data).toEqual({ - command: 'p', - changes: { - paragraph: { - align: MTextParagraphAlignment.CENTER, - }, - }, - depth: 0, - }); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Indented'); - expect(tokens[2].ctx.paragraph.indent).toBe(2); - expect(tokens[2].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); - expect(tokens[3].type).toBe(TokenType.SPACE); - expect(tokens[3].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); - expect(tokens[4].type).toBe(TokenType.WORD); - expect(tokens[4].data).toBe('Centered'); - expect(tokens[4].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); - }); - - it('yields property change tokens for complex formatting', () => { - const parser = new MTextParser('{\\H2.5;\\C1;\\FArial|b1|i1;Formatted Text}', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(7); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'H', - changes: { - capHeight: { value: 2.5, isRelative: false }, - }, - depth: 1, - }); - expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[1].data).toEqual({ - command: 'C', - changes: { - aci: 1, - }, - depth: 1, - }); - expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[2].data).toEqual({ - command: 'F', - changes: { - fontFace: { - family: 'Arial', - style: 'Italic', - weight: 700, - }, - }, - depth: 1, - }); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Formatted'); - expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); - expect(tokens[3].ctx.aci).toBe(1); - expect(tokens[3].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - expect(tokens[4].type).toBe(TokenType.SPACE); - expect(tokens[4].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - expect(tokens[5].type).toBe(TokenType.WORD); - expect(tokens[5].data).toBe('Text'); - expect(tokens[5].ctx.fontFace).toEqual({ - family: 'Arial', - style: 'Italic', - weight: 700, - }); - expect(tokens[6].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[6].data).toEqual({ - command: undefined, - changes: { - aci: 256, - capHeight: { value: 1, isRelative: false }, - fontFace: { family: '', style: 'Regular', weight: 400 }, - }, - depth: 0, - }); - }); - }); - - describe('MTextParser context restoration with braces {}', () => { - it('scopes font formatting to braces and restores after', () => { - const parser = new MTextParser('Normal {\\fArial|i;Italic} Back to normal'); - const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); - expect(tokens[0].data).toBe('Normal'); - expect(tokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); - expect(tokens[1].data).toBe('Italic'); - expect(tokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 }); - expect(tokens[2].data).toBe('Back'); - expect(tokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); - }); - - it('scopes color formatting to braces and restores after', () => { - const parser = new MTextParser('{\\C1;Red} Normal'); - const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); - expect(tokens[0].data).toBe('Red'); - expect(tokens[0].ctx.aci).toBe(1); - expect(tokens[1].data).toBe('Normal'); - expect(tokens[1].ctx.aci).toBe(256); // default - }); - - it('restores previous formatting after a formatting block', () => { - const parser = new MTextParser('\\C2;Before {\\C1;Red} After'); - const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); - expect(tokens[0].data).toBe('Before'); - expect(tokens[0].ctx.aci).toBe(2); - expect(tokens[1].data).toBe('Red'); - expect(tokens[1].ctx.aci).toBe(1); - expect(tokens[2].data).toBe('After'); - expect(tokens[2].ctx.aci).toBe(2); - }); - - it('restores context correctly with nested braces', () => { - const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}'); - const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); - expect(tokens[0].data).toBe('Red'); - expect(tokens[0].ctx.aci).toBe(1); - expect(tokens[1].data).toBe('Blue'); - expect(tokens[1].ctx.aci).toBe(2); - expect(tokens[2].data).toBe('RedAgain'); - expect(tokens[2].ctx.aci).toBe(1); - }); - - it('persists formatting outside braces if not reset', () => { - const parser = new MTextParser('\\C3;All {\\C1;Red} StillAll'); - const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); - expect(tokens[0].data).toBe('All'); - expect(tokens[0].ctx.aci).toBe(3); - expect(tokens[1].data).toBe('Red'); - expect(tokens[1].ctx.aci).toBe(1); - expect(tokens[2].data).toBe('StillAll'); - expect(tokens[2].ctx.aci).toBe(3); - }); - }); - - describe('MTextParser context restoration with braces {} and yieldPropertyCommands', () => { - it('yields property change tokens when entering and exiting a formatting block', () => { - const parser = new MTextParser('Normal {\\fArial|i;Italic} Back', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - // Filter for property changes and words - const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); - const wordTokens = tokens.filter(t => t.type === TokenType.WORD); - // Should yield a property change for entering Arial Italic - expect(propTokens[0].data).toEqual({ - command: 'f', - changes: { fontFace: { family: 'Arial', style: 'Italic', weight: 400 } }, - depth: 1, - }); - // Should yield a property change for restoring default font after block - expect(propTokens[propTokens.length - 1].data).toEqual({ - command: undefined, - changes: { fontFace: { family: '', style: 'Regular', weight: 400 } }, - depth: 0, - }); - // Check word tokens context - expect(wordTokens[0].data).toBe('Normal'); - expect(wordTokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); - expect(wordTokens[1].data).toBe('Italic'); - expect(wordTokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 }); - expect(wordTokens[2].data).toBe('Back'); - expect(wordTokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); - }); - - it('yields property change tokens for color and restores after block', () => { - const parser = new MTextParser('{\\C1;Red} Normal', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); - const wordTokens = tokens.filter(t => t.type === TokenType.WORD); - expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 }); - expect(propTokens[propTokens.length - 1].data).toEqual({ - command: undefined, - changes: { aci: 256 }, - depth: 0, - }); - expect(wordTokens[0].data).toBe('Red'); - expect(wordTokens[0].ctx.aci).toBe(1); - expect(wordTokens[1].data).toBe('Normal'); - expect(wordTokens[1].ctx.aci).toBe(256); - }); - - it('yields property change tokens for nested braces', () => { - const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); - const wordTokens = tokens.filter(t => t.type === TokenType.WORD); - // Enter C1 - expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 }); - // Enter C2 - expect(propTokens[1].data).toEqual({ command: 'C', changes: { aci: 2 }, depth: 2 }); - // Exit C2 (restore C1) - expect(propTokens[2].data).toEqual({ command: undefined, changes: { aci: 1 }, depth: 1 }); - // Exit C1 (restore default) - expect(propTokens[propTokens.length - 1].data).toEqual({ - command: undefined, - changes: { aci: 256 }, - depth: 0, - }); - expect(wordTokens[0].data).toBe('Red'); - expect(wordTokens[0].ctx.aci).toBe(1); - expect(wordTokens[1].data).toBe('Blue'); - expect(wordTokens[1].ctx.aci).toBe(2); - expect(wordTokens[2].data).toBe('RedAgain'); - expect(wordTokens[2].ctx.aci).toBe(1); - }); - - it('yields property change tokens for RGB color commands', () => { - // \c16711680 is 0xFF0000, which is [255,0,0] (red) - const parser = new MTextParser('\\c16711680Red Text', undefined, { - yieldPropertyCommands: true, - }); - const tokens = Array.from(parser.parse()); - expect(tokens).toHaveLength(4); - expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); - expect(tokens[0].data).toEqual({ - command: 'c', - changes: { - aci: null, - rgb: [255, 0, 0], - }, - depth: 0, - }); - expect(tokens[1].type).toBe(TokenType.WORD); - expect(tokens[1].data).toBe('Red'); - expect(tokens[1].ctx.rgb).toEqual([255, 0, 0]); - expect(tokens[2].type).toBe(TokenType.SPACE); - expect(tokens[2].ctx.rgb).toEqual([255, 0, 0]); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Text'); - expect(tokens[3].ctx.rgb).toEqual([255, 0, 0]); - }); - }); -}); - -describe('MTextParser resetParagraphParameters option', () => { - it('resets paragraph properties after NEW_PARAGRAPH when resetParagraphParameters is true', () => { - // Create a context with non-default paragraph properties - const ctx = new MTextContext(); - ctx.paragraph.indent = 2; - ctx.paragraph.align = MTextParagraphAlignment.LEFT; - - const parser = new MTextParser('Line1\\PLine2', ctx, { - yieldPropertyCommands: true, - resetParagraphParameters: true, - }); - const tokens = Array.from(parser.parse()); - // Should emit: WORD(Line1), NEW_PARAGRAPH, PROPERTIES_CHANGED (reset), WORD(Line2) - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Line1'); - expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); - const propChanged = tokens[2].data as import('./parser').ChangedProperties; - expect(propChanged.changes).toHaveProperty('paragraph'); - expect(tokens[3].type).toBe(TokenType.WORD); - expect(tokens[3].data).toBe('Line2'); - }); - - it('does not emit PROPERTIES_CHANGED after NEW_PARAGRAPH if resetParagraphParameters is false', () => { - // Create a context with non-default paragraph properties - const ctx = new MTextContext(); - ctx.paragraph.indent = 2; - ctx.paragraph.align = MTextParagraphAlignment.CENTER; - - const parser = new MTextParser('Line1\\PLine2', ctx, { - yieldPropertyCommands: true, - resetParagraphParameters: false, - }); - const tokens = Array.from(parser.parse()); - // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Line1'); - expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Line2'); - expect( - tokens.find( - t => - t.type === TokenType.PROPERTIES_CHANGED && - t.data && - (t.data as import('./parser').ChangedProperties).changes?.paragraph - ) - ).toBeUndefined(); - }); - - it('resets paragraph properties but does not emit PROPERTIES_CHANGED if yieldPropertyCommands is false', () => { - // Create a context with non-default paragraph properties - const ctx = new MTextContext(); - ctx.paragraph.indent = 2; - ctx.paragraph.align = MTextParagraphAlignment.CENTER; - - const parser = new MTextParser('Line1\\PLine2', ctx, { - yieldPropertyCommands: false, - resetParagraphParameters: true, - }); - const tokens = Array.from(parser.parse()); - // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Line1'); - expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Line2'); - expect( - tokens.find( - t => - t.type === TokenType.PROPERTIES_CHANGED && - t.data && - (t.data as import('./parser').ChangedProperties).changes?.paragraph - ) - ).toBeUndefined(); - }); - - it('does not emit PROPERTIES_CHANGED when using default context with resetParagraphParameters true', () => { - const parser = new MTextParser('Line1\\PLine2', undefined, { - yieldPropertyCommands: true, - resetParagraphParameters: true, - }); - const tokens = Array.from(parser.parse()); - // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - no PROPERTIES_CHANGED because default context has default paragraph properties - expect(tokens[0].type).toBe(TokenType.WORD); - expect(tokens[0].data).toBe('Line1'); - expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); - expect(tokens[2].type).toBe(TokenType.WORD); - expect(tokens[2].data).toBe('Line2'); - expect( - tokens.find( - t => - t.type === TokenType.PROPERTIES_CHANGED && - t.data && - (t.data as import('./parser').ChangedProperties).changes?.paragraph - ) - ).toBeUndefined(); - }); -}); - -describe('TextScanner', () => { - let scanner: TextScanner; - - beforeEach(() => { - scanner = new TextScanner('Hello World'); - }); - - it('initializes with correct state', () => { - expect(scanner.currentIndex).toBe(0); - expect(scanner.isEmpty).toBe(false); - expect(scanner.hasData).toBe(true); - }); - - it('consumes characters', () => { - expect(scanner.get()).toBe('H'); - expect(scanner.currentIndex).toBe(1); - expect(scanner.get()).toBe('e'); - expect(scanner.currentIndex).toBe(2); - }); - - it('peeks characters', () => { - expect(scanner.peek()).toBe('H'); - expect(scanner.peek(1)).toBe('e'); - expect(scanner.currentIndex).toBe(0); - }); - - it('consumes multiple characters', () => { - scanner.consume(5); - expect(scanner.currentIndex).toBe(5); - expect(scanner.get()).toBe(' '); - }); - - it('finds characters', () => { - expect(scanner.find('W')).toBe(6); - expect(scanner.find('X')).toBe(-1); - }); - - it('handles escaped characters in find', () => { - scanner = new TextScanner('Hello\\;World'); - expect(scanner.find(';', true)).toBe(6); - }); - - it('gets remaining text', () => { - scanner.consume(6); - expect(scanner.tail).toBe('World'); - }); - - it('handles end of text', () => { - scanner.consume(11); - expect(scanner.isEmpty).toBe(true); - expect(scanner.hasData).toBe(false); - expect(scanner.get()).toBe(''); - expect(scanner.peek()).toBe(''); - }); -}); - -describe('getFonts', () => { - it('should return empty set for empty string', () => { - const result = getFonts(''); - expect(result).toEqual(new Set()); - }); - - it('should extract single font name', () => { - const result = getFonts('\\fArial|Hello World'); - expect(result).toEqual(new Set(['arial'])); - }); - - it('should extract multiple unique font names', () => { - const result = getFonts('\\fArial|Hello \\fTimes New Roman|World'); - expect(result).toEqual(new Set(['arial', 'times new roman'])); - }); - - it('should handle case-insensitive font names', () => { - const result = getFonts('\\fARIAL|Hello \\fArial|World'); - expect(result).toEqual(new Set(['arial'])); - }); - - it('should handle font names with spaces', () => { - const result = getFonts('\\fTimes New Roman|Hello \\fComic Sans MS|World'); - expect(result).toEqual(new Set(['times new roman', 'comic sans ms'])); - }); - - it('should handle multiple font changes in sequence', () => { - const result = getFonts('\\fArial|Hello \\fTimes New Roman|World \\fArial|Again'); - expect(result).toEqual(new Set(['arial', 'times new roman'])); - }); - - it('should handle font names with special characters', () => { - const result = getFonts('\\fArial-Bold|Hello \\fTimes-New-Roman|World'); - expect(result).toEqual(new Set(['arial-bold', 'times-new-roman'])); - }); - - it('should handle both lowercase and uppercase font commands', () => { - const result = getFonts('\\fArial|Hello \\FTimes New Roman|World'); - expect(result).toEqual(new Set(['arial', 'times new roman'])); - }); - - it('should handle complex MText with semicolon terminators', () => { - const mtext = - '{\\C1;\\W2;\\FSimSun;SimSun Text}\\P{\\C2;\\W0.5;\\FArial;Arial Text}\\P{\\C3;\\O30;\\FRomans;Romans Text}\\P{\\C4;\\Q1;\\FSimHei;SimHei Text}\\P{\\C5;\\Q0.5;\\FSimKai;SimKai Text}'; - const result = getFonts(mtext); - expect(result).toEqual(new Set(['simsun', 'arial', 'romans', 'simhei', 'simkai'])); - }); - - it('should preserve font extensions when removeExtension is false', () => { - const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World'; - const result = getFonts(mtext, false); - expect(result).toEqual(new Set(['arial.ttf', 'times new roman.otf'])); - }); - - it('should remove font extensions when removeExtension is true', () => { - const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World'; - const result = getFonts(mtext, true); - expect(result).toEqual(new Set(['arial', 'times new roman'])); - }); - - it('should handle various font extensions', () => { - const mtext = '\\fFont1.ttf|Text1 \\fFont2.otf|Text2 \\fFont3.woff|Text3 \\fFont4.shx|Text4'; - const result = getFonts(mtext, true); - expect(result).toEqual(new Set(['font1', 'font2', 'font3', 'font4'])); - }); - - it('should not remove non-font extensions', () => { - const mtext = '\\fFont1.txt|Text1 \\fFont2.doc|Text2'; - const result = getFonts(mtext, true); - expect(result).toEqual(new Set(['font1.txt', 'font2.doc'])); - }); -}); - -describe('MTextColor', () => { - it('defaults to ACI 256 (by layer)', () => { - const color = new MTextColor(); - expect(color.aci).toBe(256); - expect(color.rgb).toBeNull(); - expect(color.isAci).toBe(true); - expect(color.isRgb).toBe(false); - }); - - it('can be constructed with ACI', () => { - const color = new MTextColor(1); - expect(color.aci).toBe(1); - expect(color.rgb).toBeNull(); - expect(color.isAci).toBe(true); - expect(color.isRgb).toBe(false); - }); - - it('can be constructed with RGB', () => { - const color = new MTextColor([255, 0, 0]); - expect(color.aci).toBeNull(); - expect(color.rgb).toEqual([255, 0, 0]); - expect(color.isAci).toBe(false); - expect(color.isRgb).toBe(true); - }); - - it('switches from ACI to RGB and back', () => { - const color = new MTextColor(2); - expect(color.aci).toBe(2); - color.rgb = [0, 255, 0]; - expect(color.aci).toBeNull(); - expect(color.rgb).toEqual([0, 255, 0]); - color.aci = 7; - expect(color.aci).toBe(7); - expect(color.rgb).toBeNull(); - }); - - it('copy() produces a deep copy', () => { - const color = new MTextColor([1, 2, 3]); - const copy = color.copy(); - expect(copy).not.toBe(color); - expect(copy.aci).toBe(color.aci); - expect(copy.rgb).toEqual(color.rgb); - copy.rgb = [4, 5, 6]; - expect(color.rgb).toEqual([1, 2, 3]); - expect(copy.rgb).toEqual([4, 5, 6]); - }); - - it('fromCssColor parses hex and rgb()', () => { - const hex = MTextColor.fromCssColor('#ff0000'); - expect(hex).not.toBeNull(); - expect(hex!.rgb).toEqual([255, 0, 0]); - - const shortHex = MTextColor.fromCssColor('#0f0'); - expect(shortHex).not.toBeNull(); - expect(shortHex!.rgb).toEqual([0, 255, 0]); - - const rgb = MTextColor.fromCssColor('rgb(0, 128, 255)'); - expect(rgb).not.toBeNull(); - expect(rgb!.rgb).toEqual([0, 128, 255]); - }); - - it('fromCssColor handles rgba() and rejects transparent', () => { - const rgba = MTextColor.fromCssColor('rgba(10, 20, 30, 0.5)'); - expect(rgba).not.toBeNull(); - expect(rgba!.rgb).toEqual([10, 20, 30]); - - const transparent = MTextColor.fromCssColor('transparent'); - expect(transparent).toBeNull(); - }); - - it('toCssColor returns hex for RGB and null for ACI', () => { - const rgb = new MTextColor([255, 0, 0]); - expect(rgb.toCssColor()).toBe('#ff0000'); - - const aci = new MTextColor(1); - expect(aci.toCssColor()).toBeNull(); - }); -}); +import { + MTextParser, + MTextContext, + TokenType, + MTextLineAlignment, + MTextParagraphAlignment, + rgb2int, + int2rgb, + escapeDxfLineEndings, + hasInlineFormattingCodes, + TextScanner, + getFonts, + MTextColor, +} from './parser'; + +describe('Utility Functions', () => { + describe('rgb2int', () => { + it('converts RGB tuple to integer', () => { + expect(rgb2int([255, 0, 0])).toBe(0xff0000); + expect(rgb2int([0, 255, 0])).toBe(0x00ff00); + expect(rgb2int([0, 0, 255])).toBe(0x0000ff); + }); + }); + + describe('int2rgb', () => { + it('converts integer to RGB tuple', () => { + expect(int2rgb(0xff0000)).toEqual([255, 0, 0]); + expect(int2rgb(0x00ff00)).toEqual([0, 255, 0]); + expect(int2rgb(0x0000ff)).toEqual([0, 0, 255]); + }); + }); + + describe('escapeDxfLineEndings', () => { + it('escapes line endings', () => { + expect(escapeDxfLineEndings('line1\r\nline2')).toBe('line1\\Pline2'); + expect(escapeDxfLineEndings('line1\nline2')).toBe('line1\\Pline2'); + expect(escapeDxfLineEndings('line1\rline2')).toBe('line1\\Pline2'); + }); + }); + + describe('hasInlineFormattingCodes', () => { + it('detects inline formatting codes', () => { + expect(hasInlineFormattingCodes('\\L')).toBe(true); + expect(hasInlineFormattingCodes('\\P')).toBe(false); + expect(hasInlineFormattingCodes('\\~')).toBe(false); + expect(hasInlineFormattingCodes('normal text')).toBe(false); + }); + }); +}); + +describe('MTextContext', () => { + let ctx: MTextContext; + + beforeEach(() => { + ctx = new MTextContext(); + }); + + it('initializes with default values', () => { + expect(ctx.aci).toBe(256); + expect(ctx.rgb).toBeNull(); + expect(ctx.align).toBe(MTextLineAlignment.BOTTOM); + expect(ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); + expect(ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); + expect(ctx.widthFactor).toEqual({ value: 1.0, isRelative: false }); + expect(ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); + expect(ctx.oblique).toBe(0.0); + expect(ctx.paragraph).toEqual({ + indent: 0, + left: 0, + right: 0, + align: MTextParagraphAlignment.DEFAULT, + tabs: [], + }); + expect(ctx.bold).toBe(false); + expect(ctx.italic).toBe(false); + }); + + describe('italic and bold properties', () => { + it('should default to italic = false and bold = false', () => { + expect(ctx.italic).toBe(false); + expect(ctx.bold).toBe(false); + }); + + it('should set and get italic property', () => { + ctx.italic = true; + expect(ctx.italic).toBe(true); + expect(ctx.fontFace.style).toBe('Italic'); + ctx.italic = false; + expect(ctx.italic).toBe(false); + expect(ctx.fontFace.style).toBe('Regular'); + }); + + it('should set and get bold property', () => { + ctx.bold = true; + expect(ctx.bold).toBe(true); + expect(ctx.fontFace.weight).toBe(700); + ctx.bold = false; + expect(ctx.bold).toBe(false); + expect(ctx.fontFace.weight).toBe(400); + }); + + it('should reflect changes to fontFace.style and fontFace.weight', () => { + ctx.fontFace.style = 'Italic'; + expect(ctx.italic).toBe(true); + ctx.fontFace.style = 'Regular'; + expect(ctx.italic).toBe(false); + ctx.fontFace.weight = 700; + expect(ctx.bold).toBe(true); + ctx.fontFace.weight = 400; + expect(ctx.bold).toBe(false); + }); + }); + + describe('stroke properties', () => { + it('handles underline', () => { + ctx.underline = true; + expect(ctx.underline).toBe(true); + expect(ctx.hasAnyStroke).toBe(true); + ctx.underline = false; + expect(ctx.underline).toBe(false); + expect(ctx.hasAnyStroke).toBe(false); + }); + + it('handles overline', () => { + ctx.overline = true; + expect(ctx.overline).toBe(true); + expect(ctx.hasAnyStroke).toBe(true); + ctx.overline = false; + expect(ctx.overline).toBe(false); + expect(ctx.hasAnyStroke).toBe(false); + }); + + it('handles strike-through', () => { + ctx.strikeThrough = true; + expect(ctx.strikeThrough).toBe(true); + expect(ctx.hasAnyStroke).toBe(true); + ctx.strikeThrough = false; + expect(ctx.strikeThrough).toBe(false); + expect(ctx.hasAnyStroke).toBe(false); + }); + + it('handles multiple strokes', () => { + ctx.underline = true; + ctx.overline = true; + expect(ctx.hasAnyStroke).toBe(true); + ctx.underline = false; + expect(ctx.hasAnyStroke).toBe(true); + ctx.overline = false; + expect(ctx.hasAnyStroke).toBe(false); + }); + }); + + describe('color properties', () => { + it('handles ACI color', () => { + ctx.aci = 1; + expect(ctx.aci).toBe(1); + expect(ctx.rgb).toBeNull(); + expect(ctx.color.aci).toBe(1); + expect(ctx.color.rgb).toBeNull(); + expect(ctx.color.rgbValue).toBeNull(); + expect(() => (ctx.aci = 257)).toThrow('ACI not in range [0, 256]'); + }); + + it('handles RGB color', () => { + ctx.rgb = [255, 0, 0]; + expect(ctx.rgb).toEqual([255, 0, 0]); + expect(ctx.color.rgb).toEqual([255, 0, 0]); + expect(ctx.color.aci).toBeNull(); + expect(ctx.color.rgbValue).toBe(0xff0000); + }); + + it('switches from RGB to ACI', () => { + ctx.rgb = [255, 0, 0]; + ctx.aci = 2; + expect(ctx.rgb).toBeNull(); + expect(ctx.aci).toBe(2); + expect(ctx.color.rgb).toBeNull(); + expect(ctx.color.aci).toBe(2); + expect(ctx.color.rgbValue).toBeNull(); + }); + + it('handles RGB value set directly', () => { + ctx.color.rgbValue = 0x00ff00; + expect(ctx.rgb).toEqual([0, 255, 0]); + expect(ctx.color.rgb).toEqual([0, 255, 0]); + expect(ctx.color.rgbValue).toBe(0x00ff00); + expect(ctx.color.aci).toBeNull(); + }); + }); + + describe('copy', () => { + it('creates a deep copy', () => { + ctx.underline = true; + ctx.rgb = [255, 0, 0]; + const copy = ctx.copy(); + expect(copy).not.toBe(ctx); + expect(copy.underline).toBe(ctx.underline); + expect(copy.rgb).toEqual(ctx.rgb); + expect(copy.color.rgb).toEqual(ctx.color.rgb); + expect(copy.color.aci).toBe(ctx.color.aci); + expect(copy.color.rgbValue).toBe(ctx.color.rgbValue); + expect(copy.fontFace).toEqual(ctx.fontFace); + expect(copy.paragraph).toEqual(ctx.paragraph); + // Changing the copy's color should not affect the original + copy.rgb = [0, 255, 0]; + expect(ctx.rgb).toEqual([255, 0, 0]); + expect(copy.rgb).toEqual([0, 255, 0]); + expect(copy.color.rgbValue).toBe(0x00ff00); + expect(ctx.color.rgbValue).toBe(0xff0000); + }); + }); + + describe('factor properties', () => { + it('handles charTrackingFactor absolute values', () => { + ctx.charTrackingFactor = { value: 2.0, isRelative: false }; + expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false }); + + ctx.charTrackingFactor = { value: 0.5, isRelative: false }; + expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: false }); + }); + + it('handles charTrackingFactor relative values', () => { + ctx.charTrackingFactor = { value: 2.0, isRelative: true }; + expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: true }); + + ctx.charTrackingFactor = { value: 0.5, isRelative: true }; + expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); + }); + + it('converts negative values to positive for charTrackingFactor', () => { + ctx.charTrackingFactor = { value: -2.0, isRelative: false }; + expect(ctx.charTrackingFactor).toEqual({ value: 2.0, isRelative: false }); + + ctx.charTrackingFactor = { value: -0.5, isRelative: true }; + expect(ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); + }); + }); +}); + +describe('MTextParser', () => { + describe('basic parsing', () => { + it('parses plain text', () => { + const parser = new MTextParser('Hello World'); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(3); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Hello'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('World'); + }); + + it('parses spaces', () => { + const parser = new MTextParser('Hello World'); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(3); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Hello'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('World'); + }); + + it('parses text starting with control characters', () => { + // Test with newline + let parser = new MTextParser('\nHello World'); + let tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Hello'); + expect(tokens[2].type).toBe(TokenType.SPACE); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('World'); + + // Test with tab + parser = new MTextParser('\tHello World'); + tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(TokenType.TABULATOR); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Hello'); + expect(tokens[2].type).toBe(TokenType.SPACE); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('World'); + + // Test with multiple control characters + parser = new MTextParser('\n\tHello World'); + tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(5); + expect(tokens[0].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[1].type).toBe(TokenType.TABULATOR); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Hello'); + expect(tokens[3].type).toBe(TokenType.SPACE); + expect(tokens[4].type).toBe(TokenType.WORD); + expect(tokens[4].data).toBe('World'); + }); + + it('parses new paragraphs', () => { + const parser = new MTextParser('Line 1\\PLine 2'); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(7); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Line'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('1'); + expect(tokens[3].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[4].type).toBe(TokenType.WORD); + expect(tokens[4].data).toBe('Line'); + expect(tokens[5].type).toBe(TokenType.SPACE); + expect(tokens[6].type).toBe(TokenType.WORD); + expect(tokens[6].data).toBe('2'); + }); + }); + + describe('formatting', () => { + it('parses underline', () => { + const parser = new MTextParser('\\LUnderlined\\l'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Underlined'); + expect(tokens[0].ctx.underline).toBe(true); + }); + + it('parses color', () => { + const parser = new MTextParser('\\C1Red Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Red'); + expect(tokens[0].ctx.aci).toBe(1); + }); + + it('parses font properties', () => { + const parser = new MTextParser('\\FArial|b1|i1;Bold Italic'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Bold'); + expect(tokens[0].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + }); + + describe('height command', () => { + it('parses absolute height values', () => { + const parser = new MTextParser('\\H2.5;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + }); + + it('parses relative height values with x suffix', () => { + const parser = new MTextParser('\\H2.5x;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: true }); + }); + + it('handles optional terminator', () => { + const parser = new MTextParser('\\H2.5Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles leading signs', () => { + let parser = new MTextParser('\\H-2.5;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored + + parser = new MTextParser('\\H+2.5;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles decimal values without leading zero', () => { + let parser = new MTextParser('\\H.5x;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); + + parser = new MTextParser('\\H-.5x;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored + + parser = new MTextParser('\\H+.5x;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); + }); + + it('handles exponential notation', () => { + let parser = new MTextParser('\\H1e2;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 100, isRelative: false }); + + parser = new MTextParser('\\H1e-2;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.01, isRelative: false }); + + parser = new MTextParser('\\H.5e2;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: false }); + + parser = new MTextParser('\\H.5e-2;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.005, isRelative: false }); + }); + + it('handles invalid floating point values', () => { + let parser = new MTextParser('\\H1..5;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('.5;Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value + + parser = new MTextParser('\\H1e;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('e;Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value + + parser = new MTextParser('\\H1e+;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('e+;Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value + }); + + it('handles complex height expressions', () => { + let parser = new MTextParser('\\H+1.5e-1x;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 0.15, isRelative: true }); + + parser = new MTextParser('\\H-.5e+2x;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 50, isRelative: true }); // Negative values are ignored + }); + + it('handles multiple height commands', () => { + const parser = new MTextParser('\\H2.5;First\\H.5x;Second'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('First'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Second'); + expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); + }); + + it('handles height command with no value', () => { + const parser = new MTextParser('\\H;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 1.0, isRelative: false }); // Default value + }); + }); + + describe('width command', () => { + it('parses absolute width values', () => { + const parser = new MTextParser('\\W2.5;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('parses relative width values with x suffix', () => { + const parser = new MTextParser('\\W2.5x;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: true }); + }); + + it('handles optional terminator', () => { + const parser = new MTextParser('\\W2.5Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles leading signs', () => { + let parser = new MTextParser('\\W-2.5;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored + + parser = new MTextParser('\\W+2.5;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles decimal values without leading zero', () => { + let parser = new MTextParser('\\W.5x;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); + + parser = new MTextParser('\\W-.5x;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored + }); + + it('handles multiple width commands', () => { + const parser = new MTextParser('\\W2.5;First\\W.5x;Second'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('First'); + expect(tokens[0].ctx.widthFactor).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Second'); + expect(tokens[1].ctx.widthFactor).toEqual({ value: 0.5, isRelative: true }); + }); + }); + + describe('character tracking command', () => { + it('parses absolute tracking values', () => { + const parser = new MTextParser('\\T2.5;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('parses relative tracking values with x suffix', () => { + const parser = new MTextParser('\\T2.5x;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: true }); + }); + + it('handles optional terminator', () => { + const parser = new MTextParser('\\T2.5Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles leading signs', () => { + let parser = new MTextParser('\\T-2.5;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); // Negative values are ignored + + parser = new MTextParser('\\T+2.5;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); + }); + + it('handles decimal values without leading zero', () => { + let parser = new MTextParser('\\T.5x;Text'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); + + parser = new MTextParser('\\T-.5x;Text'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); // Negative values are ignored + }); + + it('handles multiple tracking commands', () => { + const parser = new MTextParser('\\T2.5;First\\T.5x;Second'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('First'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Second'); + expect(tokens[1].ctx.charTrackingFactor).toEqual({ value: 0.5, isRelative: true }); + }); + + it('handles tracking command with no value', () => { + const parser = new MTextParser('\\T;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.charTrackingFactor).toEqual({ value: 1.0, isRelative: false }); // Default value + }); + }); + + describe('oblique command', () => { + it('parses positive oblique angle', () => { + const parser = new MTextParser('\\Q15;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.oblique).toBe(15); + }); + + it('parses negative oblique angle', () => { + const parser = new MTextParser('\\Q-15;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.oblique).toBe(-15); + }); + + it('handles optional terminator', () => { + const parser = new MTextParser('\\Q15Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.oblique).toBe(15); + }); + + it('handles decimal values', () => { + const parser = new MTextParser('\\Q15.5;Text'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + expect(tokens[0].ctx.oblique).toBe(15.5); + }); + + it('handles multiple oblique commands', () => { + const parser = new MTextParser('\\Q15;First\\Q-30;Second'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('First'); + expect(tokens[0].ctx.oblique).toBe(15); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Second'); + expect(tokens[1].ctx.oblique).toBe(-30); + }); + }); + + describe('special encoded characters', () => { + it('renders diameter symbol (%%c)', () => { + let parser = new MTextParser('%%cText'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('ØText'); + + parser = new MTextParser('%%CText'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('ØText'); + }); + + it('renders degree symbol (%%d)', () => { + let parser = new MTextParser('%%dText'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('°Text'); + + parser = new MTextParser('%%DText'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('°Text'); + }); + + it('renders plus-minus symbol (%%p)', () => { + let parser = new MTextParser('%%pText'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('±Text'); + + parser = new MTextParser('%%PText'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('±Text'); + }); + + it('handles multiple special characters in sequence', () => { + const parser = new MTextParser('%%c%%d%%pText'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('ذ±Text'); + }); + + it('handles special characters with spaces', () => { + const parser = new MTextParser('%%c %%d %%p Text'); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(7); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Ø'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('°'); + expect(tokens[3].type).toBe(TokenType.SPACE); + expect(tokens[4].type).toBe(TokenType.WORD); + expect(tokens[4].data).toBe('±'); + expect(tokens[5].type).toBe(TokenType.SPACE); + expect(tokens[6].type).toBe(TokenType.WORD); + expect(tokens[6].data).toBe('Text'); + }); + + it('handles special characters with formatting', () => { + const parser = new MTextParser('\\H2.5;%%c\\H.5x;%%d%%p'); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(2); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Ø'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('°±'); + expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); + }); + + it('handles invalid special character codes', () => { + const parser = new MTextParser('%%x%%y%%zText'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Text'); + }); + }); + + describe('yieldPercentSymbols', () => { + it('emits PERCENT_SYMBOL tokens for %%c, %%d, and %%p', () => { + const parser = new MTextParser('%%c%%d%%p', undefined, { + yieldPercentSymbols: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(3); + expect(tokens[0]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { kind: 'named', code: 'c', char: 'Ø' }, + }); + expect(tokens[1]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { kind: 'named', code: 'd', char: '°' }, + }); + expect(tokens[2]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { kind: 'named', code: 'p', char: '±' }, + }); + }); + + it('emits PERCENT_SYMBOL for numeric %%ddd codes', () => { + const parser = new MTextParser('%%130%%132', undefined, { + yieldPercentSymbols: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(2); + expect(tokens[0]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { + kind: 'numeric', + charCode: 130, + char: String.fromCharCode(130), + }, + }); + expect(tokens[1]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { + kind: 'numeric', + charCode: 132, + char: String.fromCharCode(132), + }, + }); + }); + + it('emits PERCENT_SYMBOL for literal percent (%%%)', () => { + const parser = new MTextParser('%%%', undefined, { + yieldPercentSymbols: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ + type: TokenType.PERCENT_SYMBOL, + data: { kind: 'literal', char: '%' }, + }); + }); + + it('interleaves WORD and PERCENT_SYMBOL tokens', () => { + const parser = new MTextParser('A%%cB', undefined, { + yieldPercentSymbols: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(3); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('A'); + expect(tokens[1].type).toBe(TokenType.PERCENT_SYMBOL); + expect(tokens[1].data).toEqual({ kind: 'named', code: 'c', char: 'Ø' }); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('B'); + }); + + it('preserves default WORD expansion when yieldPercentSymbols is false', () => { + const parser = new MTextParser('%%c', undefined, { + yieldPercentSymbols: false, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Ø'); + }); + }); + }); + + describe('GBK character encoding', () => { + it('decodes GBK hex codes', () => { + // Test "你" (C4E3 in GBK) + let parser = new MTextParser('\\M+C4E3', undefined, { yieldPropertyCommands: false }); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + + // Test "好" (BAC3 in GBK) + parser = new MTextParser('\\M+BAC3', undefined, { yieldPropertyCommands: false }); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('好'); + + // Test multiple GBK characters + parser = new MTextParser('\\M+C4E3\\M+BAC3', undefined, { yieldPropertyCommands: false }); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('好'); + }); + + it('handles invalid GBK codes', () => { + // Test invalid hex code + let parser = new MTextParser('\\M+XXXX', undefined, { yieldPropertyCommands: false }); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('\\M+XXXX'); + + // Test incomplete hex code + parser = new MTextParser('\\M+C4', undefined, { yieldPropertyCommands: false }); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('\\M+C4'); + + // Test missing plus sign + parser = new MTextParser('\\MC4E3', undefined, { yieldPropertyCommands: false }); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('\\MC4E3'); + }); + + it('handles GBK characters with other formatting', () => { + // Test GBK characters with height command + const parser = new MTextParser('\\H2.5;\\M+C4E3\\H.5x;\\M+BAC3', undefined, { + yieldPropertyCommands: false, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(2); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + expect(tokens[0].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('好'); + expect(tokens[1].ctx.capHeight).toEqual({ value: 0.5, isRelative: true }); + }); + + it('handles GBK characters with font properties', () => { + const parser = new MTextParser('{\\fgbcbig.shx|b0|i0|c0|p0;\\M+C4E3\\M+BAC3}', undefined, { + yieldPropertyCommands: false, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('好'); + expect(tokens[0].ctx.fontFace).toEqual({ + family: 'gbcbig.shx', + style: 'Regular', + weight: 400, + }); + expect(tokens[1].ctx.fontFace).toEqual({ + family: 'gbcbig.shx', + style: 'Regular', + weight: 400, + }); + }); + }); + + describe('MIF (Multibyte Interchange Format) with custom decoder', () => { + it('uses custom decoder when provided', () => { + const customDecoder = (hex: string) => { + // Custom decoder that reverses the hex and converts to char + const reversed = hex.split('').reverse().join(''); + const codePoint = parseInt(reversed, 16); + return String.fromCodePoint(codePoint); + }; + + const parser = new MTextParser('\\M+C4E3', undefined, { + mifDecoder: customDecoder, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + // The custom decoder will produce different output + expect(tokens[0].data).not.toBe('\\M+C4E3'); + }); + + it('parses 5-digit MIF codes with auto-detect', () => { + // Use default decoder with auto-detect + const parser = new MTextParser('\\M+1A2B3', undefined, { + mifCodeLength: 'auto', + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + // Should successfully parse 5-digit code + expect(tokens[0].data).not.toBe('\\M+1A2B3'); + }); + + it('parses 5-digit MIF codes when specified', () => { + const parser = new MTextParser('\\M+1A2B3', undefined, { + mifCodeLength: 5, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).not.toBe('\\M+1A2B3'); + }); + + it('parses 4-digit MIF codes when specified', () => { + const parser = new MTextParser('\\M+C4E3', undefined, { + mifCodeLength: 4, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + }); + + it('falls back to 4-digit when 5-digit not available', () => { + const parser = new MTextParser('\\M+C4E3', undefined, { + mifCodeLength: 'auto', + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('你'); + }); + + it('uses custom decoder with specific code length', () => { + const customDecoder = (hex: string) => `[DECODED:${hex}]`; + const parser = new MTextParser('\\M+C4E3', undefined, { + mifDecoder: customDecoder, + mifCodeLength: 4, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('[DECODED:C4E3]'); + }); + + it('parses complex MText with 5-digit MIF codes and Unicode', () => { + // Test data: \M+1928D:\M+18DD1\M+197702.0t\U+9540\U+950C\M+194C2\M+190A7\M+18DEC\M+18142 + // According to user, \M+19770 should be parsed as 5-digit code, leaving "2.0t" as separate characters + // But the parser's auto-detect logic tries 5 digits first, which consumes "19770" + // So "2" becomes part of the next sequence + const mtext = + '\\M+1928D:\\M+18DD1\\M+197702.0t\\U+9540\\U+950C\\M+194C2\\M+190A7\\M+18DEC\\M+18142'; + const parser = new MTextParser(mtext, undefined, { + mifCodeLength: 'auto', + }); + const tokens = Array.from(parser.parse()); + + // Should parse without errors and generate tokens + // The parser produces 11 tokens: each MIF code and Unicode becomes one token, and "2.0t" becomes a single token + expect(tokens.length).toBe(11); + + // Verify the decoded characters are valid + const wordTokens = tokens.filter(t => t.type === TokenType.WORD); + expect(wordTokens.length).toBeGreaterThan(0); + + // Note: 5-digit MIF codes return placeholder character '▯' as per decodeMultiByteChar implementation + // Only verify that Unicode characters (4-digit hex) are decoded properly + const unicodeTokens = wordTokens.filter(t => t.data && t.data !== '▯'); + expect(unicodeTokens.length).toBeGreaterThan(0); + }); + + it('decodes specific 5-digit MIF codes correctly', () => { + // Test individual 5-digit MIF codes from the provided test data + const testCases = [ + { code: '1928D', decoded: '注' }, + { code: '18DD1', decoded: '采' }, + { code: '19770', decoded: '用' }, + { code: '194C2', decoded: '板' }, + { code: '190A7', decoded: '制' }, + { code: '18DEC', decoded: '作' }, + { code: '18142', decoded: '。' }, + ]; + + testCases.forEach(({ code, decoded }) => { + const parser = new MTextParser(`\\M+${code}`, undefined, { + mifCodeLength: 5, + }); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + // Note: decodeMultiByteChar returns '▯' for 5-digit codes + if (tokens[0].data && typeof tokens[0].data === 'string') { + expect(tokens[0].data).toBe(decoded); + } + }); + }); + }); + + describe('Unicode (\\U+XXXX) escape sequences', () => { + it('decodes Unicode BMP and supplementary plane code points', () => { + // Greek capital omega: \U+03A9 + let parser = new MTextParser('Omega: \\U+03A9'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Omega:'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Ω'); + + // Emoji: \U+1F600 (grinning face) + parser = new MTextParser('Smile: \\U+1F600'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Smile:'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('😀'); + }); + + it('handles invalid or incomplete Unicode escapes as literal text', () => { + // Not enough hex digits + let parser = new MTextParser('Test: \\U+12'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Test:'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('\\U+12'); + + // Invalid hex + parser = new MTextParser('Test: \\U+ZZZZ'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Test:'); + expect(tokens[1].type).toBe(TokenType.SPACE); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('\\U+ZZZZ'); + }); + }); + + describe('stacking', () => { + it('parses basic fractions with different dividers', () => { + let parser = new MTextParser('\\S1/2;'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2', '/']); + + parser = new MTextParser('\\S1#2;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2', '#']); + }); + + it('handles caret for baseline alignment', () => { + let parser = new MTextParser('\\S1^2;'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2', '^']); + + // Test with spaces + parser = new MTextParser('\\S1 2^3 4;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1 2', '3 4', '^']); + + // Test with escaped characters + parser = new MTextParser('\\S1^2\\;;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2;', '^']); + }); + + it('handles spaces in numerator and denominator', () => { + const parser = new MTextParser('\\S1 2/3 4;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1 2', '3 4', '/']); + }); + + it('handles spaces after / and # dividers', () => { + let parser = new MTextParser('\\S1/ 2;'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', ' 2', '/']); + + parser = new MTextParser('\\S1# 2;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', ' 2', '#']); + }); + + it('handles escaped terminator', () => { + const parser = new MTextParser('\\S1/2\\;;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2;', '/']); + }); + + it('ignores backslashes except for escaped terminator', () => { + const parser = new MTextParser('\\S\\N^ \\P;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['N', 'P', '^']); + }); + + it('renders grouping chars as simple braces', () => { + const parser = new MTextParser('\\S{1}/2;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['{1}', '2', '/']); + }); + + it('treats carets in stack formatting as literal text', () => { + let parser = new MTextParser('\\S^I/^J;'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual([' ', ' ', '/']); + + parser = new MTextParser('\\Sabc^def;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['abc', 'def', '^']); + }); + + it('handles subscript and superscript', () => { + // Subscript + let parser = new MTextParser('abc\\S^ 1;'); + let tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toEqual('abc'); + expect(tokens[1].type).toBe(TokenType.STACK); + expect(tokens[1].data).toEqual(['', '1', '^']); + + // Superscript + parser = new MTextParser('abc\\S1^ ;'); + tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toEqual('abc'); + expect(tokens[1].type).toBe(TokenType.STACK); + expect(tokens[1].data).toEqual(['1', '', '^']); + }); + + it('handles multiple divider chars', () => { + const parser = new MTextParser('\\S1/2/3;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2/3', '/']); + }); + + it('requires terminator for command end', () => { + const parser = new MTextParser('\\S1/2'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1', '2', '/']); + }); + + it('handles complex fractions', () => { + const parser = new MTextParser('\\S1 2/3 4^ 5 6;'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.STACK); + expect(tokens[0].data).toEqual(['1 2', '3 4^ 5 6', '/']); + }); + }); + + describe('paragraph properties', () => { + it('parses indentation', () => { + const parser = new MTextParser('\\pi2;Indented'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Indented'); + expect(tokens[0].ctx.paragraph.indent).toBe(2); + }); + + it('parses alignment', () => { + const parser = new MTextParser('\\pqc;Centered'); + const tokens = Array.from(parser.parse()); + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Centered'); + expect(tokens[0].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); + }); + + it('switches alignment', () => { + const mtext = + 'Line1: {\\pql;Left aligned paragraph.}\\PLine2: {\\pqc;Center aligned paragraph.} Middle\\PLine3: {\\pql;Back to left.} {End}'; + const ctx = new MTextContext(); + ctx.fontFace.family = 'simkai'; + ctx.capHeight = { value: 0.1, isRelative: true }; + ctx.widthFactor = { value: 1.0, isRelative: true }; + ctx.align = MTextLineAlignment.BOTTOM; + ctx.paragraph.align = MTextParagraphAlignment.LEFT; + const parser = new MTextParser(mtext, ctx, { yieldPropertyCommands: true }); + const tokens = Array.from(parser.parse()); + // Filter for word tokens + const wordTokens = tokens.filter(t => t.type === TokenType.WORD); + const expected = [ + // Paragraph 1 (LEFT) + { data: 'Line1:', align: MTextParagraphAlignment.LEFT }, + { data: 'Left', align: MTextParagraphAlignment.LEFT }, + { data: 'aligned', align: MTextParagraphAlignment.LEFT }, + { data: 'paragraph.', align: MTextParagraphAlignment.LEFT }, + // Paragraph 2 (CENTER) + { data: 'Line2:', align: MTextParagraphAlignment.LEFT }, + { data: 'Center', align: MTextParagraphAlignment.CENTER }, + { data: 'aligned', align: MTextParagraphAlignment.CENTER }, + { data: 'paragraph.', align: MTextParagraphAlignment.CENTER }, + { data: 'Middle', align: MTextParagraphAlignment.CENTER }, + // Paragraph 3 (LEFT) + { data: 'Line3:', align: MTextParagraphAlignment.CENTER }, + { data: 'Back', align: MTextParagraphAlignment.LEFT }, + { data: 'to', align: MTextParagraphAlignment.LEFT }, + { data: 'left.', align: MTextParagraphAlignment.LEFT }, + { data: 'End', align: MTextParagraphAlignment.LEFT }, + ]; + expect(wordTokens).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + console.log(wordTokens[i]); + expect(wordTokens[i].data).toBe(expected[i].data); + expect(wordTokens[i].ctx.paragraph.align).toBe(expected[i].align); + } + }); + }); + + describe('property commands with yieldPropertyCommands', () => { + it('yields property change tokens for formatting commands', () => { + const parser = new MTextParser('\\LUnderlined\\l', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(3); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'L', + changes: { + underline: true, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Underlined'); + expect(tokens[1].ctx.underline).toBe(true); + expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[2].data).toEqual({ + command: 'l', + changes: { + underline: false, + }, + depth: 0, + }); + expect(tokens[2].ctx.underline).toBe(false); + }); + + it('yields property change tokens for color commands', () => { + const parser = new MTextParser('\\C1Red Text', undefined, { yieldPropertyCommands: true }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'C', + changes: { + aci: 1, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Red'); + expect(tokens[1].ctx.aci).toBe(1); + expect(tokens[2].type).toBe(TokenType.SPACE); + expect(tokens[2].ctx.aci).toBe(1); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Text'); + expect(tokens[3].ctx.aci).toBe(1); + }); + + it('yields property change tokens for font properties', () => { + const parser = new MTextParser('\\FArial|b1|i1;Bold Italic', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'F', + changes: { + fontFace: { + family: 'Arial', + style: 'Italic', + weight: 700, + }, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Bold'); + expect(tokens[1].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + expect(tokens[2].type).toBe(TokenType.SPACE); + expect(tokens[2].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Italic'); + expect(tokens[3].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + }); + + it('yields property change tokens for height command', () => { + const parser = new MTextParser('\\H2.5;Text', undefined, { yieldPropertyCommands: true }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(2); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'H', + changes: { + capHeight: { value: 2.5, isRelative: false }, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Text'); + expect(tokens[1].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + }); + + it('yields property change tokens for multiple commands', () => { + const parser = new MTextParser('\\H2.5;\\C1;\\LText\\l', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(5); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'H', + changes: { + capHeight: { value: 2.5, isRelative: false }, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[1].data).toEqual({ + command: 'C', + changes: { + aci: 1, + }, + depth: 0, + }); + expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[2].data).toEqual({ + command: 'L', + changes: { + underline: true, + }, + depth: 0, + }); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Text'); + expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[3].ctx.underline).toBe(true); + expect(tokens[4].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[4].data).toEqual({ + command: 'l', + changes: { + underline: false, + }, + depth: 0, + }); + }); + + it('yields property change tokens for paragraph properties', () => { + const parser = new MTextParser('\\pi2;\\pqc;Indented Centered', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(5); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'p', + changes: { + paragraph: { + indent: 2, + }, + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[1].data).toEqual({ + command: 'p', + changes: { + paragraph: { + align: MTextParagraphAlignment.CENTER, + }, + }, + depth: 0, + }); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Indented'); + expect(tokens[2].ctx.paragraph.indent).toBe(2); + expect(tokens[2].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); + expect(tokens[3].type).toBe(TokenType.SPACE); + expect(tokens[3].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); + expect(tokens[4].type).toBe(TokenType.WORD); + expect(tokens[4].data).toBe('Centered'); + expect(tokens[4].ctx.paragraph.align).toBe(MTextParagraphAlignment.CENTER); + }); + + it('yields property change tokens for complex formatting', () => { + const parser = new MTextParser('{\\H2.5;\\C1;\\FArial|b1|i1;Formatted Text}', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(7); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'H', + changes: { + capHeight: { value: 2.5, isRelative: false }, + }, + depth: 1, + }); + expect(tokens[1].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[1].data).toEqual({ + command: 'C', + changes: { + aci: 1, + }, + depth: 1, + }); + expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[2].data).toEqual({ + command: 'F', + changes: { + fontFace: { + family: 'Arial', + style: 'Italic', + weight: 700, + }, + }, + depth: 1, + }); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Formatted'); + expect(tokens[3].ctx.capHeight).toEqual({ value: 2.5, isRelative: false }); + expect(tokens[3].ctx.aci).toBe(1); + expect(tokens[3].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + expect(tokens[4].type).toBe(TokenType.SPACE); + expect(tokens[4].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + expect(tokens[5].type).toBe(TokenType.WORD); + expect(tokens[5].data).toBe('Text'); + expect(tokens[5].ctx.fontFace).toEqual({ + family: 'Arial', + style: 'Italic', + weight: 700, + }); + expect(tokens[6].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[6].data).toEqual({ + command: undefined, + changes: { + aci: 256, + capHeight: { value: 1, isRelative: false }, + fontFace: { family: '', style: 'Regular', weight: 400 }, + }, + depth: 0, + }); + }); + }); + + describe('MTextParser context restoration with braces {}', () => { + it('scopes font formatting to braces and restores after', () => { + const parser = new MTextParser('Normal {\\fArial|i;Italic} Back to normal'); + const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); + expect(tokens[0].data).toBe('Normal'); + expect(tokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); + expect(tokens[1].data).toBe('Italic'); + expect(tokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 }); + expect(tokens[2].data).toBe('Back'); + expect(tokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); + }); + + it('scopes color formatting to braces and restores after', () => { + const parser = new MTextParser('{\\C1;Red} Normal'); + const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); + expect(tokens[0].data).toBe('Red'); + expect(tokens[0].ctx.aci).toBe(1); + expect(tokens[1].data).toBe('Normal'); + expect(tokens[1].ctx.aci).toBe(256); // default + }); + + it('restores previous formatting after a formatting block', () => { + const parser = new MTextParser('\\C2;Before {\\C1;Red} After'); + const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); + expect(tokens[0].data).toBe('Before'); + expect(tokens[0].ctx.aci).toBe(2); + expect(tokens[1].data).toBe('Red'); + expect(tokens[1].ctx.aci).toBe(1); + expect(tokens[2].data).toBe('After'); + expect(tokens[2].ctx.aci).toBe(2); + }); + + it('restores context correctly with nested braces', () => { + const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}'); + const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); + expect(tokens[0].data).toBe('Red'); + expect(tokens[0].ctx.aci).toBe(1); + expect(tokens[1].data).toBe('Blue'); + expect(tokens[1].ctx.aci).toBe(2); + expect(tokens[2].data).toBe('RedAgain'); + expect(tokens[2].ctx.aci).toBe(1); + }); + + it('persists formatting outside braces if not reset', () => { + const parser = new MTextParser('\\C3;All {\\C1;Red} StillAll'); + const tokens = Array.from(parser.parse()).filter(t => t.type === TokenType.WORD); + expect(tokens[0].data).toBe('All'); + expect(tokens[0].ctx.aci).toBe(3); + expect(tokens[1].data).toBe('Red'); + expect(tokens[1].ctx.aci).toBe(1); + expect(tokens[2].data).toBe('StillAll'); + expect(tokens[2].ctx.aci).toBe(3); + }); + }); + + describe('MTextParser context restoration with braces {} and yieldPropertyCommands', () => { + it('yields property change tokens when entering and exiting a formatting block', () => { + const parser = new MTextParser('Normal {\\fArial|i;Italic} Back', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + // Filter for property changes and words + const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); + const wordTokens = tokens.filter(t => t.type === TokenType.WORD); + // Should yield a property change for entering Arial Italic + expect(propTokens[0].data).toEqual({ + command: 'f', + changes: { fontFace: { family: 'Arial', style: 'Italic', weight: 400 } }, + depth: 1, + }); + // Should yield a property change for restoring default font after block + expect(propTokens[propTokens.length - 1].data).toEqual({ + command: undefined, + changes: { fontFace: { family: '', style: 'Regular', weight: 400 } }, + depth: 0, + }); + // Check word tokens context + expect(wordTokens[0].data).toBe('Normal'); + expect(wordTokens[0].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); + expect(wordTokens[1].data).toBe('Italic'); + expect(wordTokens[1].ctx.fontFace).toEqual({ family: 'Arial', style: 'Italic', weight: 400 }); + expect(wordTokens[2].data).toBe('Back'); + expect(wordTokens[2].ctx.fontFace).toEqual({ family: '', style: 'Regular', weight: 400 }); + }); + + it('yields property change tokens for color and restores after block', () => { + const parser = new MTextParser('{\\C1;Red} Normal', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); + const wordTokens = tokens.filter(t => t.type === TokenType.WORD); + expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 }); + expect(propTokens[propTokens.length - 1].data).toEqual({ + command: undefined, + changes: { aci: 256 }, + depth: 0, + }); + expect(wordTokens[0].data).toBe('Red'); + expect(wordTokens[0].ctx.aci).toBe(1); + expect(wordTokens[1].data).toBe('Normal'); + expect(wordTokens[1].ctx.aci).toBe(256); + }); + + it('yields property change tokens for nested braces', () => { + const parser = new MTextParser('{\\C1;Red {\\C2;Blue} RedAgain}', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + const propTokens = tokens.filter(t => t.type === TokenType.PROPERTIES_CHANGED); + const wordTokens = tokens.filter(t => t.type === TokenType.WORD); + // Enter C1 + expect(propTokens[0].data).toEqual({ command: 'C', changes: { aci: 1 }, depth: 1 }); + // Enter C2 + expect(propTokens[1].data).toEqual({ command: 'C', changes: { aci: 2 }, depth: 2 }); + // Exit C2 (restore C1) + expect(propTokens[2].data).toEqual({ command: undefined, changes: { aci: 1 }, depth: 1 }); + // Exit C1 (restore default) + expect(propTokens[propTokens.length - 1].data).toEqual({ + command: undefined, + changes: { aci: 256 }, + depth: 0, + }); + expect(wordTokens[0].data).toBe('Red'); + expect(wordTokens[0].ctx.aci).toBe(1); + expect(wordTokens[1].data).toBe('Blue'); + expect(wordTokens[1].ctx.aci).toBe(2); + expect(wordTokens[2].data).toBe('RedAgain'); + expect(wordTokens[2].ctx.aci).toBe(1); + }); + + it('yields property change tokens for RGB color commands', () => { + // \c16711680 is 0xFF0000, which is [255,0,0] (red) + const parser = new MTextParser('\\c16711680Red Text', undefined, { + yieldPropertyCommands: true, + }); + const tokens = Array.from(parser.parse()); + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(TokenType.PROPERTIES_CHANGED); + expect(tokens[0].data).toEqual({ + command: 'c', + changes: { + aci: null, + rgb: [255, 0, 0], + }, + depth: 0, + }); + expect(tokens[1].type).toBe(TokenType.WORD); + expect(tokens[1].data).toBe('Red'); + expect(tokens[1].ctx.rgb).toEqual([255, 0, 0]); + expect(tokens[2].type).toBe(TokenType.SPACE); + expect(tokens[2].ctx.rgb).toEqual([255, 0, 0]); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Text'); + expect(tokens[3].ctx.rgb).toEqual([255, 0, 0]); + }); + }); +}); + +describe('MTextParser resetParagraphParameters option', () => { + it('resets paragraph properties after NEW_PARAGRAPH when resetParagraphParameters is true', () => { + // Create a context with non-default paragraph properties + const ctx = new MTextContext(); + ctx.paragraph.indent = 2; + ctx.paragraph.align = MTextParagraphAlignment.LEFT; + + const parser = new MTextParser('Line1\\PLine2', ctx, { + yieldPropertyCommands: true, + resetParagraphParameters: true, + }); + const tokens = Array.from(parser.parse()); + // Should emit: WORD(Line1), NEW_PARAGRAPH, PROPERTIES_CHANGED (reset), WORD(Line2) + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Line1'); + expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[2].type).toBe(TokenType.PROPERTIES_CHANGED); + const propChanged = tokens[2].data as import('./parser').ChangedProperties; + expect(propChanged.changes).toHaveProperty('paragraph'); + expect(tokens[3].type).toBe(TokenType.WORD); + expect(tokens[3].data).toBe('Line2'); + }); + + it('does not emit PROPERTIES_CHANGED after NEW_PARAGRAPH if resetParagraphParameters is false', () => { + // Create a context with non-default paragraph properties + const ctx = new MTextContext(); + ctx.paragraph.indent = 2; + ctx.paragraph.align = MTextParagraphAlignment.CENTER; + + const parser = new MTextParser('Line1\\PLine2', ctx, { + yieldPropertyCommands: true, + resetParagraphParameters: false, + }); + const tokens = Array.from(parser.parse()); + // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Line1'); + expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Line2'); + expect( + tokens.find( + t => + t.type === TokenType.PROPERTIES_CHANGED && + t.data && + (t.data as import('./parser').ChangedProperties).changes?.paragraph + ) + ).toBeUndefined(); + }); + + it('resets paragraph properties but does not emit PROPERTIES_CHANGED if yieldPropertyCommands is false', () => { + // Create a context with non-default paragraph properties + const ctx = new MTextContext(); + ctx.paragraph.indent = 2; + ctx.paragraph.align = MTextParagraphAlignment.CENTER; + + const parser = new MTextParser('Line1\\PLine2', ctx, { + yieldPropertyCommands: false, + resetParagraphParameters: true, + }); + const tokens = Array.from(parser.parse()); + // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Line1'); + expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Line2'); + expect( + tokens.find( + t => + t.type === TokenType.PROPERTIES_CHANGED && + t.data && + (t.data as import('./parser').ChangedProperties).changes?.paragraph + ) + ).toBeUndefined(); + }); + + it('does not emit PROPERTIES_CHANGED when using default context with resetParagraphParameters true', () => { + const parser = new MTextParser('Line1\\PLine2', undefined, { + yieldPropertyCommands: true, + resetParagraphParameters: true, + }); + const tokens = Array.from(parser.parse()); + // Should emit: WORD(Line1), NEW_PARAGRAPH, WORD(Line2) - no PROPERTIES_CHANGED because default context has default paragraph properties + expect(tokens[0].type).toBe(TokenType.WORD); + expect(tokens[0].data).toBe('Line1'); + expect(tokens[1].type).toBe(TokenType.NEW_PARAGRAPH); + expect(tokens[2].type).toBe(TokenType.WORD); + expect(tokens[2].data).toBe('Line2'); + expect( + tokens.find( + t => + t.type === TokenType.PROPERTIES_CHANGED && + t.data && + (t.data as import('./parser').ChangedProperties).changes?.paragraph + ) + ).toBeUndefined(); + }); +}); + +describe('TextScanner', () => { + let scanner: TextScanner; + + beforeEach(() => { + scanner = new TextScanner('Hello World'); + }); + + it('initializes with correct state', () => { + expect(scanner.currentIndex).toBe(0); + expect(scanner.isEmpty).toBe(false); + expect(scanner.hasData).toBe(true); + }); + + it('consumes characters', () => { + expect(scanner.get()).toBe('H'); + expect(scanner.currentIndex).toBe(1); + expect(scanner.get()).toBe('e'); + expect(scanner.currentIndex).toBe(2); + }); + + it('peeks characters', () => { + expect(scanner.peek()).toBe('H'); + expect(scanner.peek(1)).toBe('e'); + expect(scanner.currentIndex).toBe(0); + }); + + it('consumes multiple characters', () => { + scanner.consume(5); + expect(scanner.currentIndex).toBe(5); + expect(scanner.get()).toBe(' '); + }); + + it('finds characters', () => { + expect(scanner.find('W')).toBe(6); + expect(scanner.find('X')).toBe(-1); + }); + + it('handles escaped characters in find', () => { + scanner = new TextScanner('Hello\\;World'); + expect(scanner.find(';', true)).toBe(6); + }); + + it('gets remaining text', () => { + scanner.consume(6); + expect(scanner.tail).toBe('World'); + }); + + it('handles end of text', () => { + scanner.consume(11); + expect(scanner.isEmpty).toBe(true); + expect(scanner.hasData).toBe(false); + expect(scanner.get()).toBe(''); + expect(scanner.peek()).toBe(''); + }); +}); + +describe('getFonts', () => { + it('should return empty set for empty string', () => { + const result = getFonts(''); + expect(result).toEqual(new Set()); + }); + + it('should extract single font name', () => { + const result = getFonts('\\fArial|Hello World'); + expect(result).toEqual(new Set(['arial'])); + }); + + it('should extract multiple unique font names', () => { + const result = getFonts('\\fArial|Hello \\fTimes New Roman|World'); + expect(result).toEqual(new Set(['arial', 'times new roman'])); + }); + + it('should handle case-insensitive font names', () => { + const result = getFonts('\\fARIAL|Hello \\fArial|World'); + expect(result).toEqual(new Set(['arial'])); + }); + + it('should handle font names with spaces', () => { + const result = getFonts('\\fTimes New Roman|Hello \\fComic Sans MS|World'); + expect(result).toEqual(new Set(['times new roman', 'comic sans ms'])); + }); + + it('should handle multiple font changes in sequence', () => { + const result = getFonts('\\fArial|Hello \\fTimes New Roman|World \\fArial|Again'); + expect(result).toEqual(new Set(['arial', 'times new roman'])); + }); + + it('should handle font names with special characters', () => { + const result = getFonts('\\fArial-Bold|Hello \\fTimes-New-Roman|World'); + expect(result).toEqual(new Set(['arial-bold', 'times-new-roman'])); + }); + + it('should handle both lowercase and uppercase font commands', () => { + const result = getFonts('\\fArial|Hello \\FTimes New Roman|World'); + expect(result).toEqual(new Set(['arial', 'times new roman'])); + }); + + it('should handle complex MText with semicolon terminators', () => { + const mtext = + '{\\C1;\\W2;\\FSimSun;SimSun Text}\\P{\\C2;\\W0.5;\\FArial;Arial Text}\\P{\\C3;\\O30;\\FRomans;Romans Text}\\P{\\C4;\\Q1;\\FSimHei;SimHei Text}\\P{\\C5;\\Q0.5;\\FSimKai;SimKai Text}'; + const result = getFonts(mtext); + expect(result).toEqual(new Set(['simsun', 'arial', 'romans', 'simhei', 'simkai'])); + }); + + it('should preserve font extensions when removeExtension is false', () => { + const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World'; + const result = getFonts(mtext, false); + expect(result).toEqual(new Set(['arial.ttf', 'times new roman.otf'])); + }); + + it('should remove font extensions when removeExtension is true', () => { + const mtext = '\\fArial.ttf|Hello \\fTimes New Roman.otf|World'; + const result = getFonts(mtext, true); + expect(result).toEqual(new Set(['arial', 'times new roman'])); + }); + + it('should handle various font extensions', () => { + const mtext = '\\fFont1.ttf|Text1 \\fFont2.otf|Text2 \\fFont3.woff|Text3 \\fFont4.shx|Text4'; + const result = getFonts(mtext, true); + expect(result).toEqual(new Set(['font1', 'font2', 'font3', 'font4'])); + }); + + it('should not remove non-font extensions', () => { + const mtext = '\\fFont1.txt|Text1 \\fFont2.doc|Text2'; + const result = getFonts(mtext, true); + expect(result).toEqual(new Set(['font1.txt', 'font2.doc'])); + }); +}); + +describe('MTextColor', () => { + it('defaults to ACI 256 (by layer)', () => { + const color = new MTextColor(); + expect(color.aci).toBe(256); + expect(color.rgb).toBeNull(); + expect(color.isAci).toBe(true); + expect(color.isRgb).toBe(false); + }); + + it('can be constructed with ACI', () => { + const color = new MTextColor(1); + expect(color.aci).toBe(1); + expect(color.rgb).toBeNull(); + expect(color.isAci).toBe(true); + expect(color.isRgb).toBe(false); + }); + + it('can be constructed with RGB', () => { + const color = new MTextColor([255, 0, 0]); + expect(color.aci).toBeNull(); + expect(color.rgb).toEqual([255, 0, 0]); + expect(color.isAci).toBe(false); + expect(color.isRgb).toBe(true); + }); + + it('switches from ACI to RGB and back', () => { + const color = new MTextColor(2); + expect(color.aci).toBe(2); + color.rgb = [0, 255, 0]; + expect(color.aci).toBeNull(); + expect(color.rgb).toEqual([0, 255, 0]); + color.aci = 7; + expect(color.aci).toBe(7); + expect(color.rgb).toBeNull(); + }); + + it('copy() produces a deep copy', () => { + const color = new MTextColor([1, 2, 3]); + const copy = color.copy(); + expect(copy).not.toBe(color); + expect(copy.aci).toBe(color.aci); + expect(copy.rgb).toEqual(color.rgb); + copy.rgb = [4, 5, 6]; + expect(color.rgb).toEqual([1, 2, 3]); + expect(copy.rgb).toEqual([4, 5, 6]); + }); + + it('fromCssColor parses hex and rgb()', () => { + const hex = MTextColor.fromCssColor('#ff0000'); + expect(hex).not.toBeNull(); + expect(hex!.rgb).toEqual([255, 0, 0]); + + const shortHex = MTextColor.fromCssColor('#0f0'); + expect(shortHex).not.toBeNull(); + expect(shortHex!.rgb).toEqual([0, 255, 0]); + + const rgb = MTextColor.fromCssColor('rgb(0, 128, 255)'); + expect(rgb).not.toBeNull(); + expect(rgb!.rgb).toEqual([0, 128, 255]); + }); + + it('fromCssColor handles rgba() and rejects transparent', () => { + const rgba = MTextColor.fromCssColor('rgba(10, 20, 30, 0.5)'); + expect(rgba).not.toBeNull(); + expect(rgba!.rgb).toEqual([10, 20, 30]); + + const transparent = MTextColor.fromCssColor('transparent'); + expect(transparent).toBeNull(); + }); + + it('toCssColor returns hex for RGB and null for ACI', () => { + const rgb = new MTextColor([255, 0, 0]); + expect(rgb.toCssColor()).toBe('#ff0000'); + + const aci = new MTextColor(1); + expect(aci.toCssColor()).toBeNull(); + }); +}); diff --git a/src/parser.ts b/src/parser.ts index b2d04c7..17c4b0a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,1998 +1,2080 @@ -/** - * Token types used in MText parsing - */ -export enum TokenType { - /** No token */ - NONE = 0, - /** Word token with string data */ - WORD = 1, - /** Stack token with [numerator, denominator, type] data */ - STACK = 2, - /** Space token with no data */ - SPACE = 3, - /** Non-breaking space token with no data */ - NBSP = 4, - /** Tab token with no data */ - TABULATOR = 5, - /** New paragraph token with no data */ - NEW_PARAGRAPH = 6, - /** New column token with no data */ - NEW_COLUMN = 7, - /** Wrap at dimension line token with no data */ - WRAP_AT_DIMLINE = 8, - /** Properties changed token with string data (full command) */ - PROPERTIES_CHANGED = 9, -} - -/** - * Represents a factor value that can be either absolute or relative. - * Used for properties like height, width, and character tracking in MText formatting. - */ -export interface FactorValue { - /** The numeric value of the factor */ - value: number; - /** Whether the value is relative (true) or absolute (false) */ - isRelative: boolean; -} - -/** - * Format properties of MText word tokens. - * This interface defines all the formatting properties that can be applied to MText content, - * including text styling, colors, alignment, font properties, and paragraph formatting. - */ -export interface Properties { - /** Whether text is underlined */ - underline?: boolean; - /** Whether text has an overline */ - overline?: boolean; - /** Whether text has strike-through */ - strikeThrough?: boolean; - /** AutoCAD Color Index (ACI) color value (0-256), or null if not set */ - aci?: number | null; - /** RGB color tuple [r, g, b], or null if not set */ - rgb?: RGB | null; - /** Line alignment for the text */ - align?: MTextLineAlignment; - /** Font face properties including family, style, and weight */ - fontFace?: FontFace; - /** Capital letter height factor (can be relative or absolute) */ - capHeight?: FactorValue; - /** Character width factor (can be relative or absolute) */ - widthFactor?: FactorValue; - /** Character tracking factor for spacing between characters (can be relative or absolute) */ - charTrackingFactor?: FactorValue; - /** Oblique angle in degrees for text slant */ - oblique?: number; - /** Paragraph formatting properties (partial to allow selective updates) */ - paragraph?: Partial; -} - -/** - * Represents a change in MText properties, including the command, the changed properties, and the context depth. - */ -export interface ChangedProperties { - /** - * The property command that triggered the change (e.g., 'L', 'C', 'f'). - * The command will be undefined if it is to restore context. - */ - command: string | undefined; - /** - * The set of properties that have changed as a result of the command. - */ - changes: Properties; - /** - * The current context stack depth when the property change occurs. - * - 0: The change is global (applies outside of any `{}` block). - * - >0: The change is local (applies within one or more nested `{}` blocks). - */ - depth: number; // 0 = global, >0 = local -} - -/** - * Type for token data based on token type - */ -export type TokenData = { - [TokenType.NONE]: null; - [TokenType.WORD]: string; - [TokenType.STACK]: [string, string, string]; - [TokenType.SPACE]: null; - [TokenType.NBSP]: null; - [TokenType.TABULATOR]: null; - [TokenType.NEW_PARAGRAPH]: null; - [TokenType.NEW_COLUMN]: null; - [TokenType.WRAP_AT_DIMLINE]: null; - [TokenType.PROPERTIES_CHANGED]: ChangedProperties; -}; - -/** - * Line alignment options for MText - */ -export enum MTextLineAlignment { - /** Align text to bottom */ - BOTTOM = 0, - /** Align text to middle */ - MIDDLE = 1, - /** Align text to top */ - TOP = 2, -} - -/** - * Paragraph alignment options for MText - */ -export enum MTextParagraphAlignment { - /** Default alignment */ - DEFAULT = 0, - /** Left alignment */ - LEFT = 1, - /** Right alignment */ - RIGHT = 2, - /** Center alignment */ - CENTER = 3, - /** Justified alignment */ - JUSTIFIED = 4, - /** Distributed alignment */ - DISTRIBUTED = 5, -} - -/** - * Text stroke options for MText - */ -export enum MTextStroke { - /** No stroke */ - NONE = 0, - /** Underline stroke */ - UNDERLINE = 1, - /** Overline stroke */ - OVERLINE = 2, - /** Strike-through stroke */ - STRIKE_THROUGH = 4, -} - -/** - * RGB color tuple - */ -export type RGB = [number, number, number]; - -/** - * Font style type - */ -export type FontStyle = 'Regular' | 'Italic'; - -/** - * Font face properties - */ -export interface FontFace { - /** Font family name */ - family: string; - /** Font style (e.g., 'Regular', 'Italic') */ - style: FontStyle; - /** Font weight (e.g., 400 for normal, 700 for bold) */ - weight: number; -} - -/** - * Paragraph properties - */ -export interface ParagraphProperties { - /** Indentation value */ - indent: number; - /** Left margin value */ - left: number; - /** Right margin value */ - right: number; - /** Paragraph alignment */ - align: MTextParagraphAlignment; - /** Tab stop positions and types */ - tabs: (number | string)[]; -} - -/** - * Special character encoding mapping - */ -const SPECIAL_CHAR_ENCODING: Record = { - c: 'Ø', - d: '°', - p: '±', - '%': '%', -}; - -/** - * Character to paragraph alignment mapping - */ -const CHAR_TO_ALIGN: Record = { - l: MTextParagraphAlignment.LEFT, - r: MTextParagraphAlignment.RIGHT, - c: MTextParagraphAlignment.CENTER, - j: MTextParagraphAlignment.JUSTIFIED, - d: MTextParagraphAlignment.DISTRIBUTED, -}; - -/** - * Convert RGB tuple to integer color value - * @param rgb - RGB color tuple - * @returns Integer color value - */ -export function rgb2int(rgb: RGB): number { - const [r, g, b] = rgb; - return (r << 16) | (g << 8) | b; -} - -/** - * Convert integer color value to RGB tuple - * @param value - Integer color value - * @returns RGB color tuple - */ -export function int2rgb(value: number): RGB { - const r = (value >> 16) & 0xff; - const g = (value >> 8) & 0xff; - const b = value & 0xff; - return [r, g, b]; -} - -function clampColorChannel(value: number): number { - return Math.max(0, Math.min(255, Math.round(value))); -} - -function normalizeColorNumber(color: number): number { - return Math.max(0, Math.min(0xffffff, Math.round(color))); -} - -function colorNumberToHex(color: number | null): string | null { - if (color === null) return null; - return `#${normalizeColorNumber(color).toString(16).padStart(6, '0')}`; -} - -function normalizeHexColor(value: string | null | undefined): string | null { - if (!value) return null; - const normalized = value.trim().toLowerCase(); - if (/^#[0-9a-f]{6}$/.test(normalized)) return normalized; - if (/^[0-9a-f]{6}$/.test(normalized)) return `#${normalized}`; - if (/^#[0-9a-f]{3}$/.test(normalized)) { - const r = normalized[1]; - const g = normalized[2]; - const b = normalized[3]; - return `#${r}${r}${g}${g}${b}${b}`; - } - if (/^[0-9a-f]{3}$/.test(normalized)) { - const r = normalized[0]; - const g = normalized[1]; - const b = normalized[2]; - return `#${r}${r}${g}${g}${b}${b}`; - } - return null; -} - -function cssColorToRgbValue(value: string | null | undefined): number | null { - if (!value) return null; - const raw = value.trim().toLowerCase(); - if (raw === 'transparent') return null; - - const hex = normalizeHexColor(raw); - if (hex) { - return normalizeColorNumber(Number.parseInt(hex.slice(1), 16)); - } - - const fnMatch = raw.match(/^rgba?\((.*)\)$/); - if (!fnMatch) return null; - - const parts = fnMatch[1] - .replace(/\s*\/\s*/g, ' ') - .split(/[,\s]+/) - .map(p => p.trim()) - .filter(Boolean); - - if (parts.length < 3) return null; - - const toChannel = (token: string): number => { - if (token.endsWith('%')) { - const percent = Number.parseFloat(token.slice(0, -1)); - return clampColorChannel((percent / 100) * 255); - } - const num = Number.parseFloat(token); - return clampColorChannel(num); - }; - - const r = toChannel(parts[0]); - const g = toChannel(parts[1]); - const b = toChannel(parts[2]); - return rgb2int([r, g, b]); -} - -/** - * Escape DXF line endings - * @param text - Text to escape - * @returns Escaped text - */ -export function escapeDxfLineEndings(text: string): string { - return text.replace(/\r\n|\r|\n/g, '\\P'); -} - -/** - * Check if text contains inline formatting codes - * @param text - Text to check - * @returns True if text contains formatting codes - */ -export function hasInlineFormattingCodes(text: string): boolean { - return text.replace(/\\P/g, '').replace(/\\~/g, '').includes('\\'); -} - -/** - * Extracts all unique font names used in an MText string. - * This function searches for font commands in the format \f{fontname}| or \f{fontname}; and returns a set of unique font names. - * Font names are converted to lowercase to ensure case-insensitive uniqueness. - * - * @param mtext - The MText string to analyze for font names - * @param removeExtension - Whether to remove font file extensions (e.g., .ttf, .shx) from font names. Defaults to false. - * @returns A Set containing all unique font names found in the MText string, converted to lowercase - * @example - * ```ts - * const mtext = "\\fArial.ttf|Hello\\fTimes New Roman.otf|World"; - * const fonts = getFonts(mtext, true); - * // Returns: Set(2) { "arial", "times new roman" } - * ``` - */ -export function getFonts(mtext: string, removeExtension: boolean = false) { - const fonts: Set = new Set(); - const regex = /\\[fF](.*?)[;|]/g; - - [...mtext.matchAll(regex)].forEach(match => { - let fontName = match[1].toLowerCase(); - if (removeExtension) { - fontName = fontName.replace(/\.(ttf|otf|woff|shx)$/, ''); - } - fonts.add(fontName); - }); - - return fonts; -} - -/** - * ContextStack manages a stack of MTextContext objects for character-level formatting. - * - * - Character-level formatting (underline, color, font, etc.) is scoped to `{}` blocks and managed by the stack. - * - Paragraph-level formatting (\p) is not scoped, but when a block ends, any paragraph property changes are merged into the parent context. - * - On pop, paragraph properties from the popped context are always merged into the new top context. - */ -class ContextStack { - private stack: MTextContext[] = []; - - /** - * Creates a new ContextStack with an initial context. - * @param initial The initial MTextContext to use as the base of the stack. - */ - constructor(initial: MTextContext) { - this.stack.push(initial); - } - - /** - * Pushes a copy of the given context onto the stack. - * @param ctx The MTextContext to push (copied). - */ - push(ctx: MTextContext) { - this.stack.push(ctx); - } - - /** - * Pops the top context from the stack and merges its paragraph properties into the new top context. - * If only one context remains, nothing is popped. - * @returns The popped MTextContext, or undefined if the stack has only one context. - */ - pop(): MTextContext | undefined { - if (this.stack.length <= 1) return undefined; - const popped = this.stack.pop()!; - // Merge paragraph properties into the new top context - const top = this.stack[this.stack.length - 1]; - if (JSON.stringify(top.paragraph) !== JSON.stringify(popped.paragraph)) { - top.paragraph = { ...popped.paragraph }; - } - return popped; - } - - /** - * Returns the current (top) context on the stack. - */ - get current(): MTextContext { - return this.stack[this.stack.length - 1]; - } - - /** - * Returns the current stack depth (number of nested blocks), not counting the root context. - */ - get depth(): number { - return this.stack.length - 1; - } - - /** - * Returns the root (bottom) context, which represents the global formatting state. - * Used for paragraph property application. - */ - get root(): MTextContext { - return this.stack[0]; - } - - /** - * Replaces the current (top) context with the given context. - * @param ctx The new context to set as the current context. - */ - setCurrent(ctx: MTextContext) { - this.stack[this.stack.length - 1] = ctx; - } -} - -/** - * Configuration options for the MText parser. - * These options control how the parser behaves during tokenization and property handling. - */ -export interface MTextParserOptions { - /** - * Whether to yield PROPERTIES_CHANGED tokens when formatting properties change. - * When true, the parser will emit tokens whenever properties like color, font, or alignment change. - * When false, property changes are applied silently to the context without generating tokens. - * @default false - */ - yieldPropertyCommands?: boolean; - /** - * Whether to reset paragraph parameters when encountering a new paragraph token. - * When true, paragraph properties (indent, margins, alignment, tab stops) are reset to defaults - * at the start of each new paragraph. - * @default false - */ - resetParagraphParameters?: boolean; - /** - * Custom decoder function for MIF (Multibyte Interchange Format) codes. - * If provided, this function will be used instead of the default decodeMultiByteChar. - * The function receives the hex code string and should return the decoded character. - * @param hex - Hex code string (e.g., "C4E3" or "1A2B3") - * @returns Decoded character or empty square (▯) if invalid - * @default undefined (uses default decoder) - */ - mifDecoder?: (hex: string) => string; - /** - * The length of MIF hex codes to parse. MIF codes in AutoCAD can vary in length - * depending on the specific SHX big font used (typically 4 or 5 digits). - * If not specified, the parser will try to auto-detect the length by attempting - * to match 4 digits first, then 5 digits if needed. - * @default undefined (auto-detect) - */ - mifCodeLength?: 4 | 5 | 'auto'; -} - -/** - * Main parser class for MText content - */ -export class MTextParser { - private scanner: TextScanner; - private ctxStack: ContextStack; - private continueStroke: boolean = false; - private yieldPropertyCommands: boolean; - private resetParagraphParameters: boolean; - private inStackContext: boolean = false; - private mifDecoder: (hex: string) => string; - private mifCodeLength: 4 | 5 | 'auto'; - - /** - * Creates a new MTextParser instance - * @param content - The MText content to parse - * @param ctx - Optional initial MText context - * @param options - Parser options - */ - constructor(content: string, ctx?: MTextContext, options: MTextParserOptions = {}) { - this.scanner = new TextScanner(content); - const initialCtx = ctx ?? new MTextContext(); - this.ctxStack = new ContextStack(initialCtx); - this.yieldPropertyCommands = options.yieldPropertyCommands ?? false; - this.resetParagraphParameters = options.resetParagraphParameters ?? false; - this.mifDecoder = options.mifDecoder ?? this.decodeMultiByteChar.bind(this); - this.mifCodeLength = options.mifCodeLength ?? 'auto'; - } - - /** - * Decode multi-byte character from hex code - * @param hex - Hex code string (e.g. "C4E3" or "1A2B3") - * @returns Decoded character or empty square if invalid - */ - private decodeMultiByteChar(hex: string): string { - try { - // For 5-digit codes, return placeholder directly - if (hex.length === 5) { - const prefix = hex[0]; - - // Notes: - // I know AutoCAD uses prefix 1 for Shift-JIS, 2 for big5, and 5 for gbk. - // But I don't know whether there are other prefixes and their meanings. - let encoding = 'gbk'; - if (prefix === '1') { - encoding = 'shift-jis'; - } else if (prefix === '2') { - encoding = 'big5'; - } - const bytes = new Uint8Array([ - parseInt(hex.substr(1, 2), 16), - parseInt(hex.substr(3, 2), 16), - ]); - const decoder = new TextDecoder(encoding); - const result = decoder.decode(bytes); - return result; - } else if (hex.length === 4) { - // For 4-digit hex codes, decode as 2-byte character - const bytes = new Uint8Array([ - parseInt(hex.substr(0, 2), 16), - parseInt(hex.substr(2, 2), 16), - ]); - - // Try GBK first - const gbkDecoder = new TextDecoder('gbk'); - const gbkResult = gbkDecoder.decode(bytes); - if (gbkResult !== '▯') { - return gbkResult; - } - - // Try BIG5 if GBK fails - const big5Decoder = new TextDecoder('big5'); - const big5Result = big5Decoder.decode(bytes); - if (big5Result !== '▯') { - return big5Result; - } - } - - return '▯'; - } catch { - return '▯'; - } - } - - /** - * Extract MIF hex code from scanner - * @param length - The length of the hex code to extract (4 or 5), or 'auto' to detect - * @returns The extracted hex code, or null if not found - */ - private extractMifCode(length: 4 | 5 | 'auto'): string | null { - if (length === 'auto') { - // Try 5 digits first if available, then fall back to 4 digits - const code5 = this.scanner.tail.match(/^[0-9A-Fa-f]{5}/)?.[0]; - if (code5) { - return code5; - } - const code4 = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/)?.[0]; - if (code4) { - return code4; - } - return null; - } else { - const code = this.scanner.tail.match(new RegExp(`^[0-9A-Fa-f]{${length}}`))?.[0]; - return code ?? null; - } - } - - /** - * Push current context onto the stack - */ - private pushCtx(): void { - this.ctxStack.push(this.ctxStack.current); - } - - /** - * Pop context from the stack - */ - private popCtx(): void { - this.ctxStack.pop(); - } - - /** - * Parse stacking expression (numerator/denominator) - * @returns Tuple of [TokenType.STACK, [numerator, denominator, type]] - */ - private parseStacking(): [TokenType, [string, string, string]] { - const scanner = new TextScanner(this.extractExpression(true)); - let numerator = ''; - let denominator = ''; - let stackingType = ''; - - const getNextChar = (): [string, boolean] => { - let c = scanner.peek(); - let escape = false; - if (c.charCodeAt(0) < 32) { - c = ' '; - } - if (c === '\\') { - escape = true; - scanner.consume(1); - c = scanner.peek(); - } - scanner.consume(1); - return [c, escape]; - }; - - const parseNumerator = (): [string, string] => { - let word = ''; - while (scanner.hasData) { - const [c, escape] = getNextChar(); - // Check for stacking operators first - if (!escape && (c === '/' || c === '#' || c === '^')) { - return [word, c]; - } - word += c; - } - return [word, '']; - }; - - const parseDenominator = (skipLeadingSpace: boolean): string => { - let word = ''; - let skipping = skipLeadingSpace; - while (scanner.hasData) { - const [c, escape] = getNextChar(); - if (skipping && c === ' ') { - continue; - } - skipping = false; - // Stop at terminator unless escaped - if (!escape && c === ';') { - break; - } - word += c; - } - return word; - }; - - [numerator, stackingType] = parseNumerator(); - if (stackingType) { - // Only skip leading space for caret divider - denominator = parseDenominator(stackingType === '^'); - } - - // Special case for \S^!/^?; - if (numerator === '' && denominator.includes('I/')) { - return [TokenType.STACK, [' ', ' ', '/']]; - } - - // Handle caret as a stacking operator - if (stackingType === '^') { - return [TokenType.STACK, [numerator, denominator, '^']]; - } - - return [TokenType.STACK, [numerator, denominator, stackingType]]; - } - - /** - * Parse MText properties - * @param cmd - The property command to parse - * @returns Property changes if yieldPropertyCommands is true and changes occurred - */ - private parseProperties(cmd: string): TokenData[TokenType.PROPERTIES_CHANGED] | void { - const prevCtx = this.ctxStack.current.copy(); - const newCtx = this.ctxStack.current.copy(); - switch (cmd) { - case 'L': - newCtx.underline = true; - this.continueStroke = true; - break; - case 'l': - newCtx.underline = false; - if (!newCtx.hasAnyStroke) { - this.continueStroke = false; - } - break; - case 'O': - newCtx.overline = true; - this.continueStroke = true; - break; - case 'o': - newCtx.overline = false; - if (!newCtx.hasAnyStroke) { - this.continueStroke = false; - } - break; - case 'K': - newCtx.strikeThrough = true; - this.continueStroke = true; - break; - case 'k': - newCtx.strikeThrough = false; - if (!newCtx.hasAnyStroke) { - this.continueStroke = false; - } - break; - case 'A': - this.parseAlign(newCtx); - break; - case 'C': - this.parseAciColor(newCtx); - break; - case 'c': - this.parseRgbColor(newCtx); - break; - case 'H': - this.parseHeight(newCtx); - break; - case 'W': - this.parseWidth(newCtx); - break; - case 'Q': - this.parseOblique(newCtx); - break; - case 'T': - this.parseCharTracking(newCtx); - break; - case 'p': - this.parseParagraphProperties(newCtx); - break; - case 'f': - case 'F': - this.parseFontProperties(newCtx); - break; - default: - throw new Error(`Unknown command: ${cmd}`); - } - - // Update continueStroke based on current stroke state - this.continueStroke = newCtx.hasAnyStroke; - newCtx.continueStroke = this.continueStroke; - // Use setCurrent to replace the current context - this.ctxStack.setCurrent(newCtx); - - if (this.yieldPropertyCommands) { - const changes = this.getPropertyChanges(prevCtx, newCtx); - if (Object.keys(changes).length > 0) { - return { - command: cmd, - changes, - depth: this.ctxStack.depth, - }; - } - } - } - - /** - * Get property changes between two contexts - * @param oldCtx - The old context - * @param newCtx - The new context - * @returns Object containing changed properties - */ - private getPropertyChanges( - oldCtx: MTextContext, - newCtx: MTextContext - ): TokenData[TokenType.PROPERTIES_CHANGED]['changes'] { - const changes: TokenData[TokenType.PROPERTIES_CHANGED]['changes'] = {}; - - if (oldCtx.underline !== newCtx.underline) { - changes.underline = newCtx.underline; - } - if (oldCtx.overline !== newCtx.overline) { - changes.overline = newCtx.overline; - } - if (oldCtx.strikeThrough !== newCtx.strikeThrough) { - changes.strikeThrough = newCtx.strikeThrough; - } - if (oldCtx.color.aci !== newCtx.color.aci) { - changes.aci = newCtx.color.aci; - } - if (oldCtx.color.rgbValue !== newCtx.color.rgbValue) { - changes.rgb = newCtx.color.rgb; - } - if (oldCtx.align !== newCtx.align) { - changes.align = newCtx.align; - } - if (JSON.stringify(oldCtx.fontFace) !== JSON.stringify(newCtx.fontFace)) { - changes.fontFace = newCtx.fontFace; - } - if ( - oldCtx.capHeight.value !== newCtx.capHeight.value || - oldCtx.capHeight.isRelative !== newCtx.capHeight.isRelative - ) { - changes.capHeight = newCtx.capHeight; - } - if ( - oldCtx.widthFactor.value !== newCtx.widthFactor.value || - oldCtx.widthFactor.isRelative !== newCtx.widthFactor.isRelative - ) { - changes.widthFactor = newCtx.widthFactor; - } - if ( - oldCtx.charTrackingFactor.value !== newCtx.charTrackingFactor.value || - oldCtx.charTrackingFactor.isRelative !== newCtx.charTrackingFactor.isRelative - ) { - changes.charTrackingFactor = newCtx.charTrackingFactor; - } - if (oldCtx.oblique !== newCtx.oblique) { - changes.oblique = newCtx.oblique; - } - if (JSON.stringify(oldCtx.paragraph) !== JSON.stringify(newCtx.paragraph)) { - // Only include changed paragraph properties - const changedProps: Partial = {}; - if (oldCtx.paragraph.indent !== newCtx.paragraph.indent) { - changedProps.indent = newCtx.paragraph.indent; - } - if (oldCtx.paragraph.align !== newCtx.paragraph.align) { - changedProps.align = newCtx.paragraph.align; - } - if (oldCtx.paragraph.left !== newCtx.paragraph.left) { - changedProps.left = newCtx.paragraph.left; - } - if (oldCtx.paragraph.right !== newCtx.paragraph.right) { - changedProps.right = newCtx.paragraph.right; - } - if (JSON.stringify(oldCtx.paragraph.tabs) !== JSON.stringify(newCtx.paragraph.tabs)) { - changedProps.tabs = newCtx.paragraph.tabs; - } - if (Object.keys(changedProps).length > 0) { - changes.paragraph = changedProps; - } - } - - return changes; - } - - /** - * Parse alignment property - * @param ctx - The context to update - */ - private parseAlign(ctx: MTextContext): void { - const char = this.scanner.get(); - if ('012'.includes(char)) { - ctx.align = parseInt(char) as MTextLineAlignment; - } else { - ctx.align = MTextLineAlignment.BOTTOM; - } - this.consumeOptionalTerminator(); - } - - /** - * Parse height property - * @param ctx - The context to update - */ - private parseHeight(ctx: MTextContext): void { - const expr = this.extractFloatExpression(true); - if (expr) { - try { - if (expr.endsWith('x')) { - // For height command, treat x suffix as relative value - ctx.capHeight = { - value: parseFloat(expr.slice(0, -1)), - isRelative: true, - }; - } else { - ctx.capHeight = { - value: parseFloat(expr), - isRelative: false, - }; - } - } catch { - // If parsing fails, treat the entire command as literal text - this.scanner.consume(-expr.length); // Rewind to before the expression - return; - } - } - this.consumeOptionalTerminator(); - } - - /** - * Parse width property - * @param ctx - The context to update - */ - private parseWidth(ctx: MTextContext): void { - const expr = this.extractFloatExpression(true); - if (expr) { - try { - if (expr.endsWith('x')) { - // For width command, treat x suffix as relative value - ctx.widthFactor = { - value: parseFloat(expr.slice(0, -1)), - isRelative: true, - }; - } else { - ctx.widthFactor = { - value: parseFloat(expr), - isRelative: false, - }; - } - } catch { - // If parsing fails, treat the entire command as literal text - this.scanner.consume(-expr.length); // Rewind to before the expression - return; - } - } - this.consumeOptionalTerminator(); - } - - /** - * Parse character tracking property - * @param ctx - The context to update - */ - private parseCharTracking(ctx: MTextContext): void { - const expr = this.extractFloatExpression(true); - if (expr) { - try { - if (expr.endsWith('x')) { - // For tracking command, treat x suffix as relative value - ctx.charTrackingFactor = { - value: Math.abs(parseFloat(expr.slice(0, -1))), - isRelative: true, - }; - } else { - ctx.charTrackingFactor = { - value: Math.abs(parseFloat(expr)), - isRelative: false, - }; - } - } catch { - // If parsing fails, treat the entire command as literal text - this.scanner.consume(-expr.length); // Rewind to before the expression - return; - } - } - this.consumeOptionalTerminator(); - } - - /** - * Parse float value or factor - * @param value - Current value to apply factor to - * @returns New value - */ - private parseFloatValueOrFactor(value: number): number { - const expr = this.extractFloatExpression(true); - if (expr) { - if (expr.endsWith('x')) { - const factor = parseFloat(expr.slice(0, -1)); - value *= factor; // Allow negative factors - } else { - value = parseFloat(expr); // Allow negative values - } - } - return value; - } - - /** - * Parse oblique angle property - * @param ctx - The context to update - */ - private parseOblique(ctx: MTextContext): void { - const obliqueExpr = this.extractFloatExpression(false); - if (obliqueExpr) { - ctx.oblique = parseFloat(obliqueExpr); - } - this.consumeOptionalTerminator(); - } - - /** - * Parse ACI color property - * @param ctx - The context to update - */ - private parseAciColor(ctx: MTextContext): void { - const aciExpr = this.extractIntExpression(); - if (aciExpr) { - const aci = parseInt(aciExpr); - if (aci < 257) { - ctx.color.aci = aci; - } - } - this.consumeOptionalTerminator(); - } - - /** - * Parse RGB color property - * @param ctx - The context to update - */ - private parseRgbColor(ctx: MTextContext): void { - const rgbExpr = this.extractIntExpression(); - if (rgbExpr) { - const value = parseInt(rgbExpr) & 0xffffff; - ctx.color.rgbValue = value; - } - this.consumeOptionalTerminator(); - } - - /** - * Extract float expression from scanner - * @param relative - Whether to allow relative values (ending in 'x') - * @returns Extracted expression - */ - private extractFloatExpression(relative: boolean = false): string { - const pattern = relative - ? /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?x?/ - : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/; - const match = this.scanner.tail.match(pattern); - if (match) { - const result = match[0]; - this.scanner.consume(result.length); - return result; - } - return ''; - } - - /** - * Extract integer expression from scanner - * @returns Extracted expression - */ - private extractIntExpression(): string { - const match = this.scanner.tail.match(/^\d+/); - if (match) { - const result = match[0]; - this.scanner.consume(result.length); - return result; - } - return ''; - } - - /** - * Extract expression until semicolon or end - * @param escape - Whether to handle escaped semicolons - * @returns Extracted expression - */ - private extractExpression(escape: boolean = false): string { - const stop = this.scanner.find(';', escape); - if (stop < 0) { - const expr = this.scanner.tail; - this.scanner.consume(expr.length); - return expr; - } - // Check if the semicolon is escaped by looking at the previous character - const prevChar = this.scanner.peek(stop - this.scanner.currentIndex - 1); - const isEscaped = prevChar === '\\'; - const expr = this.scanner.tail.slice(0, stop - this.scanner.currentIndex + (isEscaped ? 1 : 0)); - this.scanner.consume(expr.length + 1); - return expr; - } - - /** - * Parse font properties - * @param ctx - The context to update - */ - private parseFontProperties(ctx: MTextContext): void { - const parts = this.extractExpression().split('|'); - if (parts.length > 0 && parts[0]) { - const name = parts[0]; - let style: FontStyle = 'Regular'; - let weight = 400; - - for (const part of parts.slice(1)) { - if (part.startsWith('b1')) { - weight = 700; - } else if (part === 'i' || part.startsWith('i1')) { - style = 'Italic'; - } else if (part === 'i0' || part.startsWith('i0')) { - style = 'Regular'; - } - } - - ctx.fontFace = { - family: name, - style, - weight, - }; - } - } - - /** - * Parse paragraph properties from the MText content - * Handles properties like indentation, alignment, and tab stops - * @param ctx - The context to update - */ - private parseParagraphProperties(ctx: MTextContext): void { - const scanner = new TextScanner(this.extractExpression()); - /** Current indentation value */ - let indent = ctx.paragraph.indent; - /** Left margin value */ - let left = ctx.paragraph.left; - /** Right margin value */ - let right = ctx.paragraph.right; - /** Current paragraph alignment */ - let align = ctx.paragraph.align; - /** Array of tab stop positions and types */ - let tabStops: (number | string)[] = []; - - /** - * Parse a floating point number from the scanner's current position - * Handles optional sign, decimal point, and scientific notation - * @returns The parsed float value, or 0 if no valid number is found - */ - const parseFloatValue = (): number => { - const match = scanner.tail.match(/^[+-]?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/); - if (match) { - const value = parseFloat(match[0]); - scanner.consume(match[0].length); - while (scanner.peek() === ',') { - scanner.consume(1); - } - return value; - } - return 0; - }; - - while (scanner.hasData) { - const cmd = scanner.get(); - switch (cmd) { - case 'i': // Indentation - indent = parseFloatValue(); - break; - case 'l': // Left margin - left = parseFloatValue(); - break; - case 'r': // Right margin - right = parseFloatValue(); - break; - case 'x': // Skip - break; - case 'q': { - // Alignment - const adjustment = scanner.get(); - align = CHAR_TO_ALIGN[adjustment] || MTextParagraphAlignment.DEFAULT; - while (scanner.peek() === ',') { - scanner.consume(1); - } - break; - } - case 't': // Tab stops - tabStops = []; - while (scanner.hasData) { - const type = scanner.peek(); - if (type === 'r' || type === 'c') { - scanner.consume(1); - const value = parseFloatValue(); - tabStops.push(type + value.toString()); - } else { - const value = parseFloatValue(); - if (!isNaN(value)) { - tabStops.push(value); - } else { - scanner.consume(1); - } - } - } - break; - } - } - - ctx.paragraph = { - indent, - left, - right, - align, - tabs: tabStops, - }; - } - - /** - * Consume optional terminator (semicolon) - */ - private consumeOptionalTerminator(): void { - if (this.scanner.peek() === ';') { - this.scanner.consume(1); - } - } - - /** - * Parse MText content into tokens - * @yields MTextToken objects - */ - *parse(): Generator { - const wordToken = TokenType.WORD; - const spaceToken = TokenType.SPACE; - let followupToken: TokenType | null = null; - - function resetParagraph(ctx: MTextContext): Partial { - const prev = { ...ctx.paragraph }; - ctx.paragraph = { - indent: 0, - left: 0, - right: 0, - align: MTextParagraphAlignment.DEFAULT, - tabs: [], - }; - const changed: Partial = {}; - if (prev.indent !== 0) changed.indent = 0; - if (prev.left !== 0) changed.left = 0; - if (prev.right !== 0) changed.right = 0; - if (prev.align !== MTextParagraphAlignment.DEFAULT) - changed.align = MTextParagraphAlignment.DEFAULT; - if (JSON.stringify(prev.tabs) !== JSON.stringify([])) changed.tabs = []; - return changed; - } - - const nextToken = (): [TokenType, TokenData[TokenType]] => { - let word = ''; - while (this.scanner.hasData) { - let escape = false; - let letter = this.scanner.peek(); - const cmdStartIndex = this.scanner.currentIndex; - - // Handle control characters first - if (letter.charCodeAt(0) < 32) { - this.scanner.consume(1); // Always consume the control character - if (letter === '\t') { - return [TokenType.TABULATOR, null]; - } - if (letter === '\n') { - return [TokenType.NEW_PARAGRAPH, null]; - } - letter = ' '; - } - - if (letter === '\\') { - if ('\\{}'.includes(this.scanner.peek(1))) { - escape = true; - this.scanner.consume(1); - letter = this.scanner.peek(); - } else { - if (word) { - return [wordToken, word]; - } - this.scanner.consume(1); - const cmd = this.scanner.get(); - switch (cmd) { - case '~': - return [TokenType.NBSP, null]; - case 'P': - return [TokenType.NEW_PARAGRAPH, null]; - case 'N': - return [TokenType.NEW_COLUMN, null]; - case 'X': - return [TokenType.WRAP_AT_DIMLINE, null]; - case 'S': { - this.inStackContext = true; - const result = this.parseStacking(); - this.inStackContext = false; - return result; - } - case 'm': - case 'M': - // Handle multi-byte character encoding (MIF) - if (this.scanner.peek() === '+') { - this.scanner.consume(1); // Consume the '+' - const hexCode = this.extractMifCode(this.mifCodeLength); - if (hexCode) { - this.scanner.consume(hexCode.length); - const decodedChar = this.mifDecoder(hexCode); - if (word) { - return [wordToken, word]; - } - return [wordToken, decodedChar]; - } - // If no valid hex code found, rewind the '+' character - this.scanner.consume(-1); - } - // If not a valid multi-byte code, treat as literal text - word += '\\M'; - continue; - case 'U': - // Handle Unicode escape: \U+XXXX or \U+XXXXXXXX - if (this.scanner.peek() === '+') { - this.scanner.consume(1); // Consume the '+' - const hexMatch = this.scanner.tail.match(/^[0-9A-Fa-f]{4,8}/); - if (hexMatch) { - const hexCode = hexMatch[0]; - this.scanner.consume(hexCode.length); - const codePoint = parseInt(hexCode, 16); - let decodedChar = ''; - try { - decodedChar = String.fromCodePoint(codePoint); - } catch { - decodedChar = '▯'; - } - if (word) { - return [wordToken, word]; - } - return [wordToken, decodedChar]; - } - // If no valid hex code found, rewind the '+' character - this.scanner.consume(-1); - } - // If not a valid Unicode code, treat as literal text - word += '\\U'; - continue; - default: - if (cmd) { - try { - const propertyChanges = this.parseProperties(cmd); - if (this.yieldPropertyCommands && propertyChanges) { - return [TokenType.PROPERTIES_CHANGED, propertyChanges]; - } - // After processing a property command, continue with normal parsing - continue; - } catch { - const commandText = this.scanner.tail.slice( - cmdStartIndex, - this.scanner.currentIndex - ); - word += commandText; - } - } - } - continue; - } - } - - if (letter === '%' && this.scanner.peek(1) === '%') { - const code = this.scanner.peek(2).toLowerCase(); - const specialChar = SPECIAL_CHAR_ENCODING[code]; - if (specialChar) { - this.scanner.consume(3); - word += specialChar; - continue; - } else { - /** - * Supports Control Codes: `%%ddd`, where ddd is a three-digit decimal number representing the ASCII code value of the character. - * - * Reference: https://help.autodesk.com/view/ACD/2026/ENU/?guid=GUID-968CBC1D-BA99-4519-ABDD-88419EB2BF92 - */ - const digits = [code, this.scanner.peek(3), this.scanner.peek(4)]; - - if (digits.every(d => d >= '0' && d <= '9')) { - const charCode = Number.parseInt(digits.join(''), 10); - this.scanner.consume(5); - word += String.fromCharCode(charCode); - } else { - // Skip invalid special character codes - this.scanner.consume(3); - } - - continue; - } - } - - if (letter === ' ') { - if (word) { - this.scanner.consume(1); - followupToken = spaceToken; - return [wordToken, word]; - } - this.scanner.consume(1); - return [spaceToken, null]; - } - - if (!escape) { - if (letter === '{') { - if (word) { - return [wordToken, word]; - } - this.scanner.consume(1); - this.pushCtx(); - continue; - } else if (letter === '}') { - if (word) { - return [wordToken, word]; - } - this.scanner.consume(1); - // Context restoration with yieldPropertyCommands - if (this.yieldPropertyCommands) { - const prevCtx = this.ctxStack.current; - this.popCtx(); - const changes = this.getPropertyChanges(prevCtx, this.ctxStack.current); - if (Object.keys(changes).length > 0) { - return [ - TokenType.PROPERTIES_CHANGED, - { command: undefined, changes, depth: this.ctxStack.depth }, - ]; - } - } else { - this.popCtx(); - } - continue; - } - } - - // Handle caret-encoded characters only when not in stack context - if (!this.inStackContext && letter === '^') { - const nextChar = this.scanner.peek(1); - if (nextChar) { - const code = nextChar.charCodeAt(0); - this.scanner.consume(2); // Consume both ^ and the next character - if (code === 32) { - // Space - word += '^'; - } else if (code === 73) { - // Tab - if (word) { - return [wordToken, word]; - } - return [TokenType.TABULATOR, null]; - } else if (code === 74) { - // Line feed - if (word) { - return [wordToken, word]; - } - return [TokenType.NEW_PARAGRAPH, null]; - } else if (code === 77) { - // Carriage return - // Ignore carriage return - continue; - } else { - word += '▯'; - } - continue; - } - } - - this.scanner.consume(1); - if (letter.charCodeAt(0) >= 32) { - word += letter; - } - } - - if (word) { - return [wordToken, word]; - } - return [TokenType.NONE, null]; - }; - - while (true) { - const [type, data] = nextToken.call(this); - if (type) { - yield new MTextToken(type, this.ctxStack.current.copy(), data); - if (type === TokenType.NEW_PARAGRAPH && this.resetParagraphParameters) { - // Reset paragraph properties and emit PROPERTIES_CHANGED if needed - const ctx = this.ctxStack.current; - const changed = resetParagraph(ctx); - if (this.yieldPropertyCommands && Object.keys(changed).length > 0) { - yield new MTextToken(TokenType.PROPERTIES_CHANGED, ctx.copy(), { - command: undefined, - changes: { paragraph: changed }, - depth: this.ctxStack.depth, - }); - } - } - if (followupToken) { - yield new MTextToken(followupToken, this.ctxStack.current.copy(), null); - followupToken = null; - } - } else { - break; - } - } - } -} - -/** - * Text scanner for parsing MText content - */ -export class TextScanner { - private text: string; - private textLen: number; - private _index: number; - - /** - * Create a new text scanner - * @param text - The text to scan - */ - constructor(text: string) { - this.text = text; - this.textLen = text.length; - this._index = 0; - } - - /** - * Get the current index in the text - */ - get currentIndex(): number { - return this._index; - } - - /** - * Check if the scanner has reached the end of the text - */ - get isEmpty(): boolean { - return this._index >= this.textLen; - } - - /** - * Check if there is more text to scan - */ - get hasData(): boolean { - return this._index < this.textLen; - } - - /** - * Get the next character and advance the index - * @returns The next character, or empty string if at end - */ - get(): string { - if (this.isEmpty) { - return ''; - } - const char = this.text[this._index]; - this._index++; - return char; - } - - /** - * Advance the index by the specified count - * @param count - Number of characters to advance - */ - consume(count: number = 1): void { - this._index = Math.max(0, Math.min(this._index + count, this.textLen)); - } - - /** - * Look at a character without advancing the index - * @param offset - Offset from current position - * @returns The character at the offset position, or empty string if out of bounds - */ - peek(offset: number = 0): string { - const index = this._index + offset; - if (index >= this.textLen || index < 0) { - return ''; - } - return this.text[index]; - } - - /** - * Find the next occurrence of a character - * @param char - The character to find - * @param escape - Whether to handle escaped characters - * @returns Index of the character, or -1 if not found - */ - find(char: string, escape: boolean = false): number { - let index = this._index; - while (index < this.textLen) { - if (escape && this.text[index] === '\\') { - if (index + 1 < this.textLen) { - if (this.text[index + 1] === char) { - return index + 1; - } - index += 2; - continue; - } - index++; - continue; - } - if (this.text[index] === char) { - return index; - } - index++; - } - return -1; - } - - /** - * Get the remaining text from the current position - */ - get tail(): string { - return this.text.slice(this._index); - } - - /** - * Check if the next character is a space - */ - isNextSpace(): boolean { - return this.peek() === ' '; - } - - /** - * Consume spaces until a non-space character is found - * @returns Number of spaces consumed - */ - consumeSpaces(): number { - let count = 0; - while (this.isNextSpace()) { - this.consume(); - count++; - } - return count; - } -} - -/** - * Class to handle ACI and RGB color logic for MText. - * - * This class encapsulates color state for MText, supporting both AutoCAD Color Index (ACI) and RGB color. - * Only one color mode is active at a time: setting an RGB color disables ACI, and vice versa. - * RGB is stored as a single 24-bit integer (0xRRGGBB) for efficient comparison and serialization. - * - * Example usage: - * ```ts - * const color1 = new MTextColor(1); // ACI color - * const color2 = new MTextColor([255, 0, 0]); // RGB color - * const color3 = new MTextColor(); // Default (ACI=256, "by layer") - * ``` - */ -export class MTextColor { - /** - * The AutoCAD Color Index (ACI) value. Only used if no RGB color is set. - * @default 256 ("by layer") - */ - private _aci: number | null = 256; - /** - * The RGB color value as a single 24-bit integer (0xRRGGBB), or null if not set. - * @default null - */ - private _rgbValue: number | null = null; // Store as 0xRRGGBB or null - - /** - * Create a new MTextColor instance. - * @param color The initial color: number for ACI, [r,g,b] for RGB, or null/undefined for default (ACI=256). - */ - constructor(color?: number | RGB | null) { - if (Array.isArray(color)) { - this.rgb = color; - } else if (typeof color === 'number') { - this.aci = color; - } else { - this.aci = 256; - } - } - - /** - * Get the current ACI color value. - * @returns The ACI color (0-256), or null if using RGB. - */ - get aci(): number | null { - return this._aci; - } - - /** - * Set the ACI color value. Setting this disables any RGB color. - * @param value The ACI color (0-256), or null to unset. - * @throws Error if value is out of range. - */ - set aci(value: number | null) { - if (value === null) { - this._aci = null; - } else if (value >= 0 && value <= 256) { - this._aci = value; - this._rgbValue = null; - } else { - throw new Error('ACI not in range [0, 256]'); - } - } - - /** - * Get the current RGB color as a tuple [r, g, b], or null if not set. - * @returns The RGB color tuple, or null if using ACI. - */ - get rgb(): RGB | null { - if (this._rgbValue === null) return null; - // Extract R, G, B from 0xRRGGBB - const r = (this._rgbValue >> 16) & 0xff; - const g = (this._rgbValue >> 8) & 0xff; - const b = this._rgbValue & 0xff; - return [r, g, b]; - } - - /** - * Set the RGB color. Setting this disables ACI color. - * @param value The RGB color tuple [r, g, b], or null to use ACI. - */ - set rgb(value: RGB | null) { - if (value) { - const [r, g, b] = value; - this._rgbValue = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); - this._aci = null; - } else { - this._rgbValue = null; - } - } - - /** - * Returns true if the color is set by RGB, false if by ACI. - */ - get isRgb(): boolean { - return this._rgbValue !== null; - } - - /** - * Returns true if the color is set by ACI, false if by RGB. - */ - get isAci(): boolean { - return this._rgbValue === null && this._aci !== null; - } - - /** - * Get or set the internal RGB value as a number (0xRRGGBB), or null if not set. - * Setting this will switch to RGB mode and set ACI to null. - */ - get rgbValue(): number | null { - return this._rgbValue; - } - - set rgbValue(val: number | null) { - if (val === null) { - this._rgbValue = null; - } else { - this._rgbValue = val & 0xffffff; - this._aci = null; - } - } - - /** - * Returns a deep copy of this color. - * @returns A new MTextColor instance with the same color state. - */ - copy(): MTextColor { - const c = new MTextColor(); - c._aci = this._aci; - c._rgbValue = this._rgbValue; - return c; - } - - /** - * Returns a plain object for serialization. - * @returns An object with aci, rgb (tuple), and rgbValue (number or null). - */ - toObject(): { aci: number | null; rgb: RGB | null; rgbValue: number | null } { - return { aci: this._aci, rgb: this.rgb, rgbValue: this._rgbValue }; - } - - /** - * Convert the current color to a CSS hex color string (#rrggbb). - * Returns null if the color is ACI-based and has no RGB value. - */ - toCssColor(): string | null { - if (this._rgbValue !== null) { - return colorNumberToHex(this._rgbValue); - } - return null; - } - - /** - * Create an MTextColor from a CSS color string. - * Supports #rgb, #rrggbb, rgb(...), rgba(...). Returns null if invalid or transparent. - */ - static fromCssColor(value: string | null | undefined): MTextColor | null { - const rgbValue = cssColorToRgbValue(value); - if (rgbValue === null) return null; - const color = new MTextColor(); - color.rgbValue = rgbValue; - return color; - } - - /** - * Equality check for color. - * @param other The other MTextColor to compare. - * @returns True if both ACI and RGB values are equal. - */ - equals(other: MTextColor): boolean { - return this._aci === other._aci && this._rgbValue === other._rgbValue; - } -} - -/** - * MText context class for managing text formatting state - */ -export class MTextContext { - private _stroke: number = 0; - /** Whether to continue stroke formatting */ - continueStroke: boolean = false; - /** Color (ACI or RGB) */ - color: MTextColor = new MTextColor(); - /** Line alignment */ - align: MTextLineAlignment = MTextLineAlignment.BOTTOM; - /** Font face properties */ - fontFace: FontFace = { family: '', style: 'Regular', weight: 400 }; - /** Capital letter height */ - private _capHeight: FactorValue = { value: 1.0, isRelative: false }; - /** Character width factor */ - private _widthFactor: FactorValue = { value: 1.0, isRelative: false }; - /** - * Character tracking factor a multiplier applied to the default spacing between characters in the MText object. - * - Value = 1.0 → Normal spacing. - * - Value < 1.0 → Characters are closer together. - * - Value > 1.0 → Characters are spaced farther apart. - */ - private _charTrackingFactor: FactorValue = { value: 1.0, isRelative: false }; - /** Oblique angle */ - oblique: number = 0.0; - /** Paragraph properties */ - paragraph: ParagraphProperties = { - indent: 0, - left: 0, - right: 0, - align: MTextParagraphAlignment.DEFAULT, - tabs: [], - }; - - /** - * Get the capital letter height - */ - get capHeight(): FactorValue { - return this._capHeight; - } - - /** - * Set the capital letter height - * @param value - Height value - */ - set capHeight(value: FactorValue) { - this._capHeight = { - value: Math.abs(value.value), - isRelative: value.isRelative, - }; - } - - /** - * Get the character width factor - */ - get widthFactor(): FactorValue { - return this._widthFactor; - } - - /** - * Set the character width factor - * @param value - Width factor value - */ - set widthFactor(value: FactorValue) { - this._widthFactor = { - value: Math.abs(value.value), - isRelative: value.isRelative, - }; - } - - /** - * Get the character tracking factor - */ - get charTrackingFactor(): FactorValue { - return this._charTrackingFactor; - } - - /** - * Set the character tracking factor - * @param value - Tracking factor value - */ - set charTrackingFactor(value: FactorValue) { - this._charTrackingFactor = { - value: Math.abs(value.value), - isRelative: value.isRelative, - }; - } - - /** - * Get the ACI color value - */ - get aci(): number | null { - return this.color.aci; - } - - /** - * Set the ACI color value - * @param value - ACI color value (0-256) - * @throws Error if value is out of range - */ - set aci(value: number) { - this.color.aci = value; - } - - /** - * Get the RGB color value - */ - get rgb(): RGB | null { - return this.color.rgb; - } - - /** - * Set the RGB color value - */ - set rgb(value: RGB | null) { - this.color.rgb = value; - } - - /** - * Gets whether the current text should be rendered in italic style. - * @returns {boolean} True if the font style is 'Italic', otherwise false. - */ - get italic(): boolean { - return this.fontFace.style === 'Italic'; - } - /** - * Sets whether the current text should be rendered in italic style. - * @param value - If true, sets the font style to 'Italic'; if false, sets it to 'Regular'. - */ - set italic(value: boolean) { - this.fontFace.style = value ? 'Italic' : 'Regular'; - } - - /** - * Gets whether the current text should be rendered in bold style. - * This is primarily used for mesh fonts and affects font selection. - * @returns {boolean} True if the font weight is 700 or higher, otherwise false. - */ - get bold(): boolean { - return (this.fontFace.weight || 400) >= 700; - } - /** - * Sets whether the current text should be rendered in bold style. - * This is primarily used for mesh fonts and affects font selection. - * @param value - If true, sets the font weight to 700; if false, sets it to 400. - */ - set bold(value: boolean) { - this.fontFace.weight = value ? 700 : 400; - } - - /** - * Get whether text is underlined - */ - get underline(): boolean { - return Boolean(this._stroke & MTextStroke.UNDERLINE); - } - - /** - * Set whether text is underlined - * @param value - Whether to underline - */ - set underline(value: boolean) { - this._setStrokeState(MTextStroke.UNDERLINE, value); - } - - /** - * Get whether text has strike-through - */ - get strikeThrough(): boolean { - return Boolean(this._stroke & MTextStroke.STRIKE_THROUGH); - } - - /** - * Set whether text has strike-through - * @param value - Whether to strike through - */ - set strikeThrough(value: boolean) { - this._setStrokeState(MTextStroke.STRIKE_THROUGH, value); - } - - /** - * Get whether text has overline - */ - get overline(): boolean { - return Boolean(this._stroke & MTextStroke.OVERLINE); - } - - /** - * Set whether text has overline - * @param value - Whether to overline - */ - set overline(value: boolean) { - this._setStrokeState(MTextStroke.OVERLINE, value); - } - - /** - * Check if any stroke formatting is active - */ - get hasAnyStroke(): boolean { - return Boolean(this._stroke); - } - - /** - * Set the state of a stroke type - * @param stroke - The stroke type to set - * @param state - Whether to enable or disable the stroke - */ - private _setStrokeState(stroke: MTextStroke, state: boolean = true): void { - if (state) { - this._stroke |= stroke; - } else { - this._stroke &= ~stroke; - } - } - - /** - * Create a copy of this context - * @returns A new context with the same properties - */ - copy(): MTextContext { - const ctx = new MTextContext(); - ctx._stroke = this._stroke; - ctx.continueStroke = this.continueStroke; - ctx.color = this.color.copy(); - ctx.align = this.align; - ctx.fontFace = { ...this.fontFace }; - ctx._capHeight = { ...this._capHeight }; - ctx._widthFactor = { ...this._widthFactor }; - ctx._charTrackingFactor = { ...this._charTrackingFactor }; - ctx.oblique = this.oblique; - ctx.paragraph = { ...this.paragraph }; - return ctx; - } -} - -/** - * Token class for MText parsing - */ -export class MTextToken { - /** - * Create a new MText token - * @param type - The token type - * @param ctx - The text context at this token - * @param data - Optional token data - */ - constructor( - public type: TokenType, - public ctx: MTextContext, - public data: TokenData[TokenType] - ) {} -} +/** + * Token types used in MText parsing + */ +export enum TokenType { + /** No token */ + NONE = 0, + /** Word token with string data */ + WORD = 1, + /** Stack token with [numerator, denominator, type] data */ + STACK = 2, + /** Space token with no data */ + SPACE = 3, + /** Non-breaking space token with no data */ + NBSP = 4, + /** Tab token with no data */ + TABULATOR = 5, + /** New paragraph token with no data */ + NEW_PARAGRAPH = 6, + /** New column token with no data */ + NEW_COLUMN = 7, + /** Wrap at dimension line token with no data */ + WRAP_AT_DIMLINE = 8, + /** Properties changed token with string data (full command) */ + PROPERTIES_CHANGED = 9, + /** AutoCAD percent-sign symbol code (`%%c`, `%%d`, `%%p`, `%%ddd`, `%%%`) */ + PERCENT_SYMBOL = 10, +} + +/** + * AutoCAD percent-sign symbol emitted when {@link MTextParserOptions.yieldPercentSymbols} + * is enabled. + */ +export type PercentSymbolData = + | { + kind: 'named'; + /** Percent code letter: `%%c`, `%%d`, or `%%p`. */ + code: 'c' | 'd' | 'p'; + /** Unicode expansion used for display (e.g. `%%c` → `Ø`). */ + char: string; + } + | { + kind: 'numeric'; + /** Decimal code from `%%ddd` (0–255). */ + charCode: number; + /** `String.fromCharCode(charCode)`. */ + char: string; + } + | { + kind: 'literal'; + /** Literal percent from `%%%`. */ + char: '%'; + }; + +/** + * Represents a factor value that can be either absolute or relative. + * Used for properties like height, width, and character tracking in MText formatting. + */ +export interface FactorValue { + /** The numeric value of the factor */ + value: number; + /** Whether the value is relative (true) or absolute (false) */ + isRelative: boolean; +} + +/** + * Format properties of MText word tokens. + * This interface defines all the formatting properties that can be applied to MText content, + * including text styling, colors, alignment, font properties, and paragraph formatting. + */ +export interface Properties { + /** Whether text is underlined */ + underline?: boolean; + /** Whether text has an overline */ + overline?: boolean; + /** Whether text has strike-through */ + strikeThrough?: boolean; + /** AutoCAD Color Index (ACI) color value (0-256), or null if not set */ + aci?: number | null; + /** RGB color tuple [r, g, b], or null if not set */ + rgb?: RGB | null; + /** Line alignment for the text */ + align?: MTextLineAlignment; + /** Font face properties including family, style, and weight */ + fontFace?: FontFace; + /** Capital letter height factor (can be relative or absolute) */ + capHeight?: FactorValue; + /** Character width factor (can be relative or absolute) */ + widthFactor?: FactorValue; + /** Character tracking factor for spacing between characters (can be relative or absolute) */ + charTrackingFactor?: FactorValue; + /** Oblique angle in degrees for text slant */ + oblique?: number; + /** Paragraph formatting properties (partial to allow selective updates) */ + paragraph?: Partial; +} + +/** + * Represents a change in MText properties, including the command, the changed properties, and the context depth. + */ +export interface ChangedProperties { + /** + * The property command that triggered the change (e.g., 'L', 'C', 'f'). + * The command will be undefined if it is to restore context. + */ + command: string | undefined; + /** + * The set of properties that have changed as a result of the command. + */ + changes: Properties; + /** + * The current context stack depth when the property change occurs. + * - 0: The change is global (applies outside of any `{}` block). + * - >0: The change is local (applies within one or more nested `{}` blocks). + */ + depth: number; // 0 = global, >0 = local +} + +/** + * Type for token data based on token type + */ +export type TokenData = { + [TokenType.NONE]: null; + [TokenType.WORD]: string; + [TokenType.STACK]: [string, string, string]; + [TokenType.SPACE]: null; + [TokenType.NBSP]: null; + [TokenType.TABULATOR]: null; + [TokenType.NEW_PARAGRAPH]: null; + [TokenType.NEW_COLUMN]: null; + [TokenType.WRAP_AT_DIMLINE]: null; + [TokenType.PROPERTIES_CHANGED]: ChangedProperties; + [TokenType.PERCENT_SYMBOL]: PercentSymbolData; +}; + +/** + * Line alignment options for MText + */ +export enum MTextLineAlignment { + /** Align text to bottom */ + BOTTOM = 0, + /** Align text to middle */ + MIDDLE = 1, + /** Align text to top */ + TOP = 2, +} + +/** + * Paragraph alignment options for MText + */ +export enum MTextParagraphAlignment { + /** Default alignment */ + DEFAULT = 0, + /** Left alignment */ + LEFT = 1, + /** Right alignment */ + RIGHT = 2, + /** Center alignment */ + CENTER = 3, + /** Justified alignment */ + JUSTIFIED = 4, + /** Distributed alignment */ + DISTRIBUTED = 5, +} + +/** + * Text stroke options for MText + */ +export enum MTextStroke { + /** No stroke */ + NONE = 0, + /** Underline stroke */ + UNDERLINE = 1, + /** Overline stroke */ + OVERLINE = 2, + /** Strike-through stroke */ + STRIKE_THROUGH = 4, +} + +/** + * RGB color tuple + */ +export type RGB = [number, number, number]; + +/** + * Font style type + */ +export type FontStyle = 'Regular' | 'Italic'; + +/** + * Font face properties + */ +export interface FontFace { + /** Font family name */ + family: string; + /** Font style (e.g., 'Regular', 'Italic') */ + style: FontStyle; + /** Font weight (e.g., 400 for normal, 700 for bold) */ + weight: number; +} + +/** + * Paragraph properties + */ +export interface ParagraphProperties { + /** Indentation value */ + indent: number; + /** Left margin value */ + left: number; + /** Right margin value */ + right: number; + /** Paragraph alignment */ + align: MTextParagraphAlignment; + /** Tab stop positions and types */ + tabs: (number | string)[]; +} + +/** + * Special character encoding mapping + */ +const SPECIAL_CHAR_ENCODING: Record = { + c: 'Ø', + d: '°', + p: '±', + '%': '%', +}; + +/** + * Character to paragraph alignment mapping + */ +const CHAR_TO_ALIGN: Record = { + l: MTextParagraphAlignment.LEFT, + r: MTextParagraphAlignment.RIGHT, + c: MTextParagraphAlignment.CENTER, + j: MTextParagraphAlignment.JUSTIFIED, + d: MTextParagraphAlignment.DISTRIBUTED, +}; + +/** + * Convert RGB tuple to integer color value + * @param rgb - RGB color tuple + * @returns Integer color value + */ +export function rgb2int(rgb: RGB): number { + const [r, g, b] = rgb; + return (r << 16) | (g << 8) | b; +} + +/** + * Convert integer color value to RGB tuple + * @param value - Integer color value + * @returns RGB color tuple + */ +export function int2rgb(value: number): RGB { + const r = (value >> 16) & 0xff; + const g = (value >> 8) & 0xff; + const b = value & 0xff; + return [r, g, b]; +} + +function clampColorChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function normalizeColorNumber(color: number): number { + return Math.max(0, Math.min(0xffffff, Math.round(color))); +} + +function colorNumberToHex(color: number | null): string | null { + if (color === null) return null; + return `#${normalizeColorNumber(color).toString(16).padStart(6, '0')}`; +} + +function normalizeHexColor(value: string | null | undefined): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (/^#[0-9a-f]{6}$/.test(normalized)) return normalized; + if (/^[0-9a-f]{6}$/.test(normalized)) return `#${normalized}`; + if (/^#[0-9a-f]{3}$/.test(normalized)) { + const r = normalized[1]; + const g = normalized[2]; + const b = normalized[3]; + return `#${r}${r}${g}${g}${b}${b}`; + } + if (/^[0-9a-f]{3}$/.test(normalized)) { + const r = normalized[0]; + const g = normalized[1]; + const b = normalized[2]; + return `#${r}${r}${g}${g}${b}${b}`; + } + return null; +} + +function cssColorToRgbValue(value: string | null | undefined): number | null { + if (!value) return null; + const raw = value.trim().toLowerCase(); + if (raw === 'transparent') return null; + + const hex = normalizeHexColor(raw); + if (hex) { + return normalizeColorNumber(Number.parseInt(hex.slice(1), 16)); + } + + const fnMatch = raw.match(/^rgba?\((.*)\)$/); + if (!fnMatch) return null; + + const parts = fnMatch[1] + .replace(/\s*\/\s*/g, ' ') + .split(/[,\s]+/) + .map(p => p.trim()) + .filter(Boolean); + + if (parts.length < 3) return null; + + const toChannel = (token: string): number => { + if (token.endsWith('%')) { + const percent = Number.parseFloat(token.slice(0, -1)); + return clampColorChannel((percent / 100) * 255); + } + const num = Number.parseFloat(token); + return clampColorChannel(num); + }; + + const r = toChannel(parts[0]); + const g = toChannel(parts[1]); + const b = toChannel(parts[2]); + return rgb2int([r, g, b]); +} + +/** + * Escape DXF line endings + * @param text - Text to escape + * @returns Escaped text + */ +export function escapeDxfLineEndings(text: string): string { + return text.replace(/\r\n|\r|\n/g, '\\P'); +} + +/** + * Check if text contains inline formatting codes + * @param text - Text to check + * @returns True if text contains formatting codes + */ +export function hasInlineFormattingCodes(text: string): boolean { + return text.replace(/\\P/g, '').replace(/\\~/g, '').includes('\\'); +} + +/** + * Extracts all unique font names used in an MText string. + * This function searches for font commands in the format \f{fontname}| or \f{fontname}; and returns a set of unique font names. + * Font names are converted to lowercase to ensure case-insensitive uniqueness. + * + * @param mtext - The MText string to analyze for font names + * @param removeExtension - Whether to remove font file extensions (e.g., .ttf, .shx) from font names. Defaults to false. + * @returns A Set containing all unique font names found in the MText string, converted to lowercase + * @example + * ```ts + * const mtext = "\\fArial.ttf|Hello\\fTimes New Roman.otf|World"; + * const fonts = getFonts(mtext, true); + * // Returns: Set(2) { "arial", "times new roman" } + * ``` + */ +export function getFonts(mtext: string, removeExtension: boolean = false) { + const fonts: Set = new Set(); + const regex = /\\[fF](.*?)[;|]/g; + + [...mtext.matchAll(regex)].forEach(match => { + let fontName = match[1].toLowerCase(); + if (removeExtension) { + fontName = fontName.replace(/\.(ttf|otf|woff|shx)$/, ''); + } + fonts.add(fontName); + }); + + return fonts; +} + +/** + * ContextStack manages a stack of MTextContext objects for character-level formatting. + * + * - Character-level formatting (underline, color, font, etc.) is scoped to `{}` blocks and managed by the stack. + * - Paragraph-level formatting (\p) is not scoped, but when a block ends, any paragraph property changes are merged into the parent context. + * - On pop, paragraph properties from the popped context are always merged into the new top context. + */ +class ContextStack { + private stack: MTextContext[] = []; + + /** + * Creates a new ContextStack with an initial context. + * @param initial The initial MTextContext to use as the base of the stack. + */ + constructor(initial: MTextContext) { + this.stack.push(initial); + } + + /** + * Pushes a copy of the given context onto the stack. + * @param ctx The MTextContext to push (copied). + */ + push(ctx: MTextContext) { + this.stack.push(ctx); + } + + /** + * Pops the top context from the stack and merges its paragraph properties into the new top context. + * If only one context remains, nothing is popped. + * @returns The popped MTextContext, or undefined if the stack has only one context. + */ + pop(): MTextContext | undefined { + if (this.stack.length <= 1) return undefined; + const popped = this.stack.pop()!; + // Merge paragraph properties into the new top context + const top = this.stack[this.stack.length - 1]; + if (JSON.stringify(top.paragraph) !== JSON.stringify(popped.paragraph)) { + top.paragraph = { ...popped.paragraph }; + } + return popped; + } + + /** + * Returns the current (top) context on the stack. + */ + get current(): MTextContext { + return this.stack[this.stack.length - 1]; + } + + /** + * Returns the current stack depth (number of nested blocks), not counting the root context. + */ + get depth(): number { + return this.stack.length - 1; + } + + /** + * Returns the root (bottom) context, which represents the global formatting state. + * Used for paragraph property application. + */ + get root(): MTextContext { + return this.stack[0]; + } + + /** + * Replaces the current (top) context with the given context. + * @param ctx The new context to set as the current context. + */ + setCurrent(ctx: MTextContext) { + this.stack[this.stack.length - 1] = ctx; + } +} + +/** + * Configuration options for the MText parser. + * These options control how the parser behaves during tokenization and property handling. + */ +export interface MTextParserOptions { + /** + * Whether to yield PROPERTIES_CHANGED tokens when formatting properties change. + * When true, the parser will emit tokens whenever properties like color, font, or alignment change. + * When false, property changes are applied silently to the context without generating tokens. + * @default false + */ + yieldPropertyCommands?: boolean; + /** + * Whether to reset paragraph parameters when encountering a new paragraph token. + * When true, paragraph properties (indent, margins, alignment, tab stops) are reset to defaults + * at the start of each new paragraph. + * @default false + */ + resetParagraphParameters?: boolean; + /** + * Whether to emit {@link TokenType.PERCENT_SYMBOL} tokens for AutoCAD `%%` symbol + * codes instead of expanding them into {@link TokenType.WORD} characters. + * @default false + */ + yieldPercentSymbols?: boolean; + /** + * Custom decoder function for MIF (Multibyte Interchange Format) codes. + * If provided, this function will be used instead of the default decodeMultiByteChar. + * The function receives the hex code string and should return the decoded character. + * @param hex - Hex code string (e.g., "C4E3" or "1A2B3") + * @returns Decoded character or empty square (▯) if invalid + * @default undefined (uses default decoder) + */ + mifDecoder?: (hex: string) => string; + /** + * The length of MIF hex codes to parse. MIF codes in AutoCAD can vary in length + * depending on the specific SHX big font used (typically 4 or 5 digits). + * If not specified, the parser will try to auto-detect the length by attempting + * to match 4 digits first, then 5 digits if needed. + * @default undefined (auto-detect) + */ + mifCodeLength?: 4 | 5 | 'auto'; +} + +/** + * Main parser class for MText content + */ +export class MTextParser { + private scanner: TextScanner; + private ctxStack: ContextStack; + private continueStroke: boolean = false; + private yieldPropertyCommands: boolean; + private resetParagraphParameters: boolean; + private yieldPercentSymbols: boolean; + private inStackContext: boolean = false; + private mifDecoder: (hex: string) => string; + private mifCodeLength: 4 | 5 | 'auto'; + + /** + * Creates a new MTextParser instance + * @param content - The MText content to parse + * @param ctx - Optional initial MText context + * @param options - Parser options + */ + constructor(content: string, ctx?: MTextContext, options: MTextParserOptions = {}) { + this.scanner = new TextScanner(content); + const initialCtx = ctx ?? new MTextContext(); + this.ctxStack = new ContextStack(initialCtx); + this.yieldPropertyCommands = options.yieldPropertyCommands ?? false; + this.resetParagraphParameters = options.resetParagraphParameters ?? false; + this.yieldPercentSymbols = options.yieldPercentSymbols ?? false; + this.mifDecoder = options.mifDecoder ?? this.decodeMultiByteChar.bind(this); + this.mifCodeLength = options.mifCodeLength ?? 'auto'; + } + + /** + * Decode multi-byte character from hex code + * @param hex - Hex code string (e.g. "C4E3" or "1A2B3") + * @returns Decoded character or empty square if invalid + */ + private decodeMultiByteChar(hex: string): string { + try { + // For 5-digit codes, return placeholder directly + if (hex.length === 5) { + const prefix = hex[0]; + + // Notes: + // I know AutoCAD uses prefix 1 for Shift-JIS, 2 for big5, and 5 for gbk. + // But I don't know whether there are other prefixes and their meanings. + let encoding = 'gbk'; + if (prefix === '1') { + encoding = 'shift-jis'; + } else if (prefix === '2') { + encoding = 'big5'; + } + const bytes = new Uint8Array([ + parseInt(hex.substr(1, 2), 16), + parseInt(hex.substr(3, 2), 16), + ]); + const decoder = new TextDecoder(encoding); + const result = decoder.decode(bytes); + return result; + } else if (hex.length === 4) { + // For 4-digit hex codes, decode as 2-byte character + const bytes = new Uint8Array([ + parseInt(hex.substr(0, 2), 16), + parseInt(hex.substr(2, 2), 16), + ]); + + // Try GBK first + const gbkDecoder = new TextDecoder('gbk'); + const gbkResult = gbkDecoder.decode(bytes); + if (gbkResult !== '▯') { + return gbkResult; + } + + // Try BIG5 if GBK fails + const big5Decoder = new TextDecoder('big5'); + const big5Result = big5Decoder.decode(bytes); + if (big5Result !== '▯') { + return big5Result; + } + } + + return '▯'; + } catch { + return '▯'; + } + } + + /** + * Extract MIF hex code from scanner + * @param length - The length of the hex code to extract (4 or 5), or 'auto' to detect + * @returns The extracted hex code, or null if not found + */ + private extractMifCode(length: 4 | 5 | 'auto'): string | null { + if (length === 'auto') { + // Try 5 digits first if available, then fall back to 4 digits + const code5 = this.scanner.tail.match(/^[0-9A-Fa-f]{5}/)?.[0]; + if (code5) { + return code5; + } + const code4 = this.scanner.tail.match(/^[0-9A-Fa-f]{4}/)?.[0]; + if (code4) { + return code4; + } + return null; + } else { + const code = this.scanner.tail.match(new RegExp(`^[0-9A-Fa-f]{${length}}`))?.[0]; + return code ?? null; + } + } + + /** + * Push current context onto the stack + */ + private pushCtx(): void { + this.ctxStack.push(this.ctxStack.current); + } + + /** + * Pop context from the stack + */ + private popCtx(): void { + this.ctxStack.pop(); + } + + /** + * Parse stacking expression (numerator/denominator) + * @returns Tuple of [TokenType.STACK, [numerator, denominator, type]] + */ + private parseStacking(): [TokenType, [string, string, string]] { + const scanner = new TextScanner(this.extractExpression(true)); + let numerator = ''; + let denominator = ''; + let stackingType = ''; + + const getNextChar = (): [string, boolean] => { + let c = scanner.peek(); + let escape = false; + if (c.charCodeAt(0) < 32) { + c = ' '; + } + if (c === '\\') { + escape = true; + scanner.consume(1); + c = scanner.peek(); + } + scanner.consume(1); + return [c, escape]; + }; + + const parseNumerator = (): [string, string] => { + let word = ''; + while (scanner.hasData) { + const [c, escape] = getNextChar(); + // Check for stacking operators first + if (!escape && (c === '/' || c === '#' || c === '^')) { + return [word, c]; + } + word += c; + } + return [word, '']; + }; + + const parseDenominator = (skipLeadingSpace: boolean): string => { + let word = ''; + let skipping = skipLeadingSpace; + while (scanner.hasData) { + const [c, escape] = getNextChar(); + if (skipping && c === ' ') { + continue; + } + skipping = false; + // Stop at terminator unless escaped + if (!escape && c === ';') { + break; + } + word += c; + } + return word; + }; + + [numerator, stackingType] = parseNumerator(); + if (stackingType) { + // Only skip leading space for caret divider + denominator = parseDenominator(stackingType === '^'); + } + + // Special case for \S^!/^?; + if (numerator === '' && denominator.includes('I/')) { + return [TokenType.STACK, [' ', ' ', '/']]; + } + + // Handle caret as a stacking operator + if (stackingType === '^') { + return [TokenType.STACK, [numerator, denominator, '^']]; + } + + return [TokenType.STACK, [numerator, denominator, stackingType]]; + } + + /** + * Parse MText properties + * @param cmd - The property command to parse + * @returns Property changes if yieldPropertyCommands is true and changes occurred + */ + private parseProperties(cmd: string): TokenData[TokenType.PROPERTIES_CHANGED] | void { + const prevCtx = this.ctxStack.current.copy(); + const newCtx = this.ctxStack.current.copy(); + switch (cmd) { + case 'L': + newCtx.underline = true; + this.continueStroke = true; + break; + case 'l': + newCtx.underline = false; + if (!newCtx.hasAnyStroke) { + this.continueStroke = false; + } + break; + case 'O': + newCtx.overline = true; + this.continueStroke = true; + break; + case 'o': + newCtx.overline = false; + if (!newCtx.hasAnyStroke) { + this.continueStroke = false; + } + break; + case 'K': + newCtx.strikeThrough = true; + this.continueStroke = true; + break; + case 'k': + newCtx.strikeThrough = false; + if (!newCtx.hasAnyStroke) { + this.continueStroke = false; + } + break; + case 'A': + this.parseAlign(newCtx); + break; + case 'C': + this.parseAciColor(newCtx); + break; + case 'c': + this.parseRgbColor(newCtx); + break; + case 'H': + this.parseHeight(newCtx); + break; + case 'W': + this.parseWidth(newCtx); + break; + case 'Q': + this.parseOblique(newCtx); + break; + case 'T': + this.parseCharTracking(newCtx); + break; + case 'p': + this.parseParagraphProperties(newCtx); + break; + case 'f': + case 'F': + this.parseFontProperties(newCtx); + break; + default: + throw new Error(`Unknown command: ${cmd}`); + } + + // Update continueStroke based on current stroke state + this.continueStroke = newCtx.hasAnyStroke; + newCtx.continueStroke = this.continueStroke; + // Use setCurrent to replace the current context + this.ctxStack.setCurrent(newCtx); + + if (this.yieldPropertyCommands) { + const changes = this.getPropertyChanges(prevCtx, newCtx); + if (Object.keys(changes).length > 0) { + return { + command: cmd, + changes, + depth: this.ctxStack.depth, + }; + } + } + } + + /** + * Get property changes between two contexts + * @param oldCtx - The old context + * @param newCtx - The new context + * @returns Object containing changed properties + */ + private getPropertyChanges( + oldCtx: MTextContext, + newCtx: MTextContext + ): TokenData[TokenType.PROPERTIES_CHANGED]['changes'] { + const changes: TokenData[TokenType.PROPERTIES_CHANGED]['changes'] = {}; + + if (oldCtx.underline !== newCtx.underline) { + changes.underline = newCtx.underline; + } + if (oldCtx.overline !== newCtx.overline) { + changes.overline = newCtx.overline; + } + if (oldCtx.strikeThrough !== newCtx.strikeThrough) { + changes.strikeThrough = newCtx.strikeThrough; + } + if (oldCtx.color.aci !== newCtx.color.aci) { + changes.aci = newCtx.color.aci; + } + if (oldCtx.color.rgbValue !== newCtx.color.rgbValue) { + changes.rgb = newCtx.color.rgb; + } + if (oldCtx.align !== newCtx.align) { + changes.align = newCtx.align; + } + if (JSON.stringify(oldCtx.fontFace) !== JSON.stringify(newCtx.fontFace)) { + changes.fontFace = newCtx.fontFace; + } + if ( + oldCtx.capHeight.value !== newCtx.capHeight.value || + oldCtx.capHeight.isRelative !== newCtx.capHeight.isRelative + ) { + changes.capHeight = newCtx.capHeight; + } + if ( + oldCtx.widthFactor.value !== newCtx.widthFactor.value || + oldCtx.widthFactor.isRelative !== newCtx.widthFactor.isRelative + ) { + changes.widthFactor = newCtx.widthFactor; + } + if ( + oldCtx.charTrackingFactor.value !== newCtx.charTrackingFactor.value || + oldCtx.charTrackingFactor.isRelative !== newCtx.charTrackingFactor.isRelative + ) { + changes.charTrackingFactor = newCtx.charTrackingFactor; + } + if (oldCtx.oblique !== newCtx.oblique) { + changes.oblique = newCtx.oblique; + } + if (JSON.stringify(oldCtx.paragraph) !== JSON.stringify(newCtx.paragraph)) { + // Only include changed paragraph properties + const changedProps: Partial = {}; + if (oldCtx.paragraph.indent !== newCtx.paragraph.indent) { + changedProps.indent = newCtx.paragraph.indent; + } + if (oldCtx.paragraph.align !== newCtx.paragraph.align) { + changedProps.align = newCtx.paragraph.align; + } + if (oldCtx.paragraph.left !== newCtx.paragraph.left) { + changedProps.left = newCtx.paragraph.left; + } + if (oldCtx.paragraph.right !== newCtx.paragraph.right) { + changedProps.right = newCtx.paragraph.right; + } + if (JSON.stringify(oldCtx.paragraph.tabs) !== JSON.stringify(newCtx.paragraph.tabs)) { + changedProps.tabs = newCtx.paragraph.tabs; + } + if (Object.keys(changedProps).length > 0) { + changes.paragraph = changedProps; + } + } + + return changes; + } + + /** + * Parse alignment property + * @param ctx - The context to update + */ + private parseAlign(ctx: MTextContext): void { + const char = this.scanner.get(); + if ('012'.includes(char)) { + ctx.align = parseInt(char) as MTextLineAlignment; + } else { + ctx.align = MTextLineAlignment.BOTTOM; + } + this.consumeOptionalTerminator(); + } + + /** + * Parse height property + * @param ctx - The context to update + */ + private parseHeight(ctx: MTextContext): void { + const expr = this.extractFloatExpression(true); + if (expr) { + try { + if (expr.endsWith('x')) { + // For height command, treat x suffix as relative value + ctx.capHeight = { + value: parseFloat(expr.slice(0, -1)), + isRelative: true, + }; + } else { + ctx.capHeight = { + value: parseFloat(expr), + isRelative: false, + }; + } + } catch { + // If parsing fails, treat the entire command as literal text + this.scanner.consume(-expr.length); // Rewind to before the expression + return; + } + } + this.consumeOptionalTerminator(); + } + + /** + * Parse width property + * @param ctx - The context to update + */ + private parseWidth(ctx: MTextContext): void { + const expr = this.extractFloatExpression(true); + if (expr) { + try { + if (expr.endsWith('x')) { + // For width command, treat x suffix as relative value + ctx.widthFactor = { + value: parseFloat(expr.slice(0, -1)), + isRelative: true, + }; + } else { + ctx.widthFactor = { + value: parseFloat(expr), + isRelative: false, + }; + } + } catch { + // If parsing fails, treat the entire command as literal text + this.scanner.consume(-expr.length); // Rewind to before the expression + return; + } + } + this.consumeOptionalTerminator(); + } + + /** + * Parse character tracking property + * @param ctx - The context to update + */ + private parseCharTracking(ctx: MTextContext): void { + const expr = this.extractFloatExpression(true); + if (expr) { + try { + if (expr.endsWith('x')) { + // For tracking command, treat x suffix as relative value + ctx.charTrackingFactor = { + value: Math.abs(parseFloat(expr.slice(0, -1))), + isRelative: true, + }; + } else { + ctx.charTrackingFactor = { + value: Math.abs(parseFloat(expr)), + isRelative: false, + }; + } + } catch { + // If parsing fails, treat the entire command as literal text + this.scanner.consume(-expr.length); // Rewind to before the expression + return; + } + } + this.consumeOptionalTerminator(); + } + + /** + * Parse float value or factor + * @param value - Current value to apply factor to + * @returns New value + */ + private parseFloatValueOrFactor(value: number): number { + const expr = this.extractFloatExpression(true); + if (expr) { + if (expr.endsWith('x')) { + const factor = parseFloat(expr.slice(0, -1)); + value *= factor; // Allow negative factors + } else { + value = parseFloat(expr); // Allow negative values + } + } + return value; + } + + /** + * Parse oblique angle property + * @param ctx - The context to update + */ + private parseOblique(ctx: MTextContext): void { + const obliqueExpr = this.extractFloatExpression(false); + if (obliqueExpr) { + ctx.oblique = parseFloat(obliqueExpr); + } + this.consumeOptionalTerminator(); + } + + /** + * Parse ACI color property + * @param ctx - The context to update + */ + private parseAciColor(ctx: MTextContext): void { + const aciExpr = this.extractIntExpression(); + if (aciExpr) { + const aci = parseInt(aciExpr); + if (aci < 257) { + ctx.color.aci = aci; + } + } + this.consumeOptionalTerminator(); + } + + /** + * Parse RGB color property + * @param ctx - The context to update + */ + private parseRgbColor(ctx: MTextContext): void { + const rgbExpr = this.extractIntExpression(); + if (rgbExpr) { + const value = parseInt(rgbExpr) & 0xffffff; + ctx.color.rgbValue = value; + } + this.consumeOptionalTerminator(); + } + + /** + * Extract float expression from scanner + * @param relative - Whether to allow relative values (ending in 'x') + * @returns Extracted expression + */ + private extractFloatExpression(relative: boolean = false): string { + const pattern = relative + ? /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?x?/ + : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/; + const match = this.scanner.tail.match(pattern); + if (match) { + const result = match[0]; + this.scanner.consume(result.length); + return result; + } + return ''; + } + + /** + * Extract integer expression from scanner + * @returns Extracted expression + */ + private extractIntExpression(): string { + const match = this.scanner.tail.match(/^\d+/); + if (match) { + const result = match[0]; + this.scanner.consume(result.length); + return result; + } + return ''; + } + + /** + * Extract expression until semicolon or end + * @param escape - Whether to handle escaped semicolons + * @returns Extracted expression + */ + private extractExpression(escape: boolean = false): string { + const stop = this.scanner.find(';', escape); + if (stop < 0) { + const expr = this.scanner.tail; + this.scanner.consume(expr.length); + return expr; + } + // Check if the semicolon is escaped by looking at the previous character + const prevChar = this.scanner.peek(stop - this.scanner.currentIndex - 1); + const isEscaped = prevChar === '\\'; + const expr = this.scanner.tail.slice(0, stop - this.scanner.currentIndex + (isEscaped ? 1 : 0)); + this.scanner.consume(expr.length + 1); + return expr; + } + + /** + * Parse font properties + * @param ctx - The context to update + */ + private parseFontProperties(ctx: MTextContext): void { + const parts = this.extractExpression().split('|'); + if (parts.length > 0 && parts[0]) { + const name = parts[0]; + let style: FontStyle = 'Regular'; + let weight = 400; + + for (const part of parts.slice(1)) { + if (part.startsWith('b1')) { + weight = 700; + } else if (part === 'i' || part.startsWith('i1')) { + style = 'Italic'; + } else if (part === 'i0' || part.startsWith('i0')) { + style = 'Regular'; + } + } + + ctx.fontFace = { + family: name, + style, + weight, + }; + } + } + + /** + * Parse paragraph properties from the MText content + * Handles properties like indentation, alignment, and tab stops + * @param ctx - The context to update + */ + private parseParagraphProperties(ctx: MTextContext): void { + const scanner = new TextScanner(this.extractExpression()); + /** Current indentation value */ + let indent = ctx.paragraph.indent; + /** Left margin value */ + let left = ctx.paragraph.left; + /** Right margin value */ + let right = ctx.paragraph.right; + /** Current paragraph alignment */ + let align = ctx.paragraph.align; + /** Array of tab stop positions and types */ + let tabStops: (number | string)[] = []; + + /** + * Parse a floating point number from the scanner's current position + * Handles optional sign, decimal point, and scientific notation + * @returns The parsed float value, or 0 if no valid number is found + */ + const parseFloatValue = (): number => { + const match = scanner.tail.match(/^[+-]?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/); + if (match) { + const value = parseFloat(match[0]); + scanner.consume(match[0].length); + while (scanner.peek() === ',') { + scanner.consume(1); + } + return value; + } + return 0; + }; + + while (scanner.hasData) { + const cmd = scanner.get(); + switch (cmd) { + case 'i': // Indentation + indent = parseFloatValue(); + break; + case 'l': // Left margin + left = parseFloatValue(); + break; + case 'r': // Right margin + right = parseFloatValue(); + break; + case 'x': // Skip + break; + case 'q': { + // Alignment + const adjustment = scanner.get(); + align = CHAR_TO_ALIGN[adjustment] || MTextParagraphAlignment.DEFAULT; + while (scanner.peek() === ',') { + scanner.consume(1); + } + break; + } + case 't': // Tab stops + tabStops = []; + while (scanner.hasData) { + const type = scanner.peek(); + if (type === 'r' || type === 'c') { + scanner.consume(1); + const value = parseFloatValue(); + tabStops.push(type + value.toString()); + } else { + const value = parseFloatValue(); + if (!isNaN(value)) { + tabStops.push(value); + } else { + scanner.consume(1); + } + } + } + break; + } + } + + ctx.paragraph = { + indent, + left, + right, + align, + tabs: tabStops, + }; + } + + /** + * Consume optional terminator (semicolon) + */ + private consumeOptionalTerminator(): void { + if (this.scanner.peek() === ';') { + this.scanner.consume(1); + } + } + + /** + * Builds {@link PercentSymbolData} for a recognized `%%` code letter. + */ + private buildPercentSymbolData( + code: string, + specialChar: string + ): PercentSymbolData | null { + if (code === 'c' || code === 'd' || code === 'p') { + return { kind: 'named', code, char: specialChar }; + } + if (code === '%') { + return { kind: 'literal', char: '%' }; + } + return null; + } + + /** + * Parse MText content into tokens + * @yields MTextToken objects + */ + *parse(): Generator { + const wordToken = TokenType.WORD; + const spaceToken = TokenType.SPACE; + let followupToken: TokenType | null = null; + let followupData: TokenData[TokenType] | undefined; + + function resetParagraph(ctx: MTextContext): Partial { + const prev = { ...ctx.paragraph }; + ctx.paragraph = { + indent: 0, + left: 0, + right: 0, + align: MTextParagraphAlignment.DEFAULT, + tabs: [], + }; + const changed: Partial = {}; + if (prev.indent !== 0) changed.indent = 0; + if (prev.left !== 0) changed.left = 0; + if (prev.right !== 0) changed.right = 0; + if (prev.align !== MTextParagraphAlignment.DEFAULT) + changed.align = MTextParagraphAlignment.DEFAULT; + if (JSON.stringify(prev.tabs) !== JSON.stringify([])) changed.tabs = []; + return changed; + } + + const nextToken = (): [TokenType, TokenData[TokenType]] => { + let word = ''; + while (this.scanner.hasData) { + let escape = false; + let letter = this.scanner.peek(); + const cmdStartIndex = this.scanner.currentIndex; + + // Handle control characters first + if (letter.charCodeAt(0) < 32) { + this.scanner.consume(1); // Always consume the control character + if (letter === '\t') { + return [TokenType.TABULATOR, null]; + } + if (letter === '\n') { + return [TokenType.NEW_PARAGRAPH, null]; + } + letter = ' '; + } + + if (letter === '\\') { + if ('\\{}'.includes(this.scanner.peek(1))) { + escape = true; + this.scanner.consume(1); + letter = this.scanner.peek(); + } else { + if (word) { + return [wordToken, word]; + } + this.scanner.consume(1); + const cmd = this.scanner.get(); + switch (cmd) { + case '~': + return [TokenType.NBSP, null]; + case 'P': + return [TokenType.NEW_PARAGRAPH, null]; + case 'N': + return [TokenType.NEW_COLUMN, null]; + case 'X': + return [TokenType.WRAP_AT_DIMLINE, null]; + case 'S': { + this.inStackContext = true; + const result = this.parseStacking(); + this.inStackContext = false; + return result; + } + case 'm': + case 'M': + // Handle multi-byte character encoding (MIF) + if (this.scanner.peek() === '+') { + this.scanner.consume(1); // Consume the '+' + const hexCode = this.extractMifCode(this.mifCodeLength); + if (hexCode) { + this.scanner.consume(hexCode.length); + const decodedChar = this.mifDecoder(hexCode); + if (word) { + return [wordToken, word]; + } + return [wordToken, decodedChar]; + } + // If no valid hex code found, rewind the '+' character + this.scanner.consume(-1); + } + // If not a valid multi-byte code, treat as literal text + word += '\\M'; + continue; + case 'U': + // Handle Unicode escape: \U+XXXX or \U+XXXXXXXX + if (this.scanner.peek() === '+') { + this.scanner.consume(1); // Consume the '+' + const hexMatch = this.scanner.tail.match(/^[0-9A-Fa-f]{4,8}/); + if (hexMatch) { + const hexCode = hexMatch[0]; + this.scanner.consume(hexCode.length); + const codePoint = parseInt(hexCode, 16); + let decodedChar = ''; + try { + decodedChar = String.fromCodePoint(codePoint); + } catch { + decodedChar = '▯'; + } + if (word) { + return [wordToken, word]; + } + return [wordToken, decodedChar]; + } + // If no valid hex code found, rewind the '+' character + this.scanner.consume(-1); + } + // If not a valid Unicode code, treat as literal text + word += '\\U'; + continue; + default: + if (cmd) { + try { + const propertyChanges = this.parseProperties(cmd); + if (this.yieldPropertyCommands && propertyChanges) { + return [TokenType.PROPERTIES_CHANGED, propertyChanges]; + } + // After processing a property command, continue with normal parsing + continue; + } catch { + const commandText = this.scanner.tail.slice( + cmdStartIndex, + this.scanner.currentIndex + ); + word += commandText; + } + } + } + continue; + } + } + + if (letter === '%' && this.scanner.peek(1) === '%') { + const code = this.scanner.peek(2).toLowerCase(); + const specialChar = SPECIAL_CHAR_ENCODING[code]; + if (specialChar) { + this.scanner.consume(3); + if (this.yieldPercentSymbols) { + const symbolData = this.buildPercentSymbolData(code, specialChar); + if (symbolData) { + if (word) { + followupToken = TokenType.PERCENT_SYMBOL; + followupData = symbolData; + return [wordToken, word]; + } + return [TokenType.PERCENT_SYMBOL, symbolData]; + } + } + word += specialChar; + continue; + } else { + /** + * Supports Control Codes: `%%ddd`, where ddd is a three-digit decimal number representing the ASCII code value of the character. + * + * Reference: https://help.autodesk.com/view/ACD/2026/ENU/?guid=GUID-968CBC1D-BA99-4519-ABDD-88419EB2BF92 + */ + const digits = [code, this.scanner.peek(3), this.scanner.peek(4)]; + + if (digits.every(d => d >= '0' && d <= '9')) { + const charCode = Number.parseInt(digits.join(''), 10); + this.scanner.consume(5); + if (this.yieldPercentSymbols) { + const symbolData: PercentSymbolData = { + kind: 'numeric', + charCode, + char: String.fromCharCode(charCode), + }; + if (word) { + followupToken = TokenType.PERCENT_SYMBOL; + followupData = symbolData; + return [wordToken, word]; + } + return [TokenType.PERCENT_SYMBOL, symbolData]; + } + word += String.fromCharCode(charCode); + } else { + // Skip invalid special character codes + this.scanner.consume(3); + } + + continue; + } + } + + if (letter === ' ') { + if (word) { + this.scanner.consume(1); + followupToken = spaceToken; + return [wordToken, word]; + } + this.scanner.consume(1); + return [spaceToken, null]; + } + + if (!escape) { + if (letter === '{') { + if (word) { + return [wordToken, word]; + } + this.scanner.consume(1); + this.pushCtx(); + continue; + } else if (letter === '}') { + if (word) { + return [wordToken, word]; + } + this.scanner.consume(1); + // Context restoration with yieldPropertyCommands + if (this.yieldPropertyCommands) { + const prevCtx = this.ctxStack.current; + this.popCtx(); + const changes = this.getPropertyChanges(prevCtx, this.ctxStack.current); + if (Object.keys(changes).length > 0) { + return [ + TokenType.PROPERTIES_CHANGED, + { command: undefined, changes, depth: this.ctxStack.depth }, + ]; + } + } else { + this.popCtx(); + } + continue; + } + } + + // Handle caret-encoded characters only when not in stack context + if (!this.inStackContext && letter === '^') { + const nextChar = this.scanner.peek(1); + if (nextChar) { + const code = nextChar.charCodeAt(0); + this.scanner.consume(2); // Consume both ^ and the next character + if (code === 32) { + // Space + word += '^'; + } else if (code === 73) { + // Tab + if (word) { + return [wordToken, word]; + } + return [TokenType.TABULATOR, null]; + } else if (code === 74) { + // Line feed + if (word) { + return [wordToken, word]; + } + return [TokenType.NEW_PARAGRAPH, null]; + } else if (code === 77) { + // Carriage return + // Ignore carriage return + continue; + } else { + word += '▯'; + } + continue; + } + } + + this.scanner.consume(1); + if (letter.charCodeAt(0) >= 32) { + word += letter; + } + } + + if (word) { + return [wordToken, word]; + } + return [TokenType.NONE, null]; + }; + + while (true) { + const [type, data] = nextToken.call(this); + if (type) { + yield new MTextToken(type, this.ctxStack.current.copy(), data); + if (type === TokenType.NEW_PARAGRAPH && this.resetParagraphParameters) { + // Reset paragraph properties and emit PROPERTIES_CHANGED if needed + const ctx = this.ctxStack.current; + const changed = resetParagraph(ctx); + if (this.yieldPropertyCommands && Object.keys(changed).length > 0) { + yield new MTextToken(TokenType.PROPERTIES_CHANGED, ctx.copy(), { + command: undefined, + changes: { paragraph: changed }, + depth: this.ctxStack.depth, + }); + } + } + if (followupToken) { + yield new MTextToken( + followupToken, + this.ctxStack.current.copy(), + followupData ?? null + ); + followupToken = null; + followupData = undefined; + } + } else { + break; + } + } + } +} + +/** + * Text scanner for parsing MText content + */ +export class TextScanner { + private text: string; + private textLen: number; + private _index: number; + + /** + * Create a new text scanner + * @param text - The text to scan + */ + constructor(text: string) { + this.text = text; + this.textLen = text.length; + this._index = 0; + } + + /** + * Get the current index in the text + */ + get currentIndex(): number { + return this._index; + } + + /** + * Check if the scanner has reached the end of the text + */ + get isEmpty(): boolean { + return this._index >= this.textLen; + } + + /** + * Check if there is more text to scan + */ + get hasData(): boolean { + return this._index < this.textLen; + } + + /** + * Get the next character and advance the index + * @returns The next character, or empty string if at end + */ + get(): string { + if (this.isEmpty) { + return ''; + } + const char = this.text[this._index]; + this._index++; + return char; + } + + /** + * Advance the index by the specified count + * @param count - Number of characters to advance + */ + consume(count: number = 1): void { + this._index = Math.max(0, Math.min(this._index + count, this.textLen)); + } + + /** + * Look at a character without advancing the index + * @param offset - Offset from current position + * @returns The character at the offset position, or empty string if out of bounds + */ + peek(offset: number = 0): string { + const index = this._index + offset; + if (index >= this.textLen || index < 0) { + return ''; + } + return this.text[index]; + } + + /** + * Find the next occurrence of a character + * @param char - The character to find + * @param escape - Whether to handle escaped characters + * @returns Index of the character, or -1 if not found + */ + find(char: string, escape: boolean = false): number { + let index = this._index; + while (index < this.textLen) { + if (escape && this.text[index] === '\\') { + if (index + 1 < this.textLen) { + if (this.text[index + 1] === char) { + return index + 1; + } + index += 2; + continue; + } + index++; + continue; + } + if (this.text[index] === char) { + return index; + } + index++; + } + return -1; + } + + /** + * Get the remaining text from the current position + */ + get tail(): string { + return this.text.slice(this._index); + } + + /** + * Check if the next character is a space + */ + isNextSpace(): boolean { + return this.peek() === ' '; + } + + /** + * Consume spaces until a non-space character is found + * @returns Number of spaces consumed + */ + consumeSpaces(): number { + let count = 0; + while (this.isNextSpace()) { + this.consume(); + count++; + } + return count; + } +} + +/** + * Class to handle ACI and RGB color logic for MText. + * + * This class encapsulates color state for MText, supporting both AutoCAD Color Index (ACI) and RGB color. + * Only one color mode is active at a time: setting an RGB color disables ACI, and vice versa. + * RGB is stored as a single 24-bit integer (0xRRGGBB) for efficient comparison and serialization. + * + * Example usage: + * ```ts + * const color1 = new MTextColor(1); // ACI color + * const color2 = new MTextColor([255, 0, 0]); // RGB color + * const color3 = new MTextColor(); // Default (ACI=256, "by layer") + * ``` + */ +export class MTextColor { + /** + * The AutoCAD Color Index (ACI) value. Only used if no RGB color is set. + * @default 256 ("by layer") + */ + private _aci: number | null = 256; + /** + * The RGB color value as a single 24-bit integer (0xRRGGBB), or null if not set. + * @default null + */ + private _rgbValue: number | null = null; // Store as 0xRRGGBB or null + + /** + * Create a new MTextColor instance. + * @param color The initial color: number for ACI, [r,g,b] for RGB, or null/undefined for default (ACI=256). + */ + constructor(color?: number | RGB | null) { + if (Array.isArray(color)) { + this.rgb = color; + } else if (typeof color === 'number') { + this.aci = color; + } else { + this.aci = 256; + } + } + + /** + * Get the current ACI color value. + * @returns The ACI color (0-256), or null if using RGB. + */ + get aci(): number | null { + return this._aci; + } + + /** + * Set the ACI color value. Setting this disables any RGB color. + * @param value The ACI color (0-256), or null to unset. + * @throws Error if value is out of range. + */ + set aci(value: number | null) { + if (value === null) { + this._aci = null; + } else if (value >= 0 && value <= 256) { + this._aci = value; + this._rgbValue = null; + } else { + throw new Error('ACI not in range [0, 256]'); + } + } + + /** + * Get the current RGB color as a tuple [r, g, b], or null if not set. + * @returns The RGB color tuple, or null if using ACI. + */ + get rgb(): RGB | null { + if (this._rgbValue === null) return null; + // Extract R, G, B from 0xRRGGBB + const r = (this._rgbValue >> 16) & 0xff; + const g = (this._rgbValue >> 8) & 0xff; + const b = this._rgbValue & 0xff; + return [r, g, b]; + } + + /** + * Set the RGB color. Setting this disables ACI color. + * @param value The RGB color tuple [r, g, b], or null to use ACI. + */ + set rgb(value: RGB | null) { + if (value) { + const [r, g, b] = value; + this._rgbValue = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); + this._aci = null; + } else { + this._rgbValue = null; + } + } + + /** + * Returns true if the color is set by RGB, false if by ACI. + */ + get isRgb(): boolean { + return this._rgbValue !== null; + } + + /** + * Returns true if the color is set by ACI, false if by RGB. + */ + get isAci(): boolean { + return this._rgbValue === null && this._aci !== null; + } + + /** + * Get or set the internal RGB value as a number (0xRRGGBB), or null if not set. + * Setting this will switch to RGB mode and set ACI to null. + */ + get rgbValue(): number | null { + return this._rgbValue; + } + + set rgbValue(val: number | null) { + if (val === null) { + this._rgbValue = null; + } else { + this._rgbValue = val & 0xffffff; + this._aci = null; + } + } + + /** + * Returns a deep copy of this color. + * @returns A new MTextColor instance with the same color state. + */ + copy(): MTextColor { + const c = new MTextColor(); + c._aci = this._aci; + c._rgbValue = this._rgbValue; + return c; + } + + /** + * Returns a plain object for serialization. + * @returns An object with aci, rgb (tuple), and rgbValue (number or null). + */ + toObject(): { aci: number | null; rgb: RGB | null; rgbValue: number | null } { + return { aci: this._aci, rgb: this.rgb, rgbValue: this._rgbValue }; + } + + /** + * Convert the current color to a CSS hex color string (#rrggbb). + * Returns null if the color is ACI-based and has no RGB value. + */ + toCssColor(): string | null { + if (this._rgbValue !== null) { + return colorNumberToHex(this._rgbValue); + } + return null; + } + + /** + * Create an MTextColor from a CSS color string. + * Supports #rgb, #rrggbb, rgb(...), rgba(...). Returns null if invalid or transparent. + */ + static fromCssColor(value: string | null | undefined): MTextColor | null { + const rgbValue = cssColorToRgbValue(value); + if (rgbValue === null) return null; + const color = new MTextColor(); + color.rgbValue = rgbValue; + return color; + } + + /** + * Equality check for color. + * @param other The other MTextColor to compare. + * @returns True if both ACI and RGB values are equal. + */ + equals(other: MTextColor): boolean { + return this._aci === other._aci && this._rgbValue === other._rgbValue; + } +} + +/** + * MText context class for managing text formatting state + */ +export class MTextContext { + private _stroke: number = 0; + /** Whether to continue stroke formatting */ + continueStroke: boolean = false; + /** Color (ACI or RGB) */ + color: MTextColor = new MTextColor(); + /** Line alignment */ + align: MTextLineAlignment = MTextLineAlignment.BOTTOM; + /** Font face properties */ + fontFace: FontFace = { family: '', style: 'Regular', weight: 400 }; + /** Capital letter height */ + private _capHeight: FactorValue = { value: 1.0, isRelative: false }; + /** Character width factor */ + private _widthFactor: FactorValue = { value: 1.0, isRelative: false }; + /** + * Character tracking factor a multiplier applied to the default spacing between characters in the MText object. + * - Value = 1.0 → Normal spacing. + * - Value < 1.0 → Characters are closer together. + * - Value > 1.0 → Characters are spaced farther apart. + */ + private _charTrackingFactor: FactorValue = { value: 1.0, isRelative: false }; + /** Oblique angle */ + oblique: number = 0.0; + /** Paragraph properties */ + paragraph: ParagraphProperties = { + indent: 0, + left: 0, + right: 0, + align: MTextParagraphAlignment.DEFAULT, + tabs: [], + }; + + /** + * Get the capital letter height + */ + get capHeight(): FactorValue { + return this._capHeight; + } + + /** + * Set the capital letter height + * @param value - Height value + */ + set capHeight(value: FactorValue) { + this._capHeight = { + value: Math.abs(value.value), + isRelative: value.isRelative, + }; + } + + /** + * Get the character width factor + */ + get widthFactor(): FactorValue { + return this._widthFactor; + } + + /** + * Set the character width factor + * @param value - Width factor value + */ + set widthFactor(value: FactorValue) { + this._widthFactor = { + value: Math.abs(value.value), + isRelative: value.isRelative, + }; + } + + /** + * Get the character tracking factor + */ + get charTrackingFactor(): FactorValue { + return this._charTrackingFactor; + } + + /** + * Set the character tracking factor + * @param value - Tracking factor value + */ + set charTrackingFactor(value: FactorValue) { + this._charTrackingFactor = { + value: Math.abs(value.value), + isRelative: value.isRelative, + }; + } + + /** + * Get the ACI color value + */ + get aci(): number | null { + return this.color.aci; + } + + /** + * Set the ACI color value + * @param value - ACI color value (0-256) + * @throws Error if value is out of range + */ + set aci(value: number) { + this.color.aci = value; + } + + /** + * Get the RGB color value + */ + get rgb(): RGB | null { + return this.color.rgb; + } + + /** + * Set the RGB color value + */ + set rgb(value: RGB | null) { + this.color.rgb = value; + } + + /** + * Gets whether the current text should be rendered in italic style. + * @returns {boolean} True if the font style is 'Italic', otherwise false. + */ + get italic(): boolean { + return this.fontFace.style === 'Italic'; + } + /** + * Sets whether the current text should be rendered in italic style. + * @param value - If true, sets the font style to 'Italic'; if false, sets it to 'Regular'. + */ + set italic(value: boolean) { + this.fontFace.style = value ? 'Italic' : 'Regular'; + } + + /** + * Gets whether the current text should be rendered in bold style. + * This is primarily used for mesh fonts and affects font selection. + * @returns {boolean} True if the font weight is 700 or higher, otherwise false. + */ + get bold(): boolean { + return (this.fontFace.weight || 400) >= 700; + } + /** + * Sets whether the current text should be rendered in bold style. + * This is primarily used for mesh fonts and affects font selection. + * @param value - If true, sets the font weight to 700; if false, sets it to 400. + */ + set bold(value: boolean) { + this.fontFace.weight = value ? 700 : 400; + } + + /** + * Get whether text is underlined + */ + get underline(): boolean { + return Boolean(this._stroke & MTextStroke.UNDERLINE); + } + + /** + * Set whether text is underlined + * @param value - Whether to underline + */ + set underline(value: boolean) { + this._setStrokeState(MTextStroke.UNDERLINE, value); + } + + /** + * Get whether text has strike-through + */ + get strikeThrough(): boolean { + return Boolean(this._stroke & MTextStroke.STRIKE_THROUGH); + } + + /** + * Set whether text has strike-through + * @param value - Whether to strike through + */ + set strikeThrough(value: boolean) { + this._setStrokeState(MTextStroke.STRIKE_THROUGH, value); + } + + /** + * Get whether text has overline + */ + get overline(): boolean { + return Boolean(this._stroke & MTextStroke.OVERLINE); + } + + /** + * Set whether text has overline + * @param value - Whether to overline + */ + set overline(value: boolean) { + this._setStrokeState(MTextStroke.OVERLINE, value); + } + + /** + * Check if any stroke formatting is active + */ + get hasAnyStroke(): boolean { + return Boolean(this._stroke); + } + + /** + * Set the state of a stroke type + * @param stroke - The stroke type to set + * @param state - Whether to enable or disable the stroke + */ + private _setStrokeState(stroke: MTextStroke, state: boolean = true): void { + if (state) { + this._stroke |= stroke; + } else { + this._stroke &= ~stroke; + } + } + + /** + * Create a copy of this context + * @returns A new context with the same properties + */ + copy(): MTextContext { + const ctx = new MTextContext(); + ctx._stroke = this._stroke; + ctx.continueStroke = this.continueStroke; + ctx.color = this.color.copy(); + ctx.align = this.align; + ctx.fontFace = { ...this.fontFace }; + ctx._capHeight = { ...this._capHeight }; + ctx._widthFactor = { ...this._widthFactor }; + ctx._charTrackingFactor = { ...this._charTrackingFactor }; + ctx.oblique = this.oblique; + ctx.paragraph = { ...this.paragraph }; + return ctx; + } +} + +/** + * Token class for MText parsing + */ +export class MTextToken { + /** + * Create a new MText token + * @param type - The token type + * @param ctx - The text context at this token + * @param data - Optional token data + */ + constructor( + public type: TokenType, + public ctx: MTextContext, + public data: TokenData[TokenType] + ) {} +}