import { LatexParseError, type AccentKind, type MathNode, type MathVariant, type MatrixEnvironment, type ParseOptions, } from "./types" import { assertNestingDepth, assertSourceLength, DEFAULT_MAX_NESTING_DEPTH, DEFAULT_MAX_SOURCE_LENGTH, resolvePositiveInteger, } from "./limits" import { delimiterTable, largeOperators, namedOperators, spacingCommands, symbolTable } from "./symbols" const matrixEnvironments: MatrixEnvironment[] = [ "matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "cases", "aligned", "align", "gathered", "gather", "smallmatrix", "array", ] const accents: Readonly> = { hat: "hat", widehat: "widehat", bar: "bar", overline: "overline", underline: "underline", vec: "vec", tilde: "tilde", widetilde: "tilde", dot: "dot", ddot: "ddot", } const variants: Readonly> = { mathrm: "normal", textrm: "normal", mathnormal: "normal", mathbf: "bold", boldsymbol: "bold", bm: "bold", mathit: "italic", mathsf: "sans", mathtt: "monospace", mathbb: "double-struck", mathcal: "script", mathscr: "script", mathfrak: "fraktur", } export function parseLatex(source: string, options: ParseOptions = {}): MathNode { const expanded = expandLatexMacros(source, options) const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth") return new Parser(expanded, options.strict ?? false, maxDepth).parse() } export function expandLatexMacros(source: string, options: ParseOptions = {}): string { const maxSourceLength = resolvePositiveInteger(options.maxSourceLength, DEFAULT_MAX_SOURCE_LENGTH, "maxSourceLength") const maxExpandedLength = resolvePositiveInteger(options.maxExpandedLength, maxSourceLength, "maxExpandedLength") const maxExpand = resolvePositiveInteger(options.maxExpand, 100, "maxExpand") const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth") assertSourceLength(source, maxSourceLength) assertNestingDepth(source, maxDepth) const expanded = expandMacros(source, options.macros, maxExpand, maxExpandedLength) if (expanded !== source) assertNestingDepth(expanded, maxDepth) return expanded } function expandMacros( source: string, macros: ParseOptions["macros"], maxExpand: number, maxExpandedLength: number, ): string { assertSourceLength(source, maxExpandedLength, "Expanded LaTeX source") if (!macros || Object.keys(macros).length === 0) return source let result = source for (let pass = 0; pass < maxExpand; pass++) { let changed = false let cursor = 0 let outputLength = 0 const output: string[] = [] const commands = /\\[A-Za-z@]+|\\./g for (const match of result.matchAll(commands)) { const command = match[0] const index = match.index const replacement = macros[command] ?? macros[command.slice(1)] if (replacement === undefined) continue if (typeof replacement !== "string") { throw new TypeError(`Macro ${command} must expand to a string`) } appendWithinLimit(output, result.slice(cursor, index), outputLength, maxExpandedLength) outputLength += index - cursor appendWithinLimit(output, replacement, outputLength, maxExpandedLength) outputLength += replacement.length cursor = index + command.length changed = true } if (!changed) return result appendWithinLimit(output, result.slice(cursor), outputLength, maxExpandedLength) result = output.join("") } throw new LatexParseError(`Macro expansion exceeded ${maxExpand} passes`, 0) } function appendWithinLimit(output: string[], value: string, currentLength: number, maximum: number): void { if (currentLength + value.length > maximum) { throw new LatexParseError(`Expanded LaTeX source exceeds the ${maximum}-character limit`, maximum) } output.push(value) } class Parser { private position = 0 private depth = 0 constructor( private readonly source: string, private readonly strict: boolean, private readonly maxDepth: number, ) {} public parse(): MathNode { const body = this.parseRow() this.skipMathWhitespace() if (!this.done()) this.fail(`Unexpected "${this.peek()}"`) return row(body) } private parseRow(stop?: () => boolean): MathNode[] { const body: MathNode[] = [] while (!this.done()) { this.skipMathWhitespace() if (this.done() || stop?.()) break const current = this.peek() if (current === "}") break if (current === "^" || current === "_") { this.position++ const script = this.parseArgument() const previous = body.pop() ?? { type: "row", body: [] } const existing = previous.type === "scripts" ? previous : { type: "scripts" as const, base: previous } if (current === "^") existing.superscript = script else existing.subscript = script body.push(existing) continue } if (current === "\\" && this.applyLimitsModifier(body)) continue body.push(this.parseAtom()) } return body } private parseAtom(): MathNode { this.depth++ if (this.depth > this.maxDepth) { this.depth-- this.fail(`LaTeX nesting exceeds the ${this.maxDepth}-level limit`) } try { return this.parseAtomInner() } finally { this.depth-- } } private parseAtomInner(): MathNode { const current = this.peek() if (current === "{") return this.parseGroup() if (current === "\\") return this.parseCommand() if (current === "~") { this.position++ return { type: "space", width: 1 } } this.position++ return { type: "symbol", value: current, role: inferRole(current) } } private parseCommand(): MathNode { const start = this.position const command = this.readCommand() if (command === "\\") return { type: "row", body: [] } if (command === "begin") return this.parseEnvironment() if (command === "frac" || command === "dfrac" || command === "tfrac" || command === "cfrac") { this.skipMathWhitespace() const alignment = command === "cfrac" && this.peek() === "[" ? /^\[([lr]?)\]/.exec(this.source.slice(this.position)) : undefined if (alignment === null) this.fail("Unsupported \\cfrac alignment; expected [l], [r], or []") if (alignment) this.position += alignment[0].length return { type: "fraction", numerator: this.parseArgument(), denominator: this.parseArgument(), bar: true, ...(alignment?.[1] ? { numeratorAlign: alignment[1] === "l" ? "left" : "right" } : {}), } } if (command === "binom" || command === "dbinom" || command === "tbinom") { const fraction: MathNode = { type: "fraction", numerator: this.parseArgument(), denominator: this.parseArgument(), bar: false, } return { type: "delimited", left: "(", body: fraction, right: ")" } } if (command === "sqrt") { const index = this.parseOptionalArgument() const result: MathNode = { type: "root", body: this.parseArgument() } if (index) result.index = index return result } if (command === "left") return this.parseLeftRight() if (command === "middle") return { type: "symbol", value: this.readDelimiter() } if (command === "right") { this.position = start this.fail("Unexpected \\right") } if (command in accents) { return { type: "accent", accent: accents[command], body: this.parseArgument() } } if (command in variants) { return { type: "variant", variant: variants[command], body: command === "textrm" ? { type: "text", value: this.readTextGroup() } : this.parseArgument(), } } if (command === "text" || command === "mbox") return { type: "text", value: this.readTextGroup() } if (command === "operatorname") { const limits = this.peek() === "*" if (limits) this.position++ return { type: "operator", value: this.readTextGroup(), limits } } if (command === "overset" || command === "stackrel") { const over = this.parseArgument() const base = this.parseArgument() return { type: "overunder", base, over } } if (command === "underset") { const under = this.parseArgument() const base = this.parseArgument() return { type: "overunder", base, under } } if (command === "overbrace" || command === "underbrace") { return { type: "brace", body: this.parseArgument(), position: command === "overbrace" ? "over" : "under" } } if (command === "textcolor") { const color = this.readRawGroup() return { type: "color", color, body: this.parseArgument() } } if (command === "color") { const color = this.readRawGroup() return { type: "color", color, body: row(this.parseRow()) } } if (command === "not") { const target = this.parseAtom() if (target.type === "symbol") return { ...target, value: negateSymbol(target.value) } return { type: "row", body: [{ type: "symbol", value: "¬" }, target] } } if (command === "pmod") { return { type: "row", body: [ { type: "space", width: 1 }, { type: "text", value: "(mod " }, this.parseArgument(), { type: "text", value: ")" }, ], } } if (command === "mod" || command === "bmod") return { type: "operator", value: "mod", limits: false } if (command === "displaylines") { this.skipMathWhitespace() this.expect("{") return this.parseMatrix("gathered", "}") } if ( command === "limits" || command === "nolimits" || command === "displaystyle" || command === "textstyle" || command === "scriptstyle" || command === "scriptscriptstyle" ) { return { type: "row", body: [] } } if (/^(?:big|Big|bigg|Bigg)[lrm]?$/.test(command)) { return { type: "symbol", value: this.readDelimiter() } } if (command in spacingCommands) return { type: "space", width: spacingCommands[command] } if (command in symbolTable) { const symbol = symbolTable[command] return { type: "symbol", value: symbol.value, ...(symbol.role ? { role: symbol.role } : {}) } } if (command in largeOperators) { return { type: "operator", value: largeOperators[command], limits: !command.includes("int") } } if (namedOperators.has(command)) { return { type: "operator", value: command, limits: command.startsWith("lim") || command === "min" || command === "max", } } if (command === "backslash") return { type: "symbol", value: "\\" } const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command] if (delimiter !== undefined) return { type: "symbol", value: delimiter } if (command === "{" || command === "}") return { type: "symbol", value: command } if (command === "%" || command === "#" || command === "$" || command === "&" || command === "_") { return { type: "symbol", value: command } } if (this.strict) this.fail(`Unsupported command \\${command}`, start) return { type: "text", value: `\\${command}` } } private parseEnvironment(): MathNode { const rawEnvironment = this.readRawGroup() const unstarredEnvironment = rawEnvironment.endsWith("*") ? rawEnvironment.slice(0, -1) : rawEnvironment const environment = matrixEnvironments.find((name) => name === unstarredEnvironment) if (!environment) { if (this.strict) this.fail(`Unsupported environment ${unstarredEnvironment}`) const content = this.readUntilEnd(rawEnvironment) return { type: "text", value: content } } const columns = environment === "array" ? this.readRawGroup().replace(/\s/g, "") : undefined if (columns !== undefined && (!/^[lcr|]+$/.test(columns) || !/[lcr]/.test(columns))) { this.fail("Unsupported array columns; expected l, c, r, and |") } return this.parseMatrix(environment, `\\end{${rawEnvironment}}`, columns) } private parseMatrix(environment: MatrixEnvironment, end: string, columns?: string): MathNode { const rows: MathNode[][] = [] let cells: MathNode[] = [] while (!this.done()) { this.skipMathWhitespace() if (this.source.startsWith(end, this.position) && cells.length === 0) break const cellStart = this.position const cell = row( this.parseRow( () => this.peek() === "&" || this.source.startsWith("\\\\", this.position) || this.source.startsWith(end, this.position), ), ) cells.push(cell) this.skipMathWhitespace() if (this.peek() === "&") { this.position++ continue } if (this.source.startsWith("\\\\", this.position)) { this.position += 2 this.consumeOptionalBracket() rows.push(cells) cells = [] continue } if (this.source.startsWith(end, this.position)) break // Empty cells are valid only when a cell, row, or closing delimiter advances the parser. if (this.position === cellStart) this.fail(`Unexpected "${this.peek()}" in ${environment}`) } if (!this.source.startsWith(end, this.position)) this.fail(`Missing ${end}`) this.expect(end) if (cells.length > 0 || rows.length === 0) rows.push(cells) return { type: "matrix", rows, environment, ...(columns !== undefined ? { columns } : {}) } } private parseLeftRight(): MathNode { const left = this.readDelimiter() const atRight = () => this.source.startsWith("\\right", this.position) && !/[A-Za-z@]/.test(this.source[this.position + 6] ?? "") const body = row(this.parseRow(atRight)) if (!atRight()) this.fail("Missing \\right") this.readCommand() const right = this.readDelimiter() return { type: "delimited", left, body, right } } private parseArgument(): MathNode { this.skipMathWhitespace() if (this.peek() === "{") return this.parseGroup() if (this.done()) this.fail("Expected an argument") return this.parseAtom() } private parseGroup(): MathNode { this.expect("{") const body = row(this.parseRow()) this.expect("}") return body } private parseOptionalArgument(): MathNode | undefined { this.skipMathWhitespace() if (this.peek() !== "[") return undefined this.position++ const body = row(this.parseRow(() => this.peek() === "]")) this.expect("]") return body } private consumeOptionalBracket(): void { this.skipMathWhitespace() if (this.peek() !== "[") return let depth = 0 while (!this.done()) { const char = this.source[this.position++] if (char === "[") depth++ if (char === "]" && --depth === 0) return } } private readDelimiter(): string { this.skipMathWhitespace() if (this.done()) this.fail("Expected a delimiter") const start = this.position if (this.peek() === "\\") { const command = this.readCommand() const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command] if (delimiter !== undefined) return delimiter if (this.strict) this.fail(`Unsupported delimiter \\${command}`, start) return `\\${command}` } const token = this.source[this.position++] return delimiterTable[token] ?? token } private readCommand(): string { this.expect("\\") if (this.done()) return "\\" const next = this.peek() if (!/[A-Za-z@]/.test(next)) { this.position++ return next } const start = this.position while (!this.done() && /[A-Za-z@]/.test(this.peek())) this.position++ const command = this.source.slice(start, this.position) if (this.peek() === " ") this.position++ return command } private readRawGroup(): string { this.skipMathWhitespace() this.expect("{") const start = this.position let depth = 1 while (!this.done()) { const char = this.source[this.position++] const escaped = (char === "{" || char === "}") && this.isEscaped(this.position - 1) if (char === "{" && !escaped) depth++ if (char === "}" && !escaped && --depth === 0) return this.source.slice(start, this.position - 1) } return this.fail("Unterminated group", start) } private applyLimitsModifier(body: MathNode[]): boolean { const match = /^\\(limits|nolimits)(?![A-Za-z@])/.exec(this.source.slice(this.position)) if (!match) return false this.position += match[0].length const target = body.at(-1) const operator = target?.type === "operator" ? target : target?.type === "scripts" && target.base.type === "operator" ? target.base : undefined if (operator) operator.limits = match[1] === "limits" return true } private isEscaped(index: number): boolean { let slashCount = 0 for (let cursor = index - 1; cursor >= 0 && this.source[cursor] === "\\"; cursor--) slashCount++ return slashCount % 2 === 1 } private readTextGroup(): string { return this.readRawGroup() .replace(/\\([A-Za-z@]+|.)/g, (match, command: string) => { if ("{}%#$&_ ".includes(command)) return command if (command === "textbackslash") return "\\" if (command === "!") return "" if (command in spacingCommands) return " ".repeat(Math.max(1, spacingCommands[command])) return match }) .replace(/~/g, " ") } private readUntilEnd(environment: string): string { const marker = `\\end{${environment}}` const end = this.source.indexOf(marker, this.position) if (end < 0) this.fail(`Missing ${marker}`) const content = this.source.slice(this.position, end) this.position = end + marker.length return content } private skipMathWhitespace(): void { while (!this.done()) { if (/\s/.test(this.peek())) { this.position++ continue } if (this.peek() === "%") { while (!this.done() && this.peek() !== "\n") this.position++ continue } break } } private expect(value: string): void { if (!this.source.startsWith(value, this.position)) this.fail(`Expected "${value}"`) this.position += value.length } private peek(): string { return this.source[this.position] ?? "" } private done(): boolean { return this.position >= this.source.length } private fail(message: string, position = this.position): never { throw new LatexParseError(message, position) } } function row(body: MathNode[]): MathNode { if (body.length === 1) return body[0] return { type: "row", body } } function inferRole(value: string): "binary" | "relation" | "punctuation" | "opening" | "closing" | "ordinary" { if ("+-*/×÷±∓".includes(value)) return "binary" if ("=<>≤≥≠≈∈∉⊂⊃".includes(value)) return "relation" if (",;:".includes(value)) return "punctuation" if ("([{".includes(value)) return "opening" if (")]}".includes(value)) return "closing" return "ordinary" } function negateSymbol(value: string): string { const negated: Record = { "=": "≠", "∈": "∉", "∋": "∌", "≡": "≢", "≈": "≉", "∼": "≁", "<": "≮", ">": "≯", "≤": "≰", "≥": "≱", "⊂": "⊄", "⊃": "⊅", "⊆": "⊈", "⊇": "⊉", "∣": "∤", "∥": "∦", } return negated[value] ?? `${value}̸` }