From 99d7f521d5287bb7c7567381a0da07638b6948d7 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:17:11 -0400 Subject: [PATCH 01/10] Revert types changed in 89c6bf09 --- src/parseTokens.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/parseTokens.ts b/src/parseTokens.ts index ec7d06b..834a038 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -6,7 +6,7 @@ import Token, { TokenType, typeToOperation, lexemeToType } from './Token'; * Create the corresponding MathJS node of a Token and its children. * @returns A newly constructed MathJS node. */ -function createMathJSNode(token: Token, children: typeof math.Node[] = []): typeof math.Node { +function createMathJSNode(token: Token, children: math.MathNode[] = []): math.MathNode { let fn = typeToOperation[token.type]; switch (token.type) { case TokenType.Times: @@ -212,7 +212,7 @@ class Parser { * | VARIABLE EQUALS expr * @returns Returns the root node of an expression tree. */ - nextExpression(): typeof math.Node { + nextExpression(): math.MathNode { let leftTerm = this.nextTerm(); // VARIABLE EQUALS expr if (this.match(TokenType.Equals)) { @@ -241,8 +241,8 @@ class Parser { * term => factor (((STAR | TIMES) factor) | power)* * @returns Returns the root node of an expression tree. */ - nextTerm(): typeof math.Node { - function isNumberNode(node: typeof math.Node) { + nextTerm(): math.MathNode { + function isNumberNode(node: math.MathNode) { return 'isConstantNode' in node && node.isConstantNode && !Number.isNaN(Number(node)); } let leftFactor = this.nextFactor(); @@ -292,7 +292,7 @@ class Parser { * factor => MINUS? power * @returns The root node of an expression tree. */ - nextFactor(): typeof math.Node { + nextFactor(): math.MathNode { // match for optional factor negation if (this.match(TokenType.Minus)) { const negate = this.nextToken(); @@ -308,7 +308,7 @@ class Parser { * power => primary (CARET primary)* * @returns The root node of an expression tree. */ - nextPower(): typeof math.Node { + nextPower(): math.MathNode { let base = this.nextPrimary(); while (this.match(TokenType.Caret)) { const caret = this.nextToken(); @@ -345,7 +345,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextPrimary(): typeof math.Node { + nextPrimary(): math.MathNode { const lookaheadType = this.match(...primaryTypes); if (lookaheadType === undefined) { throw new ParseError('expected primary', this.currentToken()); @@ -415,7 +415,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextGrouping(): typeof math.Node[] { + nextGrouping(): math.MathNode[] { // token indicating start of grouping let leftRight = false; // flag indicating if grouping tokens are marked with \left and \right if (this.match(TokenType.Left)) { @@ -435,7 +435,7 @@ class Parser { } // a grouping can contain multiple children if the // grouping is parenthetical and the values are comma-seperated - const children: typeof math.Node[] = [grouping]; + const children: math.MathNode[] = [grouping]; if (leftGrouping.type === TokenType.Lparen) { while (this.match(TokenType.Comma)) { this.nextToken(); // consume comma @@ -456,7 +456,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextUnaryFunc(): typeof math.Node { + nextUnaryFunc(): math.MathNode { const func = this.nextToken(); const argument = this.nextArgument(); return createMathJSNode(func, argument); @@ -468,7 +468,7 @@ class Parser { * customFn => OPNAME LBRACE identifier RBRACE grouping * @returns The root node of an expression tree. */ - nextCustomFunc(): typeof math.Node { + nextCustomFunc(): math.MathNode { this.nextToken(); // consume \\operatornmae this.tryConsume("expected '{' after \\operatorname", TokenType.Lbrace); const customFunc = this.nextToken(); @@ -485,7 +485,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextArgument(): typeof math.Node[] { + nextArgument(): math.MathNode[] { let argument; // try to match grouping e.g. (), {}, || if (this.match(TokenType.Left, @@ -508,7 +508,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextFrac(): typeof math.Node { + nextFrac(): math.MathNode { const frac = this.nextToken(); this.tryConsume("expected '{' for the numerator in \\frac", TokenType.Lbrace); const numerator = this.nextExpression(); @@ -532,7 +532,7 @@ class Parser { * * @returns The root node of an expression tree. */ - nextMatrix(): typeof math.Node { + nextMatrix(): math.MathNode { this.nextToken(); // consume \begin this.tryConsume("expected '{' after \\begin", TokenType.Lbrace); const matrixToken = this.tryConsume("expected 'matrix' after '\\begin{' " @@ -607,6 +607,6 @@ class Parser { * * @returns The root node of a MathJS expression tree. */ -export default function parseTokens(tokens: Token[]): typeof math.Node { +export default function parseTokens(tokens: Token[]): math.MathNode { return (new Parser(tokens)).nextExpression(); } From 5a6a15bf11ec256d9a7528134a6d485c77cd6f43 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:17:25 -0400 Subject: [PATCH 02/10] Make use of math types where possible --- src/customMath.ts | 18 +++++++++++++----- src/parseTokens.ts | 30 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/customMath.ts b/src/customMath.ts index 1686288..7c6011e 100644 --- a/src/customMath.ts +++ b/src/customMath.ts @@ -34,11 +34,19 @@ const math = create(all, { const mathImport = { lastFn: '', lastArgs: [], - eigenvalues: (matrix: any) => math.eigs(matrix).values, - eigenvectors: (matrix: any) => math.eigs(matrix).eigenvectors, - comp: (a: any, b: any) => math.divide(math.dot(a, b), math.norm(a)), // component of b along a - proj: (a: any, b: any) => math.multiply(math.divide(a, math.norm(a)), - math.divide(math.dot(a, b), math.norm(a))), // projection of b along a + eigenvalues: (matrix: math.MathCollection) => math.eigs(matrix).values, + eigenvectors: (matrix: math.MathCollection) => math.eigs(matrix).eigenvectors, + comp: ( + a: math.MathCollection, + b: math.MathCollection, + ) => math.divide(math.dot(a, b), math.norm(a)), // component of b along a + proj: ( + a: math.MathCollection, + b: math.MathCollection, + ) => math.multiply( + math.divide(a, math.norm(a)), + math.divide(math.dot(a, b), math.norm(a)), + ), // projection of b along a }; math.import(mathImport, { diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 834a038..63ef697 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -10,7 +10,7 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma let fn = typeToOperation[token.type]; switch (token.type) { case TokenType.Times: - return new (math as any).FunctionNode('cross', children); + return new math.FunctionNode('cross', children); case TokenType.Minus: // mathjs differentiates between subtraction and the unary minus fn = children.length === 1 ? 'unaryMinus' : fn; @@ -19,16 +19,16 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Star: case TokenType.Frac: case TokenType.Slash: - return new (math as any).OperatorNode(token.lexeme, fn, children); + return new math.OperatorNode(token.lexeme as any, fn as any, children); case TokenType.Caret: if (children.length < 2) { throw new ParseError('Expected two children for ^ operator', token); } // manually check for ^T as the transpose operation - if ('isSymbolNode' in children[1] && children[1].isSymbolNode && children[1].name === 'T') { - return new (math as any).FunctionNode('transpose', [children[0]]); + if (math.isSymbolNode(children[1]) && children[1].name === 'T') { + return new math.FunctionNode('transpose', [children[0]]); } - return new (math as any).OperatorNode(token.lexeme, fn, children); + return new math.OperatorNode(token.lexeme as any, fn as any, children); // mathjs built-in functions case TokenType.Bar: case TokenType.Sqrt: @@ -54,24 +54,24 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Comp: case TokenType.Norm: case TokenType.Inv: - return new (math as any).FunctionNode(fn, children); + return new math.FunctionNode(fn, children); case TokenType.Equals: - return new (math as any).AssignmentNode(children[0], children[1]); + return new math.AssignmentNode(children[0] as math.SymbolNode, children[1]); case TokenType.Variable: - return new (math as any).SymbolNode(token.lexeme); + return new math.SymbolNode(token.lexeme); case TokenType.Number: { // convert string lexeme to number if posssible const constant = Number.isNaN(Number(token.lexeme)) ? token.lexeme : +token.lexeme; - return new (math as any).ConstantNode(constant); + return new math.ConstantNode(constant); } case TokenType.Pi: - return new (math as any).SymbolNode('pi'); + return new math.SymbolNode('pi'); case TokenType.E: - return new (math as any).SymbolNode('e'); + return new math.SymbolNode('e'); case TokenType.Matrix: - return new (math as any).ArrayNode(children); + return new math.ArrayNode(children); case TokenType.T: - return new (math as any).SymbolNode('T'); + return new math.SymbolNode('T'); default: throw new ParseError('unknown token type', token); } @@ -216,7 +216,7 @@ class Parser { let leftTerm = this.nextTerm(); // VARIABLE EQUALS expr if (this.match(TokenType.Equals)) { - if ('isSymbolNode' in leftTerm && !leftTerm.isSymbolNode) { + if (!math.isSymbolNode(leftTerm)) { throw new ParseError('expected variable (SymbolNode) on left hand of assignment', this.previousToken()); } @@ -243,7 +243,7 @@ class Parser { */ nextTerm(): math.MathNode { function isNumberNode(node: math.MathNode) { - return 'isConstantNode' in node && node.isConstantNode && !Number.isNaN(Number(node)); + return math.isConstantNode(node) && !Number.isNaN(Number(node)); } let leftFactor = this.nextFactor(); let implicitMult = false; From efd4a21a8c07a8c6a65860747c326d4ec017bb86 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:49:43 -0400 Subject: [PATCH 03/10] Add subscript support --- src/Token.ts | 2 ++ src/parseTokens.ts | 31 +++++++++++++++++++++++++++---- src/tokenizeTex.ts | 2 +- tsconfig.json | 4 ++-- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/Token.ts b/src/Token.ts index 7ef726b..6af9f39 100644 --- a/src/Token.ts +++ b/src/Token.ts @@ -8,6 +8,7 @@ export const enum TokenType { Times, Slash, Caret, + Underscore, Comma, Lbrace, Rbrace, @@ -61,6 +62,7 @@ export const lexemeToType: { [key: string]: TokenType } = { '\\cdot': TokenType.Star, '\\times': TokenType.Times, '^': TokenType.Caret, + _: TokenType.Underscore, '/': TokenType.Slash, ',': TokenType.Comma, '{': TokenType.Lbrace, diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 63ef697..345352b 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -29,6 +29,8 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma return new math.FunctionNode('transpose', [children[0]]); } return new math.OperatorNode(token.lexeme as any, fn as any, children); + case TokenType.Underscore: + return new math.AccessorNode(children[0], new math.IndexNode(children.slice(1))); // mathjs built-in functions case TokenType.Bar: case TokenType.Sqrt: @@ -305,11 +307,11 @@ class Parser { /** * Consume the next power according to the following production: * - * power => primary (CARET primary)* + * power => subscript (CARET primary)* * @returns The root node of an expression tree. */ nextPower(): math.MathNode { - let base = this.nextPrimary(); + let base = this.nextSubscript(); while (this.match(TokenType.Caret)) { const caret = this.nextToken(); const exponent = this.nextPrimary(); @@ -318,6 +320,27 @@ class Parser { return base; } + /** + * Consume the next subscript according to the following production: + * + * subscript => primary (_ primary)* + * @returns The root node of an expression tree. + */ + nextSubscript(): math.MathNode { + let base = this.nextPrimary(); + while (this.match(TokenType.Underscore)) { + const underscore = this.nextToken(); + let subscript; + if (this.match(TokenType.Left, TokenType.Lparen, TokenType.Lbrace, TokenType.Bar)) { + subscript = this.nextGrouping(); + } else { + subscript = [this.nextPrimary()]; + } + base = createMathJSNode(underscore, [base, ...subscript]); + } + return base; + } + /** * Try to consume a token of the given type. If the next token does not match, * an error is thrown. @@ -427,7 +450,7 @@ class Parser { TokenType.Bar, TokenType.Lbrace); let grouping = this.nextExpression(); - + if (leftGrouping.type === TokenType.Bar) { // grouping with bars |x| also applies a function, so we create the corresponding function // here @@ -436,7 +459,7 @@ class Parser { // a grouping can contain multiple children if the // grouping is parenthetical and the values are comma-seperated const children: math.MathNode[] = [grouping]; - if (leftGrouping.type === TokenType.Lparen) { + if (leftGrouping.type === TokenType.Lparen || leftGrouping.type === TokenType.Lbrace) { while (this.match(TokenType.Comma)) { this.nextToken(); // consume comma children.push(this.nextExpression()); diff --git a/src/tokenizeTex.ts b/src/tokenizeTex.ts index 367bf46..38395c1 100644 --- a/src/tokenizeTex.ts +++ b/src/tokenizeTex.ts @@ -75,7 +75,7 @@ export default function tokenizeTex(texStr: string) { // don't accept control characters if (isControl(c)) { throw new LexError('invalid control sequence encountered ' - + '(forgot to escape backslashes (\\begin => \\\\begin)?', i); + + '(forgot to escape backslashes (\\begin => \\\\begin)?)', i); } // scan for single-char non-alphabetical lexemes if (!isAlpha(c) && c in lexemeToType) { diff --git a/tsconfig.json b/tsconfig.json index 252c9d9..5999d44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ }, "include": [ "src/**/*", - "types/**/*", + "types/**/*" ] -} \ No newline at end of file +} From feae3dd7bf60e0c7ef78afd27f3d95e492bf7377 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:49:43 -0400 Subject: [PATCH 04/10] Add symbol support --- src/Token.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++-- src/parseTokens.ts | 66 ++++++++++++++++++++++++++++++++++----- src/tokenizeTex.ts | 5 ++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/Token.ts b/src/Token.ts index 6af9f39..278d9dd 100644 --- a/src/Token.ts +++ b/src/Token.ts @@ -1,6 +1,7 @@ export const enum TokenType { Number, Variable, + Symbol, Equals, Plus, Minus, @@ -33,7 +34,11 @@ export const enum TokenType { Tanh, Log, Ln, - Pi, + Mathrm, + True, + False, + Mathbf, + Undefined, E, Begin, End, @@ -71,6 +76,9 @@ export const lexemeToType: { [key: string]: TokenType } = { ')': TokenType.Rparen, '|': TokenType.Bar, '&': TokenType.Amp, + True: TokenType.True, + False: TokenType.False, + '?': TokenType.Undefined, bmatrix: TokenType.Matrix, '\\\\': TokenType.Dblbackslash, '\\sqrt': TokenType.Sqrt, @@ -89,8 +97,9 @@ export const lexemeToType: { [key: string]: TokenType } = { '\\tanh': TokenType.Tanh, '\\log': TokenType.Log, '\\ln': TokenType.Ln, - '\\pi': TokenType.Pi, e: TokenType.E, + '\\mathrm': TokenType.Mathrm, + '\\mathbf': TokenType.Mathbf, '\\begin': TokenType.Begin, '\\end': TokenType.End, '\\left': TokenType.Left, @@ -107,6 +116,71 @@ export const lexemeToType: { [key: string]: TokenType } = { inv: TokenType.Inv, }; +export const lexemeToSymbol: { [key: string]: string } = { + // Greek letters + '\\Alpha': 'Alpha', + '\\alpha': 'alpha', + '\\Beta': 'Beta', + '\\beta': 'beta', + '\\Gamma': 'Gamma', + '\\gamma': 'gamma', + '\\Delta': 'Delta', + '\\delta': 'delta', + '\\Epsilon': 'Epsilon', + '\\epsilon': 'epsilon', + '\\varepsilon': 'varepsilon', + '\\Zeta': 'Zeta', + '\\zeta': 'zeta', + '\\Eta': 'Eta', + '\\eta': 'eta', + '\\Theta': 'Theta', + '\\theta': 'theta', + '\\vartheta': 'vartheta', + '\\Iota': 'Iota', + '\\iota': 'iota', + '\\Kappa': 'Kappa', + '\\kappa': 'kappa', + '\\varkappa': 'varkappa', + '\\Lambda': 'Lambda', + '\\lambda': 'lambda', + '\\Mu': 'Mu', + '\\mu': 'mu', + '\\Nu': 'Nu', + '\\nu': 'nu', + '\\Xi': 'Xi', + '\\xi': 'xi', + '\\Omicron': 'Omicron', + '\\omicron': 'omicron', + '\\Pi': 'Pi', + '\\pi': 'pi', + '\\varpi': 'varpi', + '\\Rho': 'Rho', + '\\rho': 'rho', + '\\varrho': 'varrho', + '\\Sigma': 'Sigma', + '\\sigma': 'sigma', + '\\varsigma': 'varsigma', + '\\Tau': 'Tau', + '\\tau': 'tau', + '\\Upsilon': 'Upsilon', + '\\upsilon': 'upsilon', + '\\Phi': 'Phi', + '\\phi': 'phi', + '\\varphi': 'varphi', + '\\Chi': 'Chi', + '\\chi': 'chi', + '\\Psi': 'Psi', + '\\psi': 'psi', + '\\Omega': 'Omega', + '\\omega': 'omega', + // Other + '\\i': 'i', + '\\infty': 'Infinity', + '\\lim': 'lim', +}; + +// TODO: Make conversions consistent with those in mathjs src/utils/latex.js + /** * A mapping from a token type to the operation it represents. * The operation is the name of a function in the mathjs namespace, diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 345352b..f780b94 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -1,6 +1,8 @@ import math from './customMath'; import ParseError from './ParseError'; -import Token, { TokenType, typeToOperation, lexemeToType } from './Token'; +import Token, { + TokenType, typeToOperation, lexemeToType, lexemeToSymbol, +} from './Token'; /** * Create the corresponding MathJS node of a Token and its children. @@ -66,10 +68,16 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma const constant = Number.isNaN(Number(token.lexeme)) ? token.lexeme : +token.lexeme; return new math.ConstantNode(constant); } - case TokenType.Pi: - return new math.SymbolNode('pi'); + case TokenType.Symbol: + return new math.SymbolNode(lexemeToSymbol[token.lexeme]); case TokenType.E: return new math.SymbolNode('e'); + case TokenType.True: + return new math.SymbolNode('true'); + case TokenType.False: + return new math.SymbolNode('false'); + case TokenType.Undefined: + return new math.SymbolNode('undefined'); case TokenType.Matrix: return new math.ArrayNode(children); case TokenType.T: @@ -95,6 +103,7 @@ const primaryTypes = [ TokenType.Bar, TokenType.Number, TokenType.Variable, + TokenType.Symbol, TokenType.Frac, TokenType.Sqrt, TokenType.Sin, @@ -112,7 +121,8 @@ const primaryTypes = [ TokenType.Log, TokenType.Ln, TokenType.Det, - TokenType.Pi, + TokenType.Mathrm, + TokenType.Mathbf, TokenType.E, TokenType.Begin, TokenType.T, // e.g. [[1,2],[3,4]]^T @@ -386,7 +396,7 @@ class Parser { break; case TokenType.Number: case TokenType.Variable: - case TokenType.Pi: + case TokenType.Symbol: case TokenType.E: case TokenType.T: primary = createMathJSNode(this.nextToken()); @@ -415,6 +425,14 @@ class Parser { case TokenType.Frac: primary = this.nextFrac(); break; + case TokenType.Mathrm: + // booleans are the only currently supported mathrm tag + primary = this.nextBoolean(); + break; + case TokenType.Mathbf: + // Undefined is the only currently supported mathbf tag + primary = this.nextUndefined(); + break; case TokenType.Begin: // matrix is the only currently supported environnment: if more are added, another // token of lookahead would be required to know which environnment to parse @@ -450,7 +468,7 @@ class Parser { TokenType.Bar, TokenType.Lbrace); let grouping = this.nextExpression(); - + if (leftGrouping.type === TokenType.Bar) { // grouping with bars |x| also applies a function, so we create the corresponding function // here @@ -459,7 +477,7 @@ class Parser { // a grouping can contain multiple children if the // grouping is parenthetical and the values are comma-seperated const children: math.MathNode[] = [grouping]; - if (leftGrouping.type === TokenType.Lparen || leftGrouping.type === TokenType.Lbrace) { + if (leftGrouping.type === TokenType.Lparen) { while (this.match(TokenType.Comma)) { this.nextToken(); // consume comma children.push(this.nextExpression()); @@ -548,6 +566,40 @@ class Parser { return createMathJSNode(frac, [numerator, denominator]); } + /** + * Consume the next boolean according to the following production: + * + * boolean => MATHRM LBRACE (TRUE | FALSE) RBRACE + * + * @returns The root node of an expression tree. + */ + nextBoolean(): math.MathNode { + this.nextToken(); // consume \mathrm + this.tryConsume("expected '{' after \\mathrm", TokenType.Lbrace); + const stateToken = this.tryConsume("expected 'True' or 'False' after '\\mathrm{' " + + '(no other expressions' + + 'are supported yet)', TokenType.True, TokenType.False); + this.tryConsume(`expected '}' after \\mathrm{${stateToken.lexeme}`, TokenType.Rbrace); + return createMathJSNode(stateToken); + } + + /** + * Consume the next undefined according to the following production: + * + * undefined => MATHBB LBRACE UNDEFINED RBRACE + * + * @returns The root node of an expression tree. + */ + nextUndefined(): math.MathNode { + this.nextToken(); // consume \mathrm + this.tryConsume("expected '{' after \\mathbf", TokenType.Lbrace); + const stateToken = this.tryConsume("expected '?' after '\\mathbf{' " + + '(no other expressions' + + 'are supported yet)', TokenType.Undefined); + this.tryConsume("expected '}' after \\mathbf{undefined", TokenType.Rbrace); + return createMathJSNode(stateToken); + } + /** * Consume the next matrix environnment according to the following production: * diff --git a/src/tokenizeTex.ts b/src/tokenizeTex.ts index 38395c1..d168c52 100644 --- a/src/tokenizeTex.ts +++ b/src/tokenizeTex.ts @@ -1,4 +1,4 @@ -import Token, { TokenType, lexemeToType } from './Token'; +import Token, { TokenType, lexemeToType, lexemeToSymbol } from './Token'; function isWhitespace(c: string) { return c.trim() === ''; @@ -103,6 +103,9 @@ export default function tokenizeTex(texStr: string) { } else { lexeme = `\\${command}`; type = lexemeToType[lexeme]; + if (type == undefined && lexeme in lexemeToSymbol) { + type = TokenType.Symbol; + } if (type === undefined) { throw new LexError(`unknown command "${lexeme}"`, i); } From a8175be1ddf765521f2f5fa26e0f10b31d64a1dd Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:50:08 -0400 Subject: [PATCH 05/10] Add partial equality support --- src/Token.ts | 28 ++++++++++++ src/parseTokens.ts | 104 ++++++++++++++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/Token.ts b/src/Token.ts index 278d9dd..4466e89 100644 --- a/src/Token.ts +++ b/src/Token.ts @@ -2,7 +2,13 @@ export const enum TokenType { Number, Variable, Symbol, + Colon, Equals, + Notequals, + Less, + Greater, + Lessequal, + Greaterequal, Plus, Minus, Star, @@ -60,7 +66,16 @@ export const enum TokenType { } export const lexemeToType: { [key: string]: TokenType } = { + ':': TokenType.Colon, '=': TokenType.Equals, + '\\ne': TokenType.Notequals, + '\\neq': TokenType.Notequals, + '<': TokenType.Less, + '>': TokenType.Greater, + '\\le': TokenType.Lessequal, + '\\leq': TokenType.Lessequal, + '\\ge': TokenType.Greaterequal, + '\\geq': TokenType.Greaterequal, '+': TokenType.Plus, '-': TokenType.Minus, '*': TokenType.Star, @@ -173,6 +188,13 @@ export const lexemeToSymbol: { [key: string]: string } = { '\\psi': 'psi', '\\Omega': 'Omega', '\\omega': 'omega', + // Comparisons + '\\ne': '!=', + '\\neq': '!=', + '\\le': '<=', + '\\leq': '<=', + '\\ge': '>=', + '\\geq': '>=', // Other '\\i': 'i', '\\infty': 'Infinity', @@ -187,6 +209,12 @@ export const lexemeToSymbol: { [key: string]: string } = { * or of a function to be defined in scope (i.e. in the argument to math.evaluate()) */ export const typeToOperation: { [key in TokenType]?: string } = { + [TokenType.Equals]: 'equal', + [TokenType.Notequals]: 'unequal', + [TokenType.Less]: 'smaller', + [TokenType.Greater]: 'larger', + [TokenType.Lessequal]: 'smallerEq', + [TokenType.Greaterequal]: 'largerEq', [TokenType.Plus]: 'add', [TokenType.Minus]: 'subtract', [TokenType.Star]: 'multiply', diff --git a/src/parseTokens.ts b/src/parseTokens.ts index f780b94..9cea863 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -11,6 +11,12 @@ import Token, { function createMathJSNode(token: Token, children: math.MathNode[] = []): math.MathNode { let fn = typeToOperation[token.type]; switch (token.type) { + case TokenType.Equals: + return new math.OperatorNode('==', fn as any, children); + case TokenType.Notequals: + case TokenType.Lessequal: + case TokenType.Greaterequal: + return new math.OperatorNode(lexemeToSymbol[token.lexeme] as any, fn as any, children); case TokenType.Times: return new math.FunctionNode('cross', children); case TokenType.Minus: @@ -21,6 +27,8 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Star: case TokenType.Frac: case TokenType.Slash: + case TokenType.Less: + case TokenType.Greater: return new math.OperatorNode(token.lexeme as any, fn as any, children); case TokenType.Caret: if (children.length < 2) { @@ -59,7 +67,7 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Norm: case TokenType.Inv: return new math.FunctionNode(fn, children); - case TokenType.Equals: + case TokenType.Colon: return new math.AssignmentNode(children[0] as math.SymbolNode, children[1]); case TokenType.Variable: return new math.SymbolNode(token.lexeme); @@ -137,8 +145,10 @@ class Parser { /** * A recursive descent parser for TeX math. The following context-free grammar is used: * + * comp => expr ((EQUALS | NOTEQUALS | LESS | LESSEQUAL | GREATER | GREATEREQUAL) expr)* + * | VARIABLE EQUALS EQUALS comp + * * expr = term ((PLUS | MINUS) term)* - * | VARIABLE EQUALS expr * * term = factor ((STAR factor | primary))* //primary and factor must both not be numbers * @@ -153,23 +163,23 @@ class Parser { * | NUMBER * | VARIABLE * - * grouping = LEFT LPAREN expr RIGHT RPAREN - * | LPAREN expr RPAREN - * | LBRACE expr RBRACE - * | LEFT BAR expr RIGHT BAR - * | BAR expr BAR + * grouping = LEFT LPAREN comp RIGHT RPAREN + * | LPAREN comp RPAREN + * | LBRACE comp RBRACE + * | LEFT BAR comp RIGHT BAR + * | BAR comp BAR * * environnment = matrix * - * frac = FRAC LBRACE expr RBRACE LBRACE expr RBRACE + * frac = FRAC LBRACE comp RBRACE LBRACE comp RBRACE * - * matrix = BEGIN LBRACE MATRIX RBRACE ((expr)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE + * matrix = BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE * * function = (SQRT | SIN | COS | TAN | ...) argument * | OPNAME LBRACE customfunc RBRACE argument * * argument = grouping - * | expr + * | primary * * In general, each production is represented by one method (e.g. nextFactor(), nextPower()...) * @@ -220,22 +230,48 @@ class Parser { /** * Consume the next expression in the token stream according to the following production: * - * expr => term ((PLUS | MINUS) term)* - * | VARIABLE EQUALS expr + * comp => expr ((EQUALS | NOTEQUALS | LESS | LESSEQUAL | GREATER | GREATEREQUAL) expr)* + * | VARIABLE EQUALS EQUALS comp * @returns Returns the root node of an expression tree. */ - nextExpression(): math.MathNode { - let leftTerm = this.nextTerm(); - // VARIABLE EQUALS expr - if (this.match(TokenType.Equals)) { - if (!math.isSymbolNode(leftTerm)) { + nextComparison(): math.MathNode { + let leftExpr = this.nextExpression(); + // VARIABLE EQUALS comp + if (this.match(TokenType.Colon)) { + if (!math.isSymbolNode(leftExpr)) { throw new ParseError('expected variable (SymbolNode) on left hand of assignment', this.previousToken()); } - const equals = this.nextToken(); + const colon = this.nextToken(); + this.tryConsume("Expected '=' after ':'", TokenType.Equals); + const rightComp = this.nextComparison(); + return createMathJSNode(colon, [leftExpr, rightComp]); + } + + // expr ((EQUALS | NOTEQUALS | LESS | LESSEQUAL | GREATER | GREATEREQUAL) expr)* + + if ( + this.match( + TokenType.Equals, TokenType.Notequals, TokenType.Less, + TokenType.Lessequal, TokenType.Greater, TokenType.Greaterequal, + ) + ) { + // TODO: Convert this to allow chained comparisons (can't be directly done with while loop) + const operator = this.nextToken(); const rightExpr = this.nextExpression(); - return createMathJSNode(equals, [leftTerm, rightExpr]); + leftExpr = createMathJSNode(operator, [leftExpr, rightExpr]); } + return leftExpr; + } + + /** + * Consume the next expression in the token stream according to the following production: + * + * expr => term ((PLUS | MINUS) term)* + * @returns Returns the root node of an expression tree. + */ + nextExpression(): math.MathNode { + let leftTerm = this.nextTerm(); // term ((PLUS | MINUS) term)* while (this.match(TokenType.Plus, TokenType.Minus)) { @@ -447,12 +483,12 @@ class Parser { /** * Consume the next grouping according to the following production: * - * grouping = LEFT LPAREN expr RIGHT RPAREN - * | LPAREN expr RPAREN - * | LBRACE expr RBRACE - * | LEFT BAR expr RIGHT BAR - * | BAR expr BAR - * | expr + * grouping = LEFT LPAREN comp RIGHT RPAREN + * | LPAREN comp RPAREN + * | LBRACE comp RBRACE + * | LEFT BAR comp RIGHT BAR + * | BAR comp BAR + * | comp * * @returns The root node of an expression tree. */ @@ -480,7 +516,7 @@ class Parser { if (leftGrouping.type === TokenType.Lparen) { while (this.match(TokenType.Comma)) { this.nextToken(); // consume comma - children.push(this.nextExpression()); + children.push(this.nextComparison()); } } if (leftRight) { @@ -522,7 +558,7 @@ class Parser { * Consume the next group of arguments according to the following production: * * argument => grouping - * | expr + * | primary * * @returns The root node of an expression tree. */ @@ -545,23 +581,23 @@ class Parser { /** * Consume the next fraction according to the following production: * - * frac => FRAC LBRACE expr RBRACE LBRACE expr RBRACE + * frac => FRAC LBRACE comp RBRACE LBRACE comp RBRACE * * @returns The root node of an expression tree. */ nextFrac(): math.MathNode { const frac = this.nextToken(); this.tryConsume("expected '{' for the numerator in \\frac", TokenType.Lbrace); - const numerator = this.nextExpression(); + const numerator = this.nextComparison(); this.tryConsume("expected '}' for the numerator in \\frac", TokenType.Rbrace); let denominator; // {} is optional for the denominator of \frac if (this.match(TokenType.Lbrace)) { this.nextToken(); - denominator = this.nextExpression(); + denominator = this.nextComparison(); this.tryConsume("expected '}' for the denominator in \\frac", TokenType.Rbrace); } else { - denominator = this.nextExpression(); + denominator = this.nextComparison(); } return createMathJSNode(frac, [numerator, denominator]); } @@ -603,7 +639,7 @@ class Parser { /** * Consume the next matrix environnment according to the following production: * - * matrix => BEGIN LBRACE MATRIX RBRACE ((expr)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE + * matrix => BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE * * @returns The root node of an expression tree. */ @@ -618,7 +654,7 @@ class Parser { const rows = []; // parse matrix elements for (;;) { - const element = this.nextExpression(); + const element = this.nextComparison(); // '&' delimits columns; append 1 element to this row if (this.match(TokenType.Amp)) { this.nextToken(); @@ -683,5 +719,5 @@ class Parser { * @returns The root node of a MathJS expression tree. */ export default function parseTokens(tokens: Token[]): math.MathNode { - return (new Parser(tokens)).nextExpression(); + return (new Parser(tokens)).nextComparison(); } From 2cf8d9e5cc058a623a4e255c8be186eb7eab4e6c Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:50:08 -0400 Subject: [PATCH 06/10] Make operatorNode more flexible --- src/parseTokens.ts | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 9cea863..0fae3da 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -67,6 +67,8 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Norm: case TokenType.Inv: return new math.FunctionNode(fn, children); + case TokenType.Opname: + return new math.FunctionNode(children[0], children.slice(1)); case TokenType.Colon: return new math.AssignmentNode(children[0] as math.SymbolNode, children[1]); case TokenType.Variable: @@ -95,6 +97,28 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma } } +function createString(tokens: Token[]): math.MathNode { + return new math.SymbolNode(tokens.map(token => { + switch (token.type) { + case TokenType.Variable: + case TokenType.Number: + case TokenType.E: + case TokenType.T: + case TokenType.Eigenvalues: + case TokenType.Eigenvectors: + case TokenType.Cross: + case TokenType.Proj: + case TokenType.Norm: + case TokenType.Inv: + return token.lexeme; + case TokenType.Symbol: + return lexemeToSymbol[token.lexeme]; + default: + throw new ParseError('unknown token type', token); + } + }).join('')); +} + // Maps each left grouping token to its corresponding right grouping token const rightGrouping: { [key in TokenType]?: TokenType } = { [TokenType.Lparen]: TokenType.Rparen, @@ -546,12 +570,24 @@ class Parser { * @returns The root node of an expression tree. */ nextCustomFunc(): math.MathNode { - this.nextToken(); // consume \\operatornmae + const opname = this.nextToken(); // consume \\operatorname this.tryConsume("expected '{' after \\operatorname", TokenType.Lbrace); - const customFunc = this.nextToken(); + const customFunc = [this.tryConsume( + 'expected a letter after \\operatorname{', + TokenType.Variable, TokenType.Symbol, TokenType.E, TokenType.T, + TokenType.Eigenvalues, TokenType.Eigenvectors, TokenType.Cross, + TokenType.Proj, TokenType.Norm, TokenType.Inv, + )]; + while (this.match( + TokenType.Variable, TokenType.Symbol, TokenType.Number, TokenType.E, + TokenType.T, TokenType.Eigenvalues, TokenType.Eigenvectors, + TokenType.Cross, TokenType.Proj, TokenType.Norm, TokenType.Inv, + )) { + customFunc.push(this.nextToken()); + } this.tryConsume("expected '}' after operator name", TokenType.Rbrace); const argument = this.nextArgument(); - return createMathJSNode(customFunc, argument); + return createMathJSNode(opname, [createString(customFunc), ...argument]); } /** From cad93b23ad32677d2a6efcb24fc286ba8249a5fa Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:50:08 -0400 Subject: [PATCH 07/10] Allow all operators to have lexeme symbols --- src/Token.ts | 3 +++ src/parseTokens.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Token.ts b/src/Token.ts index 4466e89..b3698d9 100644 --- a/src/Token.ts +++ b/src/Token.ts @@ -195,6 +195,9 @@ export const lexemeToSymbol: { [key: string]: string } = { '\\leq': '<=', '\\ge': '>=', '\\geq': '>=', + // Operators + '\\frac': '/', + '\\cdot': '*', // Other '\\i': 'i', '\\infty': 'Infinity', diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 0fae3da..639c725 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -13,10 +13,6 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma switch (token.type) { case TokenType.Equals: return new math.OperatorNode('==', fn as any, children); - case TokenType.Notequals: - case TokenType.Lessequal: - case TokenType.Greaterequal: - return new math.OperatorNode(lexemeToSymbol[token.lexeme] as any, fn as any, children); case TokenType.Times: return new math.FunctionNode('cross', children); case TokenType.Minus: @@ -27,9 +23,16 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Star: case TokenType.Frac: case TokenType.Slash: + case TokenType.Notequals: case TokenType.Less: + case TokenType.Lessequal: case TokenType.Greater: - return new math.OperatorNode(token.lexeme as any, fn as any, children); + case TokenType.Greaterequal: + return new math.OperatorNode( + (lexemeToSymbol[token.lexeme] ?? token.lexeme) as any, + fn as any, + children, + ); case TokenType.Caret: if (children.length < 2) { throw new ParseError('Expected two children for ^ operator', token); From cba273577a80d46268462205ba963d406a9b9481 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:50:08 -0400 Subject: [PATCH 08/10] Improve string parser structure --- src/parseTokens.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 639c725..eaaf4aa 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -100,7 +100,7 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma } } -function createString(tokens: Token[]): math.MathNode { +function createMathJSString(tokens: Token[]): math.MathNode { return new math.SymbolNode(tokens.map(token => { switch (token.type) { case TokenType.Variable: @@ -575,7 +575,14 @@ class Parser { nextCustomFunc(): math.MathNode { const opname = this.nextToken(); // consume \\operatorname this.tryConsume("expected '{' after \\operatorname", TokenType.Lbrace); - const customFunc = [this.tryConsume( + const customFunc = this.nextString(); + this.tryConsume("expected '}' after operator name", TokenType.Rbrace); + const argument = this.nextArgument(); + return createMathJSNode(opname, [customFunc, ...argument]); + } + + nextString() { + const string = [this.tryConsume( 'expected a letter after \\operatorname{', TokenType.Variable, TokenType.Symbol, TokenType.E, TokenType.T, TokenType.Eigenvalues, TokenType.Eigenvectors, TokenType.Cross, @@ -586,11 +593,9 @@ class Parser { TokenType.T, TokenType.Eigenvalues, TokenType.Eigenvectors, TokenType.Cross, TokenType.Proj, TokenType.Norm, TokenType.Inv, )) { - customFunc.push(this.nextToken()); + string.push(this.nextToken()); } - this.tryConsume("expected '}' after operator name", TokenType.Rbrace); - const argument = this.nextArgument(); - return createMathJSNode(opname, [createString(customFunc), ...argument]); + return createMathJSString(string); } /** From b774a1bac1ece74e098ecdbffd60cfb6fc49b284 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Fri, 18 Jul 2025 19:50:08 -0400 Subject: [PATCH 09/10] Add custom base support to sqrt and log --- src/Token.ts | 14 ++++++++++ src/parseTokens.ts | 60 ++++++++++++++++++++++++++++++++++++++--- src/tokenizeTex.ts | 2 +- tests/functions.test.ts | 12 +++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/Token.ts b/src/Token.ts index b3698d9..304da83 100644 --- a/src/Token.ts +++ b/src/Token.ts @@ -21,6 +21,8 @@ export const enum TokenType { Rbrace, Lparen, Rparen, + Lbracket, + Rbracket, Bar, Amp, Dblbackslash, @@ -89,6 +91,8 @@ export const lexemeToType: { [key: string]: TokenType } = { '}': TokenType.Rbrace, '(': TokenType.Lparen, ')': TokenType.Rparen, + '[': TokenType.Lbracket, + ']': TokenType.Rbracket, '|': TokenType.Bar, '&': TokenType.Amp, True: TokenType.True, @@ -251,6 +255,16 @@ export const typeToOperation: { [key in TokenType]?: string } = { [TokenType.Inv]: 'inv', }; +/** + * A mapping from a token type to the operation it represents for multiple variables. + * The operation is the name of a function in the mathjs namespace, + * or of a function to be defined in scope (i.e. in the argument to math.evaluate()) + */ +export const typeToMultivarOperation: { [key in TokenType]?: string } = { + [TokenType.Sqrt]: 'nthRoot', + [TokenType.Log]: 'log', +}; + interface Token { lexeme: string; type: TokenType; diff --git a/src/parseTokens.ts b/src/parseTokens.ts index eaaf4aa..4e7f5ef 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -2,6 +2,7 @@ import math from './customMath'; import ParseError from './ParseError'; import Token, { TokenType, typeToOperation, lexemeToType, lexemeToSymbol, + typeToMultivarOperation, } from './Token'; /** @@ -69,6 +70,9 @@ function createMathJSNode(token: Token, children: math.MathNode[] = []): math.Ma case TokenType.Comp: case TokenType.Norm: case TokenType.Inv: + if (children.length > 1) { + fn = typeToMultivarOperation[token.type] ?? fn; + } return new math.FunctionNode(fn, children); case TokenType.Opname: return new math.FunctionNode(children[0], children.slice(1)); @@ -126,6 +130,7 @@ function createMathJSString(tokens: Token[]): math.MathNode { const rightGrouping: { [key in TokenType]?: TokenType } = { [TokenType.Lparen]: TokenType.Rparen, [TokenType.Lbrace]: TokenType.Rbrace, + [TokenType.Lbracket]: TokenType.Rbracket, [TokenType.Left]: TokenType.Right, [TokenType.Bar]: TokenType.Bar, }; @@ -186,6 +191,8 @@ class Parser { * primary = grouping * | environnment * | frac + * | sqrt + * | log * | function * | NUMBER * | VARIABLE @@ -202,7 +209,11 @@ class Parser { * * matrix = BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE * - * function = (SQRT | SIN | COS | TAN | ...) argument + * sqrt = SQRT (LBRACKET comp RBRACKET)? argument + * + * log = LOG (UNDERSCORE (primary))? argument + * + * function = (SIN | COS | TAN | ...) argument * | OPNAME LBRACE customfunc RBRACE argument * * argument = grouping @@ -435,6 +446,8 @@ class Parser { * primary => grouping * | environnment * | frac + * | sqrt + * | log * | function * | NUMBER * | VARIABLE @@ -464,7 +477,6 @@ class Parser { case TokenType.T: primary = createMathJSNode(this.nextToken()); break; - case TokenType.Sqrt: case TokenType.Sin: case TokenType.Cos: case TokenType.Tan: @@ -477,7 +489,6 @@ class Parser { case TokenType.Sinh: case TokenType.Cosh: case TokenType.Tanh: - case TokenType.Log: case TokenType.Ln: case TokenType.Det: primary = this.nextUnaryFunc(); @@ -488,6 +499,12 @@ class Parser { case TokenType.Frac: primary = this.nextFrac(); break; + case TokenType.Sqrt: + primary = this.nextSqrt(); + break; + case TokenType.Log: + primary = this.nextLog(); + break; case TokenType.Mathrm: // booleans are the only currently supported mathrm tag primary = this.nextBoolean(); @@ -646,6 +663,43 @@ class Parser { return createMathJSNode(frac, [numerator, denominator]); } + /** + * Consume the next root according to the following production: + * + * sqrt => SQRT (LBRACKET comp RBRACKET)? argument + * + * @returns The root node of an expression tree. + */ + nextSqrt(): math.MathNode { + const sqrt = this.nextToken(); + let degree: math.MathNode[] = []; + if (this.match(TokenType.Lbracket)) { + this.tryConsume("expected '[' for the degree in \\sqrt", TokenType.Lbracket); + degree = [this.nextComparison()]; + this.tryConsume("expected ']' for the degree in \\sqrt", TokenType.Rbracket); + } + const radicand = this.nextArgument(); + return createMathJSNode(sqrt, radicand.concat(degree)); + } + + /** + * Consume the next log according to the following production: + * + * log => LOG (UNDERSCORE (grouping | primary))? argument + * + * @returns The root node of an expression tree. + */ + nextLog(): math.MathNode { + const log = this.nextToken(); + let degree: math.MathNode[] = []; + if (this.match(TokenType.Underscore)) { + this.tryConsume("expected '_' for the degree in \\log", TokenType.Underscore); + degree = [this.nextPrimary()]; + } + const argument = this.nextArgument(); + return createMathJSNode(log, argument.concat(degree)); + } + /** * Consume the next boolean according to the following production: * diff --git a/src/tokenizeTex.ts b/src/tokenizeTex.ts index d168c52..1854791 100644 --- a/src/tokenizeTex.ts +++ b/src/tokenizeTex.ts @@ -103,7 +103,7 @@ export default function tokenizeTex(texStr: string) { } else { lexeme = `\\${command}`; type = lexemeToType[lexeme]; - if (type == undefined && lexeme in lexemeToSymbol) { + if (type === undefined && lexeme in lexemeToSymbol) { type = TokenType.Symbol; } if (type === undefined) { diff --git a/tests/functions.test.ts b/tests/functions.test.ts index 9380231..598d734 100644 --- a/tests/functions.test.ts +++ b/tests/functions.test.ts @@ -9,6 +9,10 @@ test('evaluates sqrt', () => { expect(evaluate('\\sqrt{25}')).toStrictEqual(5); }); +test('evaluates cbrt', () => { + expect(evaluate('\\sqrt[3]{125}')).toBeCloseTo(5, 8); // Floating point math means this is actually 4.99... +}); + test('evaluates sin', () => { expect(evaluate('\\sin{0.5 * \\pi}')).toStrictEqual(1); }); @@ -24,3 +28,11 @@ test('evaluates cosh', () => { test('evaluates tanh', () => { expect(evaluate('\\tanh{0}')).toStrictEqual(0); }); + +test('evaluates log', () => { + expect(evaluate('\\log{10}')).toStrictEqual(1); +}); + +test('evaluates log with a base', () => { + expect(evaluate('\\log_2{2}')).toStrictEqual(1); +}); From c74b72f66f0700674a89fe9954a919d60e2176e8 Mon Sep 17 00:00:00 2001 From: thatcomputerguy0101 Date: Sat, 19 Jul 2025 07:24:47 -0400 Subject: [PATCH 10/10] Documentation and test updates --- README.md | 80 +++++++++++++++++++++++++---------------- src/parseTokens.ts | 68 +++++++++++++++++------------------ tests/all.test.ts | 2 +- tests/functions.test.ts | 2 +- tests/operators.test.ts | 2 +- tests/symbols.test.ts | 24 +++++++++++-- 6 files changed, 109 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a060eed..72eaad3 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,18 @@ This library works well as a bridge between [MathQuill](http://mathquill.com/) a ## TeX Features * Common operators available in TeX math mode: `+`, `-`, `*`, `^`, `/`, `\cdot`, `||` (absolute value), `\times` (cross product) +* Comparison operators: `=`, `\ne`/`\neq`, `<`, `>`, `\le`/`\leq`, `\ge`/`\geq` +* Assignment operator: `:=` * Basic functions: `\sqrt`, `\frac`, `\sin`, `\cos`, `\tan`, `\csc`, `\sec`, `\cot`, `\arcsin`, `\arccos`, `\arctan`, `\log`, `\ln`, `\det` -* Custom functions implemented with MathJS: `eigenvectors`, `eigenvalues`, `cross`, `proj`, `comp`, `norm`, `inv` - * Since these are custom functions, they should be formatted as `\operatorname{function}` in TeX. -* Constants: `\pi`, `e` +* Functions with custom bases: `\sqrt[n]`, `\log_n` +* Custom functions implemented with MathJS: `eigenvectors`, `eigenvalues`, `cross`, `proj`, `comp`, `norm`, `inv`, ... + * Since these are custom functions, they should be formatted as `\operatorname{function}` in TeX. +* Constants: `\pi`, `e`, `\i`, `{True}`, `{False}`, `{?}` (undefined), `\infty` * Environments: `matrix` -* Variables - * `^T` is interpreted as the transpose operation +* Variables (including greek symbols): `x`, `y`, `a`, `\alpha`, `\theta`, ... + * `^T` is interpreted as the transpose operation + * Non-latin symbols are converted to their English spellings (`\alpha` in TeX becomes the MathJS symbol `alpha`) +* Variable subscripts: `a_b`, `c_{max}` ## Browser Support @@ -23,7 +28,7 @@ Any browser with ES6 support. Install with NPM: -``` +```bash npm install tex-math-parser ``` @@ -38,6 +43,7 @@ or link to it from a CDN: Given the following TeX source string: ![Example TeX](docs/imgs/example_tex.png) + ```latex \begin{bmatrix}1&3\\2&4\end{bmatrix}\begin{bmatrix}-5\\-6\end{bmatrix}+\left|\sqrt{7}-\sqrt{8}\right|^{\frac{9}{10}}\begin{bmatrix}\cos\left(\frac{\pi}{6}\right)\\\sin\left(\frac{\pi}{6}\right)\end{bmatrix} ``` @@ -59,12 +65,14 @@ console.log(texAnswer); // \begin{bmatrix}-22.812481734548864\\-33.89173627896382\\\end{bmatrix} ``` -Parse the string and get a [a MathJS expression tree](https://mathjs.org/docs/expressions/expression_trees.html): +Parse the string and get [a MathJS expression tree](https://mathjs.org/docs/expressions/expression_trees.html): + ```javascript const mathJSTree = parseTex(escapedTex); ``` ### Variables + If the TeX string contains variables, the value of the variables must be supplied when evaluating. ![Example TeX with variables](docs/imgs/example_tex_variables.png) @@ -79,7 +87,7 @@ console.log(answer); // 1 `evaluateTex(texStr: string, scope?: Object)` -Evaluate a TeX string, replacing any variable occurences with their values in `scope`. The answer is returned as a TeX string. +Evaluate a TeX string, replacing any variable occurrences with their values in `scope`. The answer is returned as a TeX string. `parseTex(texStr: string)` @@ -87,7 +95,7 @@ Convert a TeX string into [a MathJS expression tree](https://mathjs.org/docs/exp ## Contributing -Please feel free to make a PR and add any features, add unit tests, or refactor any of the code. Both `tokenizeTex` and the `Parser` are quite messy and could really use a clean-up (maybe someday I'll get around to it...). +Please feel free to make a PR and add any features, add unit tests, or refactor any of the code. Both `tokenizeTex` and the `Parser` are quite messy and could really use a clean-up (maybe someday I'll get around to it...). Run `pnpm test` to run some unit tests and make sure they're passing! @@ -99,36 +107,48 @@ TODO: include better documentation on how to do this `parseTex` first lexes the TeX string into tokens, which are then passed to the parser to create the expression tree. A context-free grammar for the simplified version of TeX math used by the parser is as follows: -``` -expr = term ((PLUS | MINUS) term)* +```text +comp => expr ((EQUALS | NOTEQUALS | LESS | LESSEQUAL | GREATER | GREATEREQUAL) expr)* + | VARIABLE EQUALS EQUALS comp -term = factor ((CDOT factor | primary )* // primary and factor must both not be NUMBERs +expr => term ((PLUS | MINUS) term)* -factor = MINUS? power +term => factor ((STAR factor | primary))* // primary and factor must both not be numbers -power = primary (CARET primary)* +factor => MINUS? power -primary = grouping - | environnment - | frac - | function - | NUMBER - | VARIABLE +power => primary (CARET primary)* -grouping = LEFT LPAREN expr RIGHT RPAREN - | LPAREN expr RPAREN - | LBRACE expr RBRACE - | LEFT BAR expr RIGHT BAR - | BAR expr BAR +primary => grouping + | environnment + | frac + | sqrt + | log + | function + | NUMBER + | VARIABLE -environnment = matrix +grouping => LEFT LPAREN comp RIGHT RPAREN + | LPAREN comp RPAREN + | LBRACE comp RBRACE + | LEFT BAR comp RIGHT BAR + | BAR comp BAR -frac = FRAC LBRACE expr RBRACE LBRACE expr RBRACE +environnment => matrix -function = (SQRT | SIN | COS | TAN ...) grouping +frac => FRAC LBRACE comp RBRACE LBRACE comp RBRACE -matrix = BEGIN LBRACE MATRIX RBRACE ((expr)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE +matrix => BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE + +sqrt => SQRT (LBRACKET comp RBRACKET)? argument + +log => LOG (UNDERSCORE (primary))? argument + +function => (SIN | COS | TAN | ...) argument + | OPNAME LBRACE customfunc RBRACE argument + +argument => grouping + | primary ``` As the grammar is not left-recursive, the parser was implemented as a recursive descent parser with each production being represented by a separate function. This keeps the parser easily extensible. - diff --git a/src/parseTokens.ts b/src/parseTokens.ts index 4e7f5ef..76073f7 100644 --- a/src/parseTokens.ts +++ b/src/parseTokens.ts @@ -180,44 +180,44 @@ class Parser { * comp => expr ((EQUALS | NOTEQUALS | LESS | LESSEQUAL | GREATER | GREATEREQUAL) expr)* * | VARIABLE EQUALS EQUALS comp * - * expr = term ((PLUS | MINUS) term)* + * expr => term ((PLUS | MINUS) term)* * - * term = factor ((STAR factor | primary))* //primary and factor must both not be numbers + * term => factor ((STAR factor | primary))* // primary and factor must both not be numbers * - * factor = MINUS? power + * factor => MINUS? power * - * power = primary (CARET primary)* + * power => primary (CARET primary)* * - * primary = grouping - * | environnment - * | frac - * | sqrt - * | log - * | function - * | NUMBER - * | VARIABLE + * primary => grouping + * | environnment + * | frac + * | sqrt + * | log + * | function + * | NUMBER + * | VARIABLE * - * grouping = LEFT LPAREN comp RIGHT RPAREN - * | LPAREN comp RPAREN - * | LBRACE comp RBRACE - * | LEFT BAR comp RIGHT BAR - * | BAR comp BAR + * grouping => LEFT LPAREN comp RIGHT RPAREN + * | LPAREN comp RPAREN + * | LBRACE comp RBRACE + * | LEFT BAR comp RIGHT BAR + * | BAR comp BAR * - * environnment = matrix + * environnment => matrix * - * frac = FRAC LBRACE comp RBRACE LBRACE comp RBRACE + * frac => FRAC LBRACE comp RBRACE LBRACE comp RBRACE * - * matrix = BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE + * matrix => BEGIN LBRACE MATRIX RBRACE ((comp)(AMP | DBLBACKSLASH))* END LBRACE MATRIX RBRACE * - * sqrt = SQRT (LBRACKET comp RBRACKET)? argument + * sqrt => SQRT (LBRACKET comp RBRACKET)? argument * - * log = LOG (UNDERSCORE (primary))? argument + * log => LOG (UNDERSCORE (primary))? argument * - * function = (SIN | COS | TAN | ...) argument - * | OPNAME LBRACE customfunc RBRACE argument + * function => (SIN | COS | TAN | ...) argument + * | OPNAME LBRACE customfunc RBRACE argument * - * argument = grouping - * | primary + * argument => grouping + * | primary * * In general, each production is represented by one method (e.g. nextFactor(), nextPower()...) * @@ -527,12 +527,12 @@ class Parser { /** * Consume the next grouping according to the following production: * - * grouping = LEFT LPAREN comp RIGHT RPAREN - * | LPAREN comp RPAREN - * | LBRACE comp RBRACE - * | LEFT BAR comp RIGHT BAR - * | BAR comp BAR - * | comp + * grouping => LEFT LPAREN comp RIGHT RPAREN + * | LPAREN comp RPAREN + * | LBRACE comp RBRACE + * | LEFT BAR comp RIGHT BAR + * | BAR comp BAR + * | comp * * @returns The root node of an expression tree. */ @@ -720,12 +720,12 @@ class Parser { /** * Consume the next undefined according to the following production: * - * undefined => MATHBB LBRACE UNDEFINED RBRACE + * undefined => MATHBF LBRACE UNDEFINED RBRACE * * @returns The root node of an expression tree. */ nextUndefined(): math.MathNode { - this.nextToken(); // consume \mathrm + this.nextToken(); // consume \mathbf this.tryConsume("expected '{' after \\mathbf", TokenType.Lbrace); const stateToken = this.tryConsume("expected '?' after '\\mathbf{' " + '(no other expressions' diff --git a/tests/all.test.ts b/tests/all.test.ts index 7e8f65a..f222681 100644 --- a/tests/all.test.ts +++ b/tests/all.test.ts @@ -1,4 +1,4 @@ -import { parseTex, evaluateTex, Scope } from '../src/index'; +import { evaluateTex, Scope } from '../src/index'; import { number, matrix, deepEqual } from 'mathjs'; function evaluate(texStr: string, scope?: Scope) { diff --git a/tests/functions.test.ts b/tests/functions.test.ts index 598d734..d910d2c 100644 --- a/tests/functions.test.ts +++ b/tests/functions.test.ts @@ -1,4 +1,4 @@ -import { parseTex, evaluateTex, Scope } from '../src/index'; +import { evaluateTex, Scope } from '../src/index'; import { number } from 'mathjs'; function evaluate(texStr: string, scope?: Scope) { diff --git a/tests/operators.test.ts b/tests/operators.test.ts index 50b1de3..019c5bd 100644 --- a/tests/operators.test.ts +++ b/tests/operators.test.ts @@ -1,4 +1,4 @@ -import { parseTex, evaluateTex, Scope } from '../src/index'; +import { evaluateTex, Scope } from '../src/index'; import { matrix, deepEqual } from 'mathjs'; function evaluate(texStr: string, scope?: Scope) { diff --git a/tests/symbols.test.ts b/tests/symbols.test.ts index d7f0874..dcf7a5d 100644 --- a/tests/symbols.test.ts +++ b/tests/symbols.test.ts @@ -39,7 +39,7 @@ describe('evaluates with symbol (single char)', () => { }); describe('evaluates with symbol (multiple chars)', () => { - test('aa, bbb, abcd', () => { + test('multi-character symbols: aa, bbb, abcd', () => { const aa = evaluate('aa', { aa: 1 }); const bbb = evaluate('bbb', { bbb: 1 }); const abcd = evaluate('abcd', { abcd: 1 }); @@ -49,7 +49,27 @@ describe('evaluates with symbol (multiple chars)', () => { expect(abcd).toStrictEqual(1); }); - test('addition with symbols: aa, bbb', () => { + test('addition with multi-character symbols: aa, bbb', () => { expect(evaluate('aa + bbb', { aa: 1, bbb: 2 })).toStrictEqual(3); }); }); + +describe('evaluates with symbol (compound parsing)', () => { + test('subscripts: a_b, c_{de}', () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const a_b = evaluate('a_b', { a: [2, 3], b: 1 }); + expect(a_b).toStrictEqual(2); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const c_de = evaluate('c_{de}', { c: [2, 3], de: 1 }); + expect(c_de).toStrictEqual(2); + }); + + test('special symbols: α, ∞', () => { + const alpha = evaluate('\\alpha', { alpha: 1 }); + expect(alpha).toStrictEqual(1); + + const infinity = evaluate('\\infty'); + expect(infinity).toStrictEqual(Number.POSITIVE_INFINITY); + }); +});