diff --git a/bun.lock b/bun.lock index 44d1ed98ab9e..565c695873a0 100644 --- a/bun.lock +++ b/bun.lock @@ -553,6 +553,20 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/latex": { + "name": "@opencode-ai/latex", + "version": "0.0.0", + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opentui/core": "catalog:", + "string-width": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/merman": { "name": "@opencode-ai/merman", "version": "0.0.0", @@ -877,6 +891,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/latex": "workspace:*", "@opencode-ai/merman": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -2148,6 +2163,8 @@ "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"], + "@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], diff --git a/packages/latex/package.json b/packages/latex/package.json new file mode 100644 index 000000000000..f21ea473bba9 --- /dev/null +++ b/packages/latex/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/latex", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./markdown": "./src/markdown.ts", + "./plugin": "./src/plugin.ts" + }, + "scripts": { + "test": "bun test --timeout 30000 --only-failures", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opentui/core": "catalog:", + "string-width": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/latex/src/layout.test.ts b/packages/latex/src/layout.test.ts new file mode 100644 index 000000000000..19dec3592019 --- /dev/null +++ b/packages/latex/src/layout.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { layoutMath } from "./layout" +import { renderLatexToString } from "./render" + +const text = (value: string) => ({ type: "text" as const, value }) + +describe("structured math layout", () => { + test.each([ + String.raw`\sqrt{x}`, + String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, + String.raw`\underbrace{abcd}`, + String.raw`\overbrace{abcd}`, + String.raw`\sum`, + ])("empty scripts do not change geometry: %s", (source) => { + for (const scripts of ["^{}", "_{}", "^{}_{}"]) { + expect(renderLatexToString(source + scripts)).toBe(renderLatexToString(source)) + } + }) + + test("centers annotations over even-width brace junctions", () => { + expect(renderLatexToString(String.raw`\overbrace{abcd}^{n}`)).toBe([" n", "╭┴─╮", "abcd"].join("\n")) + expect(renderLatexToString(String.raw`\underbrace{abcd}_{n}`)).toBe(["abcd", "╰┬─╯", " n"].join("\n")) + }) + + test("raises powers above tall matrix delimiters", () => { + expect(renderLatexToString(String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}^2`)).toBe( + [" 2", "⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"), + ) + }) + + test("keeps piecewise values left-aligned", () => { + expect(renderLatexToString(String.raw`\begin{cases}x & x>0\\x^2+1 & x\le0\end{cases}`)).toBe( + ["⎧x x > 0", "⎨", "⎩x² + 1 x ≤ 0"].join("\n"), + ) + }) + + test("honors array column alignment and continuous separators", () => { + expect( + layoutMath({ + type: "matrix", + environment: "array", + columns: "l|r", + rows: [ + [text("a"), text("wide")], + [text("long"), text("b")], + ], + }).toString(), + ).toBe(["a │ wide", " │", "long │ b"].join("\n")) + }) + + test("preserves edge rules and double array separators", () => { + expect( + layoutMath({ + type: "matrix", + environment: "array", + columns: "|l||r|", + rows: [ + [text("a"), text("b")], + [text("long"), text("c")], + ], + }).toString(), + ).toBe(["│ a ││ b │", "│ ││ │", "│ long ││ c │"].join("\n")) + }) + + test.each(["left", "right"] as const)("aligns continued-fraction numerators to the %s", (numeratorAlign) => { + const layout = layoutMath({ + type: "fraction", + numerator: text("1"), + denominator: text("12345"), + bar: true, + numeratorAlign, + }) + expect(layout.toString()).toBe([numeratorAlign === "left" ? " 1" : " 1", "───────", " 12345"].join("\n")) + }) + + test.each(["over", "under"] as const)("stretches %s braces and places annotations outside them", (position) => { + const layout = layoutMath({ + type: "scripts", + base: { type: "brace", body: text("a + b + c"), position }, + ...(position === "over" ? { superscript: text("n") } : { subscript: text("n") }), + }) + expect(layout.toString()).toBe( + (position === "over" ? [" n", "╭───┴───╮", "a + b + c"] : ["a + b + c", "╰───┬───╯", " n"]).join("\n"), + ) + expect(layout.baseline).toBe(position === "over" ? 2 : 0) + }) +}) diff --git a/packages/latex/src/layout.ts b/packages/latex/src/layout.ts new file mode 100644 index 000000000000..fed6fa03106b --- /dev/null +++ b/packages/latex/src/layout.ts @@ -0,0 +1,673 @@ +import type { MathCell, MathLayout, MathNode, MathStyle, MathVariant, RenderLatexOptions, SymbolRole } from "./types" + +interface Box { + width: number + height: number + baseline: number + cells: Array> +} + +interface LayoutContext { + displayMode: boolean + compactScripts: boolean + style?: MathStyle + variant?: MathVariant +} + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +const superscripts: Readonly> = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "+": "⁺", + "-": "⁻", + "=": "⁼", + "(": "⁽", + ")": "⁾", + n: "ⁿ", + i: "ⁱ", +} + +const subscripts: Readonly> = { + "0": "₀", + "1": "₁", + "2": "₂", + "3": "₃", + "4": "₄", + "5": "₅", + "6": "₆", + "7": "₇", + "8": "₈", + "9": "₉", + "+": "₊", + "-": "₋", + "=": "₌", + "(": "₍", + ")": "₎", + a: "ₐ", + e: "ₑ", + h: "ₕ", + i: "ᵢ", + j: "ⱼ", + k: "ₖ", + l: "ₗ", + m: "ₘ", + n: "ₙ", + o: "ₒ", + p: "ₚ", + r: "ᵣ", + s: "ₛ", + t: "ₜ", + u: "ᵤ", + v: "ᵥ", + x: "ₓ", +} + +export function layoutMath(node: MathNode, options: RenderLatexOptions = {}): MathLayout { + const context: LayoutContext = { + displayMode: options.displayMode ?? true, + compactScripts: options.compactScripts ?? true, + ...(options.color ? { style: { color: options.color } } : {}), + } + return asPublicLayout(layoutNode(node, context)) +} + +function layoutNode(node: MathNode, context: LayoutContext): Box { + switch (node.type) { + case "row": + return layoutRow(node.body, context) + case "symbol": + case "text": + case "operator": + return textBox(applyVariant(node.value, context.variant), context.style) + case "space": + return blank(node.width, 1, 0) + case "fraction": + return layoutFraction(node, context) + case "root": + return layoutRoot(node.body, node.index, context) + case "scripts": + return layoutScripts(node, context) + case "delimited": + return layoutDelimited(node.left, node.body, node.right, context) + case "matrix": + return layoutMatrix(node, context) + case "brace": + return layoutBrace(node, context) + case "accent": + return layoutAccent(node.accent, node.body, context) + case "variant": + return layoutNode(node.body, withVariant(context, node.variant)) + case "overunder": + return layoutOverUnder(node.base, node.over, node.under, context) + case "color": + return layoutNode(node.body, { ...context, style: { ...context.style, color: node.color } }) + } + throw new Error("Unsupported math node") +} + +function layoutRow(nodes: MathNode[], context: LayoutContext): Box { + if (nodes.length === 0) return blank(0, 1, 0) + + const boxes: Box[] = [] + let previousRole: SymbolRole | undefined + + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + const rawRole = nodeRole(node) + const role = normalizeBinaryRole(rawRole, previousRole, nextSignificantRole(nodes, index + 1)) + if (needsMathSpace(previousRole, role, boxes.length)) boxes.push(blank(1, 1, 0)) + boxes.push(layoutNode(node, context)) + if (node.type !== "space") previousRole = role ?? "ordinary" + } + + return hpack(boxes) +} + +function layoutFraction(node: Extract, context: LayoutContext): Box { + const numerator = layoutNode(node.numerator, context) + const denominator = layoutNode(node.denominator, context) + const width = Math.max(numerator.width, denominator.width) + 2 + // Barless fractions (binomials) still reserve an axis row so surrounding + // atoms and their stretching parentheses align between the two entries. + const gap = 1 + const height = numerator.height + denominator.height + gap + // TeX places a fraction's math axis on its rule (or the equivalent empty + // axis row for a binomial). Align neighbors there, not on the denominator. + const baseline = numerator.height + const result = blank(width, height, baseline) + + const numeratorX = + node.numeratorAlign === "left" + ? 1 + : node.numeratorAlign === "right" + ? width - numerator.width - 1 + : Math.floor((width - numerator.width) / 2) + overlay(result, numerator, numeratorX, 0) + if (node.bar) drawHorizontal(result, numerator.height, 0, width, "─", context.style) + overlay(result, denominator, Math.floor((width - denominator.width) / 2), numerator.height + gap) + return result +} + +function layoutRoot(bodyNode: MathNode, indexNode: MathNode | undefined, context: LayoutContext): Box { + const body = layoutNode(bodyNode, context) + const index = indexNode ? layoutNode(indexNode, context) : undefined + const indexWidth = index ? Math.max(0, index.width - 1) : 0 + // The index ends beside the overbar, never inside the hook or radicand. + const top = Math.max(0, (index?.height ?? 1) - 1) + const bodyX = indexWidth + 2 + const width = bodyX + body.width + const height = top + body.height + 1 + const baseline = top + body.baseline + 1 + const result = blank(width, height, baseline) + + setCell(result, bodyX - 1, top, "╭", context.style) + drawHorizontal(result, top, bodyX, body.width, "─", context.style) + for (let y = top + 1; y < height - 1; y++) setCell(result, bodyX - 1, y, "│", context.style) + setCell(result, bodyX - 2, height - 1, "╰", context.style) + setCell(result, bodyX - 1, height - 1, "╯", context.style) + overlay(result, body, bodyX, top + 1) + if (index) overlay(result, index, 0, 0) + return result +} + +function layoutScripts(node: Extract, context: LayoutContext): Box { + const superscriptNode = node.superscript && simpleNodeText(node.superscript) !== "" ? node.superscript : undefined + const subscriptNode = node.subscript && simpleNodeText(node.subscript) !== "" ? node.subscript : undefined + if (node.base.type === "brace" || (node.base.type === "operator" && node.base.limits && context.displayMode)) { + return layoutOverUnder(node.base, superscriptNode, subscriptNode, context) + } + + const base = layoutNode(node.base, context) + if (context.compactScripts && base.height === 1) { + const superscript = mapScript(superscriptNode ? simpleNodeText(superscriptNode) : "", superscripts) + const subscript = mapScript(subscriptNode ? simpleNodeText(subscriptNode) : "", subscripts) + if (superscript !== undefined && subscript !== undefined) { + return hpack([base, textBox(superscript + subscript, context.style)]) + } + } + + const superscript = superscriptNode ? layoutNode(superscriptNode, context) : undefined + const subscript = subscriptNode ? layoutNode(subscriptNode, context) : undefined + const scriptWidth = Math.max(superscript?.width ?? 0, subscript?.width ?? 0) + const topHeight = superscript?.height ?? 0 + const bottomHeight = subscript?.height ?? 0 + const width = base.width + scriptWidth + const height = topHeight + base.height + bottomHeight + const baseline = topHeight + base.baseline + const result = blank(width, height, baseline) + + overlay(result, base, 0, topHeight) + if (superscript) overlay(result, superscript, base.width, 0) + if (subscript) overlay(result, subscript, base.width, topHeight + base.height) + return result +} + +function layoutOverUnder( + baseNode: MathNode, + overNode: MathNode | undefined, + underNode: MathNode | undefined, + context: LayoutContext, +): Box { + const base = layoutNode(baseNode, context) + const over = overNode ? layoutNode(overNode, context) : undefined + const under = underNode ? layoutNode(underNode, context) : undefined + const width = Math.max(base.width, over?.width ?? 0, under?.width ?? 0) + const overHeight = over?.height ?? 0 + const height = overHeight + base.height + (under?.height ?? 0) + const baseline = overHeight + base.baseline + const result = blank(width, height, baseline) + + if (over) overlay(result, over, Math.floor((width - over.width) / 2), 0) + overlay(result, base, Math.floor((width - base.width) / 2), overHeight) + if (under) overlay(result, under, Math.floor((width - under.width) / 2), overHeight + base.height) + return result +} + +function layoutDelimited(left: string, bodyNode: MathNode, right: string, context: LayoutContext): Box { + const body = layoutNode(bodyNode, context) + const leftBox = delimiterBox(left, body.height, body.baseline, true, context.style) + const rightBox = delimiterBox(right, body.height, body.baseline, false, context.style) + return hpack([leftBox, body, rightBox]) +} + +function layoutMatrix(node: Extract, context: LayoutContext): Box { + const cellRows = node.rows.map((row) => row.map((cell) => layoutNode(cell, context))) + const columns = node.columns?.match(/[lcr]/g) + const rules = node.columns?.split(/[lcr]/).map((rule) => rule.length) ?? [] + const columnCount = Math.max(columns?.length ?? 0, ...cellRows.map((row) => row.length)) + const columnWidths = Array.from({ length: columnCount }, (_, column) => + Math.max(0, ...cellRows.map((row) => row[column]?.width ?? 0)), + ) + const rowAscents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.baseline))) + const rowDescents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.height - cell.baseline - 1))) + const rowHeights = rowAscents.map((ascent, index) => ascent + 1 + rowDescents[index]) + const aligned = node.environment === "aligned" || node.environment === "align" + const columnGap = node.environment === "cases" || aligned ? 2 : 1 + const gaps = Array.from({ length: columnCount + 1 }, (_, boundary) => { + const edge = boundary === 0 || boundary === columnCount + return rules[boundary] ? rules[boundary] + (edge ? 1 : 2) : edge ? 0 : columnGap + }) + const width = columnWidths.reduce((sum, value) => sum + value, 0) + gaps.reduce((sum, value) => sum + value, 0) + const height = Math.max(1, rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, node.rows.length - 1)) + const result = blank(width, height, Math.floor(height / 2)) + let y = 0 + + for (let rowIndex = 0; rowIndex < cellRows.length; rowIndex++) { + let x = gaps[0] + const cells = cellRows[rowIndex] + for (let column = 0; column < columnCount; column++) { + const cell = cells[column] + const columnWidth = columnWidths[column] + if (cell) { + const alignment = + columns?.[column] ?? (node.environment === "cases" ? "l" : aligned ? (column % 2 === 0 ? "r" : "l") : "c") + const cellX = + x + + (alignment === "l" + ? 0 + : alignment === "r" + ? columnWidth - cell.width + : Math.floor((columnWidth - cell.width) / 2)) + const cellY = y + rowAscents[rowIndex] - cell.baseline + overlay(result, cell, cellX, cellY) + } + x += columnWidth + gaps[column + 1] + } + y += rowHeights[rowIndex] + 1 + } + + let boundaryX = 0 + for (let boundary = 0; boundary <= columnCount; boundary++) { + for (let rule = 0; rule < (rules[boundary] ?? 0); rule++) { + for (let row = 0; row < height; row++) { + setCell(result, boundaryX + (boundary === 0 ? 0 : 1) + rule, row, "│", context.style) + } + } + boundaryX += gaps[boundary] + (columnWidths[boundary] ?? 0) + } + + const delimiters = matrixDelimiters(node.environment) + return delimiters + ? hpack([ + delimiterBox(delimiters[0], height, result.baseline, true, context.style), + result, + delimiterBox(delimiters[1], height, result.baseline, false, context.style), + ]) + : result +} + +function layoutBrace(node: Extract, context: LayoutContext): Box { + const body = layoutNode(node.body, context) + const over = node.position === "over" + const width = Math.max(3, body.width) + const result = blank(width, body.height + 1, body.baseline + (over ? 1 : 0)) + const y = over ? 0 : body.height + overlay(result, body, Math.floor((width - body.width) / 2), over ? 1 : 0) + drawHorizontal(result, y, 0, width, "─", context.style) + setCell(result, 0, y, over ? "╭" : "╰", context.style) + setCell(result, width - 1, y, over ? "╮" : "╯", context.style) + setCell(result, Math.floor((width - 1) / 2), y, over ? "┴" : "┬", context.style) + return result +} + +function layoutAccent( + accent: Extract["accent"], + bodyNode: MathNode, + context: LayoutContext, +): Box { + const body = layoutNode(bodyNode, context) + if (accent === "underline") { + const result = blank(body.width, body.height + 1, body.baseline) + overlay(result, body, 0, 0) + drawHorizontal(result, body.height, 0, body.width, "─", context.style) + return result + } + + const result = blank(body.width, body.height + 1, body.baseline + 1) + overlay(result, body, 0, 1) + const mark = + accent === "hat" || accent === "widehat" + ? body.width === 1 + ? "^" + : "⌢" + : accent === "bar" || accent === "overline" + ? "─" + : accent === "vec" + ? "→" + : accent === "tilde" + ? "~" + : accent === "dot" + ? "·" + : "¨" + + if (accent === "bar" || accent === "overline") drawHorizontal(result, 0, 0, body.width, mark, context.style) + else setCell(result, Math.max(0, Math.floor((body.width - cellWidth(mark)) / 2)), 0, mark, context.style) + return result +} + +function delimiterBox( + delimiter: string, + height: number, + baseline: number, + left: boolean, + style: MathStyle | undefined, +): Box { + if (!delimiter) return blank(0, height, baseline) + if (height <= 1) return textBox(delimiter, style) + const glyphs = delimiterGlyphs(delimiter) + const width = Math.max(...glyphs.map(cellWidth)) + const result = blank(width, height, baseline) + for (let y = 0; y < height; y++) { + const glyph = y === 0 ? glyphs[0] : y === height - 1 ? glyphs[2] : glyphs[1] + setCell(result, 0, y, glyph, style) + } + if ((delimiter === "{" || delimiter === "}") && height >= 3) { + setCell(result, 0, Math.floor(height / 2), left ? "⎨" : "⎬", style) + } + return result +} + +function delimiterGlyphs(delimiter: string): [string, string, string] { + switch (delimiter) { + case "(": + return ["⎛", "⎜", "⎝"] + case ")": + return ["⎞", "⎟", "⎠"] + case "[": + return ["⎡", "⎢", "⎣"] + case "]": + return ["⎤", "⎥", "⎦"] + case "{": + return ["⎧", "⎪", "⎩"] + case "}": + return ["⎫", "⎪", "⎭"] + case "⌊": + return ["│", "│", "⌊"] + case "⌋": + return ["│", "│", "⌋"] + case "⌈": + return ["⌈", "│", "│"] + case "⌉": + return ["⌉", "│", "│"] + case "⟨": + return ["/", "│", "\\"] + case "⟩": + return ["\\", "│", "/"] + default: + return [delimiter, delimiter, delimiter] + } +} + +function matrixDelimiters(environment: string): [string, string] | undefined { + switch (environment) { + case "pmatrix": + return ["(", ")"] + case "bmatrix": + return ["[", "]"] + case "Bmatrix": + return ["{", "}"] + case "vmatrix": + return ["│", "│"] + case "Vmatrix": + return ["║", "║"] + case "cases": + return ["{", ""] + default: + return undefined + } +} + +function hpack(boxes: Box[]): Box { + if (boxes.length === 0) return blank(0, 1, 0) + const ascent = Math.max(...boxes.map((box) => box.baseline)) + const descent = Math.max(...boxes.map((box) => box.height - box.baseline - 1)) + const width = boxes.reduce((sum, box) => sum + box.width, 0) + const result = blank(width, ascent + descent + 1, ascent) + let x = 0 + for (const box of boxes) { + overlay(result, box, x, ascent - box.baseline) + x += box.width + } + return result +} + +function textBox(text: string, style?: MathStyle): Box { + const graphemes = Array.from(graphemeSegmenter.segment(text), (item) => item.segment) + const width = graphemes.reduce((sum, grapheme) => sum + cellWidth(grapheme), 0) + const result = blank(width, 1, 0) + let x = 0 + for (const grapheme of graphemes) { + setCell(result, x, 0, grapheme, style) + x += cellWidth(grapheme) + } + return result +} + +function blank(width: number, height: number, baseline: number): Box { + return { + width: Math.max(0, width), + height: Math.max(1, height), + baseline: Math.max(0, baseline), + cells: Array.from({ length: Math.max(1, height) }, () => Array(Math.max(0, width))), + } +} + +function overlay(target: Box, source: Box, x: number, y: number): void { + for (let sourceY = 0; sourceY < source.height; sourceY++) { + for (let sourceX = 0; sourceX < source.width; sourceX++) { + const cell = source.cells[sourceY]?.[sourceX] + if (cell) target.cells[y + sourceY][x + sourceX] = cell + } + } +} + +function drawHorizontal( + box: Box, + y: number, + x: number, + width: number, + char: string, + style: MathStyle | undefined, +): void { + for (let offset = 0; offset < width; offset++) setCell(box, x + offset, y, char, style) +} + +function setCell(box: Box, x: number, y: number, char: string, style?: MathStyle): void { + if (x < 0 || y < 0 || x >= box.width || y >= box.height) return + box.cells[y][x] = style ? { char, style } : { char } +} + +function nodeRole(node: MathNode): SymbolRole | undefined { + if (node.type === "symbol") return node.role + if (node.type === "operator") return "operator" + // Tall constructs need a terminal-cell side bearing. Treating them like + // operators gives their fraction bars/radical hooks breathing room without + // adding padding inside the construct itself. + if (node.type === "fraction" || node.type === "root" || node.type === "matrix") return "operator" + if (node.type === "scripts") return nodeRole(node.base) + return undefined +} + +function needsMathSpace(previous: SymbolRole | undefined, current: SymbolRole | undefined, count: number): boolean { + if (count === 0) return false + if (previous === "punctuation" || previous === "opening" || current === "punctuation" || current === "closing") { + return false + } + return ( + previous === "binary" || + previous === "relation" || + previous === "operator" || + current === "binary" || + current === "relation" || + current === "operator" + ) +} + +function normalizeBinaryRole( + role: SymbolRole | undefined, + previous: SymbolRole | undefined, + next: SymbolRole | undefined, +): SymbolRole | undefined { + if (role !== "binary") return role + if ( + previous === undefined || + previous === "binary" || + previous === "relation" || + previous === "operator" || + previous === "punctuation" || + previous === "opening" || + next === undefined || + next === "binary" || + next === "relation" || + next === "punctuation" || + next === "closing" + ) { + return "ordinary" + } + return role +} + +function nextSignificantRole(nodes: MathNode[], start: number): SymbolRole | undefined { + for (let index = start; index < nodes.length; index++) { + const node = nodes[index] + if (node.type === "space") continue + return nodeRole(node) ?? "ordinary" + } + return undefined +} + +function simpleNodeText(node: MathNode): string | undefined { + if (node.type === "symbol" || node.type === "text" || node.type === "operator") return node.value + if (node.type === "row") { + const values = node.body.map(simpleNodeText) + return values.every((value) => value !== undefined) ? values.join("") : undefined + } + return undefined +} + +function mapScript(value: string | undefined, table: Readonly>): string | undefined { + if (value === undefined) return undefined + let result = "" + for (const char of value) { + const mapped = table[char] + if (!mapped) return undefined + result += mapped + } + return result +} + +function withVariant(context: LayoutContext, variant: MathVariant): LayoutContext { + const style = variant === "bold" ? { bold: true } : variant === "italic" ? { italic: true } : {} + return { ...context, variant, style: { ...context.style, ...style } } +} + +function applyVariant(value: string, variant: MathVariant | undefined): string { + if (!variant || variant === "normal" || variant === "bold" || variant === "italic") return value + + const exceptions: Partial>>> = { + "double-struck": { + C: "ℂ", + H: "ℍ", + N: "ℕ", + P: "ℙ", + Q: "ℚ", + R: "ℝ", + Z: "ℤ", + }, + script: { + B: "ℬ", + E: "ℰ", + F: "ℱ", + H: "ℋ", + I: "ℐ", + L: "ℒ", + M: "ℳ", + R: "ℛ", + e: "ℯ", + g: "ℊ", + o: "ℴ", + }, + fraktur: { + C: "ℭ", + H: "ℌ", + I: "ℑ", + R: "ℜ", + Z: "ℨ", + }, + } + + const ranges: Partial> = { + "double-struck": [0x1d538, 0x1d552, 0x1d7d8], + script: [0x1d49c, 0x1d4b6], + fraktur: [0x1d504, 0x1d51e], + sans: [0x1d5a0, 0x1d5ba, 0x1d7e2], + monospace: [0x1d670, 0x1d68a, 0x1d7f6], + } + const range = ranges[variant] + if (!range) return value + + return Array.from(value) + .map((char) => { + const exception = exceptions[variant]?.[char] + if (exception) return exception + const code = char.codePointAt(0)! + if (code >= 65 && code <= 90) return String.fromCodePoint(range[0] + code - 65) + if (code >= 97 && code <= 122) return String.fromCodePoint(range[1] + code - 97) + if (range[2] !== undefined && code >= 48 && code <= 57) return String.fromCodePoint(range[2] + code - 48) + return char + }) + .join("") +} + +function cellWidth(value: string): number { + if (value.length === 0) return 0 + if (/^(?:[\u0000-\u001f\u007f-\u009f]|[\u0300-\u036f]|[\ufe00-\ufe0f])$/u.test(value)) return 0 + const code = value.codePointAt(0) ?? 0 + if ( + code >= 0x1100 && + (code <= 0x115f || + code === 0x2329 || + code === 0x232a || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x1f300 && code <= 0x1faff)) + ) { + return 2 + } + return 1 +} + +function asPublicLayout(box: Box): MathLayout { + return { + width: box.width, + height: box.height, + baseline: box.baseline, + cells: box.cells, + toString() { + return box.cells + .map((row) => { + let output = "" + for (let x = 0; x < box.width; x++) { + const cell = row[x] + output += cell?.char ?? " " + if (cell && cellWidth(cell.char) > 1) x += cellWidth(cell.char) - 1 + } + return output.trimEnd() + }) + .join("\n") + }, + } +} diff --git a/packages/latex/src/limits.ts b/packages/latex/src/limits.ts new file mode 100644 index 000000000000..14319cbd98da --- /dev/null +++ b/packages/latex/src/limits.ts @@ -0,0 +1,40 @@ +import { LatexParseError } from "./types" + +export const DEFAULT_MAX_SOURCE_LENGTH = 100_000 +export const DEFAULT_MAX_NESTING_DEPTH = 256 + +export function resolvePositiveInteger(value: number | undefined, fallback: number, optionName: string): number { + if (value === undefined) return fallback + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${optionName} must be a positive safe integer`) + } + return value +} + +export function assertSourceLength(source: string, maximum: number, label = "LaTeX source"): void { + if (source.length > maximum) { + throw new LatexParseError(`${label} exceeds the ${maximum}-character limit`, maximum) + } +} + +export function assertNestingDepth(source: string, maximum: number): void { + let depth = 0 + let slashRun = 0 + for (let index = 0; index < source.length; index++) { + const char = source[index] + if (char === "\\") { + slashRun++ + continue + } + const escaped = slashRun % 2 === 1 + slashRun = 0 + if (char === "{" && !escaped) { + depth++ + if (depth > maximum) { + throw new LatexParseError(`LaTeX nesting exceeds the ${maximum}-level limit`, index) + } + } else if (char === "}" && !escaped) { + depth = Math.max(0, depth - 1) + } + } +} diff --git a/packages/latex/src/markdown.test.ts b/packages/latex/src/markdown.test.ts new file mode 100644 index 000000000000..c6cf4a2a95b9 --- /dev/null +++ b/packages/latex/src/markdown.test.ts @@ -0,0 +1,245 @@ +import { afterEach, expect, test } from "bun:test" +import { + CodeRenderable, + MarkdownRenderable, + RGBA, + ScrollBoxRenderable, + SyntaxStyle, + TextAttributes, + TextRenderable, + createMarkdownCodeBlockRenderer, +} from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { renderLatex } from "./render" +import { createLatexCodeBlockRenderer } from "./markdown" + +const renderers: Awaited>["renderer"][] = [] +const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }) + +afterEach(() => { + renderers.splice(0).forEach((renderer) => renderer.destroy()) +}) + +async function setup(content: string, width = 80) { + const output = await createTestRenderer({ + width, + height: 24, + remote: true, + useThread: false, + }) + renderers.push(output.renderer) + const palette = { text: "#abcdef", subdued: "#667788" } + const render = createLatexCodeBlockRenderer(output.renderer, () => palette) + const markdown = new MarkdownRenderable(output.renderer, { + content, + syntaxStyle, + streaming: true, + internalBlockMode: "top-level", + renderNode: createMarkdownCodeBlockRenderer({ latex: render, math: render }), + }) + output.renderer.root.add(markdown) + await output.renderOnce() + return { ...output, markdown, palette } +} + +test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence", async (language) => { + const output = await setup(`\`\`\`${language}\n\\frac{1}{2}\n\`\`\``) + const formula = output.markdown.getChildren()[0]?.getChildren()[0] + expect(formula).toBeInstanceOf(TextRenderable) + if (!(formula instanceof TextRenderable)) throw new Error("Expected a formula") + expect(formula.height).toBe(3) + expect(formula.chunks.find((chunk) => chunk.text === "1")?.fg?.equals(RGBA.fromHex("#abcdef"))).toBe(true) + expect(output.captureCharFrame()).toContain("1") + expect(output.captureCharFrame()).toContain("2") + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + +test.each([ + String.raw`\frac{1}{`, + String.raw`\unsupported{x}`, + String.raw`\cfrac[x]{1}{2}`, + String.raw`\left\unknown x\right)`, + String.raw`\begin{array}{p{2cm}}x\end{array}`, + String.raw`\documentclass{article} +\begin{document} +Hello +\end{document}`, +])("preserves invalid or unsupported math as source: %s", async (source) => { + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``) + const block = output.markdown.getChildren()[0] + expect(block).toBeInstanceOf(CodeRenderable) + if (!(block instanceof CodeRenderable)) throw new Error("Expected source fallback") + expect(block.content).toBe(source) +}) + +test.each([ + String.raw`\sqrt[\frac{1}{2}]{x}`, + String.raw`\left\|v\right\|`, + String.raw`\left(A\rightarrow B\right)`, + String.raw`\begin{aligned}a&=b+c\\&=d\end{aligned}`, + String.raw`\displaylines{x=1\\y=2}`, + String.raw`\cfrac[l]{1}{12345}`, + String.raw`\underbrace{a+b+c}_{n}`, + String.raw`\begin{array}{l|r}a&wide\\long&b\end{array}`, +])("renders structured math through the Markdown adapter: %s", async (source) => { + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``) + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + expect(output.captureCharFrame()).not.toContain("\\") +}) + +test("renders the next valid formula after an incomplete streaming prefix", async () => { + const output = await setup("```latex\n\\frac{1}{") + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) + + output.markdown.content += "2}" + await output.renderOnce() + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + expect(output.captureCharFrame()).not.toContain("\\frac") + + output.markdown.content += "\n```" + output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) +}) + +test("renders the final formula when the last text update is applied before completion", async () => { + const output = await setup("```latex\n\\frac{1}{") + output.markdown.content += "2}\n```" + output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren().filter((child) => child instanceof ScrollBoxRenderable).length).toBe(1) + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + +test("retains the last valid Unicode formula while the next fraction is incomplete", async () => { + const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}") + const previous = output.captureCharFrame() + output.markdown.content += "+\\frac{a_" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + expect(output.captureCharFrame()).toBe(previous) + + output.markdown.content += "2+b_2}{c_2+d_2}" + await output.renderOnce() + expect(output.captureCharFrame()).not.toBe(previous) + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + +test.each(["close", "stop"])("discards an incomplete preview when the stream ends: %s", async (end) => { + const output = await setup("```latex\nx^2") + output.markdown.content += " + \\frac{1}{" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + + if (end === "close") output.markdown.content += "\n```" + if (end === "stop") output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("does not reuse another fence's preview or keep a removed fence's preview", async () => { + const output = await setup("```latex\nx^2\n```\n\n```latex\n\\frac{1}{") + expect(output.markdown.getChildren()[1]).toBeInstanceOf(CodeRenderable) + + output.markdown.content = "" + await output.renderOnce() + output.markdown.content = "```latex\nx^2 + \\frac{1}{" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("does not leave a stale formula when a stream ends with invalid math", async () => { + const output = await setup("```latex\nx^2") + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + + output.markdown.content += " + \\unsupported{x}\n```" + output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("keeps a matrix and surrounding Markdown intact in a narrow terminal", async () => { + const output = await setup("Before\n\n```math\n\\begin{pmatrix}a & b \\\\ c & d\\end{pmatrix}\n```\n\nAfter", 32) + await output.renderOnce() + const frame = output.captureCharFrame() + expect(frame).toContain("Before") + expect(frame).toContain("a b") + expect(frame).toContain("c d") + expect(frame).toContain("After") + expect(frame).not.toContain("pmatrix") +}) + +test("leaves ordinary code fences alone", async () => { + const output = await setup("```typescript\nconst x = 2\n```") + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("allows wide formulas to scroll horizontally without wrapping", async () => { + const output = await setup( + "```latex\n\\text{Start a very long formula with enough content to overflow Finish}\n```", + 24, + ) + const viewport = output.markdown.getChildren()[0] + expect(viewport).toBeInstanceOf(ScrollBoxRenderable) + if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected a horizontal viewport") + expect(output.captureCharFrame()).toContain("Start") + expect(output.captureCharFrame()).not.toContain("Finish") + expect(viewport.height).toBe(1) + + await output.mockMouse.scroll(2, 1, "right") + await output.renderOnce() + expect(viewport.scrollLeft).toBeGreaterThan(0) + + viewport.scrollLeft = viewport.scrollWidth + await output.renderOnce() + expect(output.captureCharFrame()).toContain("Finish") + expect(output.captureCharFrame()).not.toContain("Start") +}) + +test("subdues structure and emphasizes relations using the theme", async () => { + const output = await setup("```latex\nx=\\sqrt{\\frac{1}{2}}\n```") + const formula = output.markdown.getChildren()[0]?.getChildren()[0] + if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") + for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) { + expect(formula.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe( + true, + ) + } + expect(formula.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) + expect(formula.chunks.find((chunk) => chunk.text === "=")?.attributes).toBe(TextAttributes.BOLD) + + output.palette.text = "#123456" + output.palette.subdued = "#789abc" + output.markdown.refreshStyles() + await output.renderOnce() + const updated = output.markdown.getChildren()[0]?.getChildren()[0] + if (!(updated instanceof TextRenderable)) throw new Error("Expected Unicode math") + expect(updated.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) + for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) { + expect(updated.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe( + true, + ) + } +}) + +test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])( + "preserves wide-character alignment: %s", + async (source) => { + const layout = renderLatex(source) + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``, layout.width) + const viewport = output.markdown.getChildren()[0] + if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected math viewport") + const formula = viewport.getChildren()[0] + if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") + expect( + formula.chunks + .map((chunk) => chunk.text) + .join("") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"), + ).toBe(layout.toString()) + expect(viewport.scrollWidth).toBe(layout.width) + }, +) diff --git a/packages/latex/src/markdown.ts b/packages/latex/src/markdown.ts new file mode 100644 index 000000000000..f5ecceed1a13 --- /dev/null +++ b/packages/latex/src/markdown.ts @@ -0,0 +1,120 @@ +import { + CodeRenderable, + RenderableEvents, + ScrollBoxRenderable, + StyledText, + TextRenderable, + createTextAttributes, + parseColor, + type ColorInput, + type MarkdownCodeBlockRenderer, + type RenderContext, +} from "@opentui/core" +import stringWidth from "string-width" +import { renderLatex } from "./render" +import { LatexParseError, type MathLayout } from "./types" + +export type LatexOptions = { + text: ColorInput + subdued: ColorInput +} + +type LatexFrame = { + source: string + layout: MathLayout +} + +export function createLatexCodeBlockRenderer( + context: RenderContext, + options: () => LatexOptions, +): MarkdownCodeBlockRenderer { + const lastGood = new Map() + return (token, render) => { + const fallback = render.defaultRender() + const key = fallback?.id + const previous = key ? lastGood.get(key) : undefined + const retained = previous && token.text.startsWith(previous.source) ? previous : undefined + const fence = /^ {0,3}(`{3,}|~{3,})/.exec(token.raw)?.[1] + const streaming = + fallback instanceof CodeRenderable && + fallback.streaming && + fence && + !new RegExp(`\\n {0,3}${fence[0]}{${fence.length},}\\s*$`).test(token.raw) + const layout = layoutLatex(token.text) + const frame: LatexFrame | undefined = layout + ? { source: token.text, layout } + : streaming && retained + ? { ...retained } + : undefined + if (!frame) return fallback ?? undefined + const palette = options() + const text = parseColor(palette.text) + const subdued = parseColor(palette.subdued) + const formula = new TextRenderable(context, { + content: new StyledText( + frame.layout.cells.flatMap((row, index) => [ + ...Array.from(row).flatMap((cell, column) => { + // Wide glyphs already occupy the following cell; do not emit another space for it. + if (column > 0 && stringWidth(row[column - 1]?.char ?? "") > 1) return [] + return [ + { + __isChunk: true as const, + text: cell?.char ?? " ", + fg: /^[()[\]{}|\u221a\u239b-\u23ad\u2500-\u257f]$/u.test(cell?.char ?? "") ? subdued : text, + attributes: createTextAttributes({ + bold: cell?.style?.bold || /^[=<>\u2260\u2261\u2264\u2265\u2248]$/u.test(cell?.char ?? ""), + italic: cell?.style?.italic, + dim: cell?.style?.dim, + }), + }, + ] + }), + ...(index < frame.layout.height - 1 ? [{ __isChunk: true as const, text: "\n", fg: text }] : []), + ]), + ), + width: "100%", + minWidth: frame.layout.width, + height: frame.layout.height, + wrapMode: "none", + selectable: false, + flexShrink: 0, + }) + const viewport = new ScrollBoxRenderable(context, { + width: "100%", + height: frame.layout.height, + flexShrink: 0, + marginTop: 1, + scrollX: true, + scrollY: false, + onMouseScroll(event) { + if (event.modifiers.shift || event.scroll?.direction === "left" || event.scroll?.direction === "right") { + event.stopPropagation() + } + }, + }) + // The setters opt out of automatic scrollbar visibility; constructor options do not. + viewport.horizontalScrollBar.visible = false + viewport.verticalScrollBar.visible = false + viewport.add(formula) + if (key) { + lastGood.set(key, frame) + viewport.once(RenderableEvents.DESTROYED, () => { + // Markdown destroys the old block before constructing its replacement in the same stack. + queueMicrotask(() => { + if (lastGood.get(key) === frame) lastGood.delete(key) + }) + }) + } + return viewport + } +} + +function layoutLatex(source: string) { + try { + return renderLatex(source, { strict: true, displayMode: true }) + } catch (error) { + // Preserve the exact source for incomplete math, unsupported commands, and oversized input. + if (error instanceof LatexParseError || error instanceof RangeError) return undefined + throw error + } +} diff --git a/packages/latex/src/parser-render.test.ts b/packages/latex/src/parser-render.test.ts new file mode 100644 index 000000000000..5eb4c0c5f4af --- /dev/null +++ b/packages/latex/src/parser-render.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { renderLatexToString } from "./render" + +describe("parser rendering regressions", () => { + test.each([ + [String.raw`\|v\|`, "║v║"], + [String.raw`\left\|v\right\|`, "║v║"], + [String.raw`\left|v\right|`, "│v│"], + [String.raw`\left(A\rightarrow B\right)`, "(A → B)"], + [String.raw`\left\lbrace x\right\rbrace`, "{x}"], + [String.raw`\operatorname{arg\,max} x`, "arg max x"], + [String.raw`\textrm{if }x`, "if x"], + [String.raw`\displaylines{x=1\\y=2}`, "x = 1\n\ny = 2"], + ])("renders supported syntax without leaking or losing tokens: %s", (source, expected) => { + expect(renderLatexToString(source, { strict: true })).toBe(expected) + }) + + test("renders empty aligned cells like explicitly empty groups", () => { + expect(renderLatexToString(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toBe( + renderLatexToString(String.raw`\begin{aligned}{}&=x\\{}&=y\end{aligned}`, { strict: true }), + ) + }) +}) diff --git a/packages/latex/src/parser.test.ts b/packages/latex/src/parser.test.ts new file mode 100644 index 000000000000..7c59be817f8d --- /dev/null +++ b/packages/latex/src/parser.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test" +import { parseLatex } from "./parser" +import { LatexParseError } from "./types" + +describe("parseLatex", () => { + test("parses fractions and scripts structurally", () => { + expect(parseLatex(String.raw`\frac{x^2+1}{y_0}`)).toMatchObject({ + type: "fraction", + bar: true, + numerator: { type: "row" }, + denominator: { type: "scripts" }, + }) + }) + + test("parses matrix environments into rows and cells", () => { + expect(parseLatex(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toMatchObject({ + type: "matrix", + environment: "pmatrix", + rows: [ + [ + { type: "symbol", value: "a" }, + { type: "symbol", value: "b" }, + ], + [ + { type: "symbol", value: "c" }, + { type: "symbol", value: "d" }, + ], + ], + }) + }) + + test("accepts array column specs and starred alignment environments", () => { + expect(parseLatex(String.raw`\begin{array}{cc}a & b \\ c & d\end{array}`)).toMatchObject({ + type: "matrix", + environment: "array", + columns: "cc", + rows: [ + [{}, {}], + [{}, {}], + ], + }) + expect(parseLatex(String.raw`\begin{align*}a &= b \\ c &= d\end{align*}`)).toMatchObject({ + type: "matrix", + environment: "align", + }) + }) + + test("preserves double norm delimiters without changing single bars", () => { + expect(parseLatex(String.raw`\|v\|`, { strict: true })).toMatchObject({ + type: "row", + body: [{ value: "║" }, { value: "v" }, { value: "║" }], + }) + expect(parseLatex(String.raw`\left\|v\right\|`, { strict: true })).toMatchObject({ + type: "delimited", + left: "║", + right: "║", + }) + expect(parseLatex(String.raw`\left|v\right|`, { strict: true })).toMatchObject({ + type: "delimited", + left: "│", + right: "│", + }) + }) + + test("matches the whole right command and keeps nested delimiters", () => { + expect(parseLatex(String.raw`\left(A\rightarrow B\right)`, { strict: true })).toMatchObject({ + type: "delimited", + left: "(", + body: { type: "row", body: [{ value: "A" }, { value: "→" }, { value: "B" }] }, + right: ")", + }) + expect(parseLatex(String.raw`\left(\left[A\right]\rightharpoonup B\right)`)).toMatchObject({ + type: "delimited", + body: { type: "row", body: [{ type: "delimited" }, { value: "⇀" }, { value: "B" }] }, + }) + expect(() => parseLatex(String.raw`\left(A\rightarrow B`, { strict: true })).toThrow(/Missing \\right/) + expect(() => parseLatex(String.raw`\left(A\rightward B\right)`, { strict: true })).toThrow( + /Unsupported command \\rightward/, + ) + }) + + test("accepts empty leading, interior, and trailing environment cells", () => { + expect(parseLatex(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "aligned", + rows: [ + [ + { type: "row", body: [] }, + { type: "row", body: [{ value: "=" }, { value: "x" }] }, + ], + [ + { type: "row", body: [] }, + { type: "row", body: [{ value: "=" }, { value: "y" }] }, + ], + ], + }) + expect(parseLatex(String.raw`\begin{matrix}a&&\\&b&\end{matrix}`, { strict: true })).toMatchObject({ + type: "matrix", + rows: [ + [{ value: "a" }, { type: "row", body: [] }, { type: "row", body: [] }], + [{ type: "row", body: [] }, { value: "b" }, { type: "row", body: [] }], + ], + }) + expect(parseLatex(String.raw`\begin{matrix}a\\\end{matrix}`)).toMatchObject({ rows: [[{ value: "a" }]] }) + expect(parseLatex(String.raw`\begin{matrix}\\\end{matrix}`)).toMatchObject({ rows: [[{ type: "row", body: [] }]] }) + }) + + test("parses displaylines as separate gathered rows", () => { + expect(parseLatex(String.raw`\displaylines{x=1\\y=2}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "gathered", + rows: [ + [{ type: "row", body: [{ value: "x" }, { value: "=" }, { value: "1" }] }], + [{ type: "row", body: [{ value: "y" }, { value: "=" }, { value: "2" }] }], + ], + }) + expect(parseLatex(String.raw`\displaylines{\frac{1}{2}\\{y}}+z`)).toMatchObject({ + type: "row", + body: [{ type: "matrix", rows: [[{ type: "fraction" }], [{ value: "y" }]] }, { value: "+" }, { value: "z" }], + }) + expect(() => parseLatex(String.raw`\displaylines[l]{x\\y}`, { strict: true })).toThrow(LatexParseError) + expect(() => parseLatex(String.raw`\displaylines{x\\y`, { strict: true })).toThrow(LatexParseError) + }) + + test.each([ + ["", undefined], + ["[]", undefined], + ["[l]", "left"], + ["[r]", "right"], + ] as const)("parses continued fraction alignment %s before its arguments", (option, numeratorAlign) => { + expect(parseLatex(String.raw`\cfrac${option}{1}{23}`, { strict: true })).toEqual({ + type: "fraction", + numerator: { type: "symbol", value: "1", role: "ordinary" }, + denominator: { + type: "row", + body: [ + { type: "symbol", value: "2", role: "ordinary" }, + { type: "symbol", value: "3", role: "ordinary" }, + ], + }, + bar: true, + ...(numeratorAlign ? { numeratorAlign } : {}), + }) + }) + + test.each(["[c]", "[lr]", "[left]", "[l"])("rejects unsupported continued fraction alignment %s", (option) => { + expect(() => parseLatex(String.raw`\cfrac${option}{1}{2}`, { strict: true })).toThrow(LatexParseError) + }) + + test("retains normalized array columns including edge and double rules", () => { + expect(parseLatex(String.raw`\begin{array}{ | l || c r | }a&b&c\end{array}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "array", + columns: "|l||cr|", + }) + }) + + test.each(["", "||", "p{2cm}", "*{2}{c}", "c@{}c", "lXr"])("rejects unsupported array columns %s", (columns) => { + expect(() => parseLatex(String.raw`\begin{array}{${columns}}a\end{array}`, { strict: true })).toThrow( + LatexParseError, + ) + }) + + test("requires an array column specification", () => { + expect(() => parseLatex(String.raw`\begin{array}a&b\end{array}`, { strict: true })).toThrow(LatexParseError) + }) + + test("emits structural braces while keeping annotations as scripts", () => { + expect(parseLatex(String.raw`\overbrace{a+b}^{n}`, { strict: true })).toMatchObject({ + type: "scripts", + base: { type: "brace", position: "over", body: { type: "row" } }, + superscript: { value: "n" }, + }) + expect(parseLatex(String.raw`\underbrace{x}_{k}`, { strict: true })).toMatchObject({ + type: "scripts", + base: { type: "brace", position: "under", body: { value: "x" } }, + subscript: { value: "k" }, + }) + }) + + test("recognizes named braces and rejects unsupported delimiter commands in strict mode", () => { + expect(parseLatex(String.raw`\left\lbrace x\right\rbrace`, { strict: true })).toMatchObject({ + type: "delimited", + left: "{", + right: "}", + }) + expect(parseLatex(String.raw`\lbrace x\rbrace`, { strict: true })).toMatchObject({ + type: "row", + body: [{ value: "{" }, { value: "x" }, { value: "}" }], + }) + for (const source of [ + String.raw`\left\unknown x\right)`, + String.raw`\left(x\right\unknown`, + String.raw`\big\unknown`, + String.raw`\left(x\middle\unknown y\right)`, + ]) { + expect(() => parseLatex(source, { strict: true })).toThrow(/Unsupported delimiter \\unknown/) + } + }) + + test("expands user macros", () => { + expect(parseLatex(String.raw`\R \to \R`, { macros: { "\\R": String.raw`\mathbb{R}` } })).toMatchObject({ + type: "row", + }) + }) + + test("reports useful strict-mode errors", () => { + expect(() => parseLatex(String.raw`\definitelyUnknown{x}`, { strict: true })).toThrow(LatexParseError) + }) + + test("keeps escaped braces inside raw text groups", () => { + expect(parseLatex(String.raw`\text{left \{ only}`)).toMatchObject({ + type: "text", + value: "left { only", + }) + expect(parseLatex(String.raw`\text{right \} only}`)).toMatchObject({ + type: "text", + value: "right } only", + }) + }) + + test("supports starred named operators and limits modifiers", () => { + expect(parseLatex(String.raw`\operatorname*{arg\,max}_{x}`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: "arg max", limits: true }, + }) + expect(parseLatex(String.raw`\int\limits_0^1`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: "∫", limits: true }, + }) + expect(parseLatex(String.raw`\sum\nolimits_{i=1}`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: "∑", limits: false }, + }) + }) + + test("interprets operator spacing and preserves roman text whitespace", () => { + expect(parseLatex(String.raw`\operatorname{arg\,max}`, { strict: true })).toEqual({ + type: "operator", + value: "arg max", + limits: false, + }) + expect(parseLatex(String.raw`\textrm{ if }`, { strict: true })).toEqual({ + type: "variant", + variant: "normal", + body: { type: "text", value: " if " }, + }) + }) + + test("bounds source and recursive macro expansion", () => { + expect(() => parseLatex("12345", { maxSourceLength: 4 })).toThrow(/4-character limit/) + expect(() => + parseLatex(String.raw`\a`, { + macros: { a: String.raw`\a\a` }, + maxExpandedLength: 64, + }), + ).toThrow(/64-character limit/) + expect(() => parseLatex(String.raw`\a`, { macros: { a: "{{x}}" }, maxDepth: 1 })).toThrow(/1-level limit/) + expect(() => parseLatex("x", { maxSourceLength: 0 })).toThrow(RangeError) + }) + + test("fails quickly when malformed environments cannot advance", () => { + expect(() => parseLatex(String.raw`\begin{matrix}]`)).toThrow(/Missing \\end{matrix}/) + expect(() => parseLatex(String.raw`\begin{matrix}x}`)).toThrow(/Unexpected "}" in matrix/) + expect(() => parseLatex(String.raw`\begin{matrix}&}`)).toThrow(/Unexpected "}" in matrix/) + }) + + test("bounds structural nesting with a parse error instead of overflowing the stack", () => { + const source = "{".repeat(80) + "x" + "}".repeat(80) + expect(() => parseLatex(source, { maxDepth: 64 })).toThrow(/64-level limit/) + expect(() => parseLatex(String.raw`\frac`.repeat(80) + "x", { maxDepth: 64 })).toThrow(/64-level limit/) + }) +}) diff --git a/packages/latex/src/parser.ts b/packages/latex/src/parser.ts new file mode 100644 index 000000000000..2bb2f08c246a --- /dev/null +++ b/packages/latex/src/parser.ts @@ -0,0 +1,598 @@ +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}̸` +} diff --git a/packages/latex/src/plugin.ts b/packages/latex/src/plugin.ts new file mode 100644 index 000000000000..39a648c04be0 --- /dev/null +++ b/packages/latex/src/plugin.ts @@ -0,0 +1,14 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { createLatexCodeBlockRenderer } from "./markdown" + +export default Plugin.define({ + id: "opencode.latex", + setup(context) { + const render = createLatexCodeBlockRenderer(context.renderer, () => ({ + text: context.theme.text.default, + subdued: context.theme.text.subdued, + })) + context.markdown.registerCodeBlockRenderer("latex", render) + context.markdown.registerCodeBlockRenderer("math", render) + }, +}) diff --git a/packages/latex/src/render.test.ts b/packages/latex/src/render.test.ts new file mode 100644 index 000000000000..b6a280339f4c --- /dev/null +++ b/packages/latex/src/render.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { renderLatex, renderLatexToString } from "./render" + +describe("renderLatexToString", () => { + test("renders a fraction with a centered rule", () => { + expect(renderLatexToString(String.raw`\frac{x+1}{y-1}`)).toBe([" x + 1", "───────", " y - 1"].join("\n")) + }) + + test.each([ + [String.raw`E = mc^2`, "E = mc²"], + [String.raw`a_n`, "aₙ"], + [String.raw`x_i^2`, "x²ᵢ"], + [String.raw`x^{}`, "x"], + [String.raw`x_{}`, "x"], + [String.raw`x^{}_{}`, "x"], + [String.raw`x^m_1`, " m\nx\n 1"], + [String.raw`x^2_q`, " 2\nx\n q"], + [String.raw`x^{\frac{1}{2}}_1`, " 1\n ───\n 2\nx\n 1"], + ])("compacts scripts only when every script is supported: %s", (source, expected) => { + expect(renderLatexToString(source)).toBe(expected) + }) + + test("respects script and display mode options", () => { + expect(renderLatexToString(String.raw`x_i^2`, { compactScripts: false })).toBe(" 2\nx\n i") + expect(renderLatexToString(String.raw`\sum_1^n`, { displayMode: false })).toBe("∑ⁿ₁") + expect(renderLatexToString(String.raw`\sum_1^n`, { compactScripts: false })).toBe("n\n∑\n1") + }) + + test("centers binomials around an empty math-axis row", () => { + expect(renderLatexToString(String.raw`P = \binom{n}{k}`)).toBe([" ⎛ n ⎞", "P = ⎜ ⎟", " ⎝ k ⎠"].join("\n")) + }) + + test("renders roots with a vinculum", () => { + expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "╰╯x² + y²"].join("\n")) + }) + + test("renders matrices with stretching delimiters", () => { + expect(renderLatexToString(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toBe( + ["⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"), + ) + }) + + test("places display operator limits above and below", () => { + expect(renderLatexToString(String.raw`\sum_{i=1}^{n} i^2`)).toBe([" n", " ∑ i²", "i = 1"].join("\n")) + }) + + test("returns intrinsic geometry and baseline", () => { + const layout = renderLatex(String.raw`\frac{1}{2}`) + expect(layout.width).toBe(3) + expect(layout.height).toBe(3) + expect(layout.baseline).toBe(1) + }) + + test("renders blackboard, calligraphic, and fraktur alphabets", () => { + expect(renderLatexToString(String.raw`\mathbb{R} \to \mathcal{C} \times \mathfrak{g}`)).toBe("ℝ → 𝒞 × 𝔤") + }) + + test("preserves inherited styles through nested variants and colors", () => { + const layout = renderLatex(String.raw`\mathbf{\mathsf{\textcolor{red}{\mathit{x}}}}`) + expect(layout.cells[0][0]).toEqual({ char: "x", style: { bold: true, italic: true, color: "red" } }) + }) + + test("renders nested fractions without flattening their structure", () => { + const result = renderLatexToString(String.raw`\frac{1}{1+\frac{1}{x}}`) + expect(result.split("\n")).toHaveLength(5) + expect(result.match(/─/g)?.length).toBeGreaterThanOrEqual(10) + }) + + test("renders common textbook structures", () => { + const result = renderLatexToString(String.raw`\left[\frac{-b \pm \sqrt{b^2-4ac}}{2a}\right]`) + expect(result).toContain("±") + expect(result).toContain("╰╯") + expect(result).toContain("─") + expect(result).toContain("⎡") + expect(result).toContain("⎦") + }) + + test("places fallback combining negation after the base symbol", () => { + const result = renderLatexToString(String.raw`\not\rightarrow`) + expect(Array.from(result)).toEqual(["→", "̸"]) + }) + + test("treats square brackets as ordinary interval delimiters", () => { + expect(renderLatexToString(String.raw`x\in[0,1]`)).toBe("x ∈ [0,1]") + expect(renderLatexToString(String.raw`[-1,1]`)).toBe("[-1,1]") + }) +}) diff --git a/packages/latex/src/render.ts b/packages/latex/src/render.ts new file mode 100644 index 000000000000..4ebc8aa59b82 --- /dev/null +++ b/packages/latex/src/render.ts @@ -0,0 +1,11 @@ +import { layoutMath } from "./layout" +import { parseLatex } from "./parser" +import type { MathLayout, RenderLatexOptions } from "./types" + +export function renderLatex(source: string, options: RenderLatexOptions = {}): MathLayout { + return layoutMath(parseLatex(source, options), options) +} + +export function renderLatexToString(source: string, options: RenderLatexOptions = {}): string { + return renderLatex(source, options).toString() +} diff --git a/packages/latex/src/root.test.ts b/packages/latex/src/root.test.ts new file mode 100644 index 000000000000..d9b3482862d9 --- /dev/null +++ b/packages/latex/src/root.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { renderLatex } from "./render" + +describe("root geometry", () => { + test.each([ + ["x", String.raw`\frac{1}{2}`], + ["x", String.raw`\sqrt{n}`], + ["x", "123456789"], + ["x", String.raw`\frac{123456789}{\frac{n}{m}}`], + [String.raw`\frac{a}{b}`, "3"], + [String.raw`\sqrt{\frac{a}{b}}`, String.raw`\sqrt{\frac{n}{m}}`], + [String.raw`\text{界}`, String.raw`\text{次}`], + ["", String.raw`\frac{1}{2}`], + ])("preserves body %s and index %s", (bodySource, indexSource) => { + const body = renderLatex(bodySource, { color: "red" }) + const index = renderLatex(indexSource, { color: "blue" }) + const root = renderLatex(String.raw`\sqrt[\textcolor{blue}{${indexSource}}]{\textcolor{red}{${bodySource}}}`) + const bodyX = root.width - body.width + const bodyY = root.height - body.height + + expect(root.cells).toHaveLength(root.height) + expect(root.baseline).toBe(bodyY + body.baseline) + expect(bodyX).toBeGreaterThan(index.width) + expect(bodyY).toBeGreaterThanOrEqual(index.height) + for (const row of root.cells) expect(row).toHaveLength(root.width) + for (const [y, row] of body.cells.entries()) { + for (const [x, cell] of row.entries()) expect(root.cells[bodyY + y][bodyX + x]).toEqual(cell) + } + for (const [y, row] of index.cells.entries()) { + for (const [x, cell] of row.entries()) expect(root.cells[y][x]).toEqual(cell) + } + expect(root.cells.flat().filter((cell) => cell?.style?.color === "red")).toHaveLength( + body.cells.flat().filter(Boolean).length, + ) + expect(root.cells.flat().filter((cell) => cell?.style?.color === "blue")).toHaveLength( + index.cells.flat().filter(Boolean).length, + ) + }) + + test("connects each nested overbar to a full-height stem", () => { + const root = renderLatex(String.raw`\sqrt{\sqrt{\sqrt{x}}}`) + expect(root.toString()).toBe([" ╭─────", " │ ╭───", " │ │ ╭─", "╰╯╰╯╰╯x"].join("\n")) + expect(root.height).toBe(4) + expect(root.baseline).toBe(3) + for (const depth of [0, 1, 2]) { + for (let y = depth; y < root.height; y++) expect(root.cells[y][depth * 2 + 1]).toBeDefined() + } + expect(root.cells.flat().filter((cell) => cell?.char === "x")).toHaveLength(1) + }) + + test("extends a fraction root below the math axis without moving its baseline", () => { + const root = renderLatex(String.raw`\sqrt{\frac{a}{b}}`) + expect(root.toString()).toBe([" ╭───", " │ a", " │───", "╰╯ b"].join("\n")) + expect(root.height).toBe(4) + expect(root.baseline).toBe(2) + for (let y = 0; y < root.height; y++) expect(root.cells[y][1]).toBeDefined() + expect(root.cells[root.baseline].map((cell) => cell?.char ?? " ").join("")).toContain("───") + expect(root.cells[root.height - 1].some((cell) => cell?.char === "b")).toBe(true) + }) + + test.each([ + [String.raw`\sqrt{x}`, [" ╭─", "╰╯x"]], + [String.raw`\sqrt[3]{x}`, ["3╭─", "╰╯x"]], + [String.raw`\sqrt[\frac{1}{2}]{x}`, [" 1", "───", " 2 ╭─", " ╰╯x"]], + [String.raw`\sqrt[\sqrt{n}]{x}`, [" ╭─", "╰╯n╭─", " ╰╯x"]], + ])("uses the same connected construction for %s", (source, expected) => { + expect(renderLatex(source).toString()).toBe(expected.join("\n")) + }) +}) diff --git a/packages/latex/src/symbols.ts b/packages/latex/src/symbols.ts new file mode 100644 index 000000000000..ab54910a448e --- /dev/null +++ b/packages/latex/src/symbols.ts @@ -0,0 +1,309 @@ +import type { SymbolRole } from "./types" + +export interface SymbolDefinition { + value: string + role?: SymbolRole +} + +const ordinary: Record = { + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ε", + varepsilon: "ϵ", + zeta: "ζ", + eta: "η", + theta: "θ", + vartheta: "ϑ", + iota: "ι", + kappa: "κ", + lambda: "λ", + mu: "μ", + nu: "ν", + xi: "ξ", + omicron: "ο", + pi: "π", + varpi: "ϖ", + rho: "ρ", + varrho: "ϱ", + sigma: "σ", + varsigma: "ς", + tau: "τ", + upsilon: "υ", + phi: "ϕ", + varphi: "φ", + chi: "χ", + psi: "ψ", + omega: "ω", + Gamma: "Γ", + Delta: "Δ", + Theta: "Θ", + Lambda: "Λ", + Xi: "Ξ", + Pi: "Π", + Sigma: "Σ", + Upsilon: "Υ", + Phi: "Φ", + Psi: "Ψ", + Omega: "Ω", + infty: "∞", + ell: "ℓ", + hbar: "ℏ", + imath: "ı", + jmath: "ȷ", + Re: "ℜ", + Im: "ℑ", + aleph: "ℵ", + beth: "ℶ", + gimel: "ℷ", + daleth: "ℸ", + partial: "∂", + nabla: "∇", + angle: "∠", + measuredangle: "∡", + triangle: "△", + square: "□", + lozenge: "◊", + top: "⊤", + bot: "⊥", + emptyset: "∅", + varnothing: "∅", + forall: "∀", + exists: "∃", + nexists: "∄", + neg: "¬", + lnot: "¬", + prime: "′", + backprime: "‵", + clubsuit: "♣", + diamondsuit: "♢", + heartsuit: "♡", + spadesuit: "♠", + checkmark: "✓", +} + +const binary: Record = { + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + ast: "∗", + star: "⋆", + circ: "∘", + bullet: "∙", + oplus: "⊕", + ominus: "⊖", + otimes: "⊗", + oslash: "⊘", + odot: "⊙", + cap: "∩", + cup: "∪", + uplus: "⊎", + sqcap: "⊓", + sqcup: "⊔", + vee: "∨", + lor: "∨", + wedge: "∧", + land: "∧", + setminus: "∖", + wr: "≀", + diamond: "⋄", + bigtriangleup: "△", + bigtriangledown: "▽", + triangleleft: "◁", + triangleright: "▷", +} + +const relation: Record = { + equals: "=", + neq: "≠", + ne: "≠", + equiv: "≡", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + asymp: "≍", + propto: "∝", + lt: "<", + gt: ">", + le: "≤", + leq: "≤", + ge: "≥", + geq: "≥", + ll: "≪", + gg: "≫", + prec: "≺", + succ: "≻", + preceq: "⪯", + succeq: "⪰", + subset: "⊂", + supset: "⊃", + subseteq: "⊆", + supseteq: "⊇", + sqsubset: "⊏", + sqsupset: "⊐", + sqsubseteq: "⊑", + sqsupseteq: "⊒", + in: "∈", + ni: "∋", + notin: "∉", + owns: "∋", + vdash: "⊢", + dashv: "⊣", + models: "⊨", + mid: "∣", + parallel: "∥", + perp: "⊥", + smile: "⌣", + frown: "⌢", +} + +const arrows: Record = { + leftarrow: "←", + gets: "←", + rightarrow: "→", + to: "→", + leftrightarrow: "↔", + Leftarrow: "⇐", + Rightarrow: "⇒", + Leftrightarrow: "⇔", + mapsto: "↦", + hookleftarrow: "↩", + hookrightarrow: "↪", + leftharpoonup: "↼", + leftharpoondown: "↽", + rightharpoonup: "⇀", + rightharpoondown: "⇁", + rightleftharpoons: "⇌", + longleftarrow: "⟵", + longrightarrow: "⟶", + longleftrightarrow: "⟷", + Longleftarrow: "⟸", + Longrightarrow: "⟹", + Longleftrightarrow: "⟺", + longmapsto: "⟼", + uparrow: "↑", + downarrow: "↓", + updownarrow: "↕", + Uparrow: "⇑", + Downarrow: "⇓", + Updownarrow: "⇕", + nearrow: "↗", + searrow: "↘", + swarrow: "↙", + nwarrow: "↖", +} + +const punctuation: Record = { + cdots: "⋯", + ldots: "…", + dots: "…", + vdots: "⋮", + ddots: "⋱", + colon: ":", +} + +export const symbolTable: Readonly> = { + ...Object.fromEntries(Object.entries(ordinary).map(([name, value]) => [name, { value, role: "ordinary" as const }])), + ...Object.fromEntries(Object.entries(binary).map(([name, value]) => [name, { value, role: "binary" as const }])), + ...Object.fromEntries(Object.entries(relation).map(([name, value]) => [name, { value, role: "relation" as const }])), + ...Object.fromEntries(Object.entries(arrows).map(([name, value]) => [name, { value, role: "relation" as const }])), + ...Object.fromEntries( + Object.entries(punctuation).map(([name, value]) => [name, { value, role: "punctuation" as const }]), + ), +} + +export const largeOperators: Readonly> = { + sum: "∑", + prod: "∏", + coprod: "∐", + int: "∫", + iint: "∬", + iiint: "∭", + oint: "∮", + bigcap: "⋂", + bigcup: "⋃", + bigvee: "⋁", + bigwedge: "⋀", + bigoplus: "⨁", + bigotimes: "⨂", + bigodot: "⨀", +} + +export const namedOperators = new Set([ + "arccos", + "arcsin", + "arctan", + "arg", + "cos", + "cosh", + "cot", + "coth", + "csc", + "deg", + "det", + "dim", + "exp", + "gcd", + "hom", + "inf", + "ker", + "lg", + "lim", + "liminf", + "limsup", + "ln", + "log", + "max", + "min", + "mod", + "Pr", + "sec", + "sin", + "sinh", + "sup", + "tan", + "tanh", +]) + +export const delimiterTable: Readonly> = { + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "\\{": "{", + "\\}": "}", + "{": "{", + "}": "}", + "|": "│", + "\\|": "║", + vert: "│", + Vert: "║", + lvert: "│", + rvert: "│", + lVert: "║", + rVert: "║", + lbrace: "{", + rbrace: "}", + langle: "⟨", + rangle: "⟩", + lfloor: "⌊", + rfloor: "⌋", + lceil: "⌈", + rceil: "⌉", + ".": "", +} + +export const spacingCommands: Readonly> = { + ",": 0, + ":": 1, + ";": 1, + "!": 0, + quad: 2, + qquad: 4, + enspace: 1, + thinspace: 0, +} diff --git a/packages/latex/src/types.ts b/packages/latex/src/types.ts new file mode 100644 index 000000000000..009838c1554a --- /dev/null +++ b/packages/latex/src/types.ts @@ -0,0 +1,97 @@ +export type MathVariant = "normal" | "bold" | "italic" | "sans" | "monospace" | "double-struck" | "script" | "fraktur" + +export type MathNode = + | { type: "row"; body: MathNode[] } + | { type: "symbol"; value: string; role?: SymbolRole } + | { type: "text"; value: string } + | { type: "space"; width: number } + | { + type: "fraction" + numerator: MathNode + denominator: MathNode + bar: boolean + numeratorAlign?: "left" | "right" + } + | { type: "root"; body: MathNode; index?: MathNode } + | { type: "scripts"; base: MathNode; superscript?: MathNode; subscript?: MathNode } + | { type: "delimited"; left: string; body: MathNode; right: string } + | { type: "matrix"; rows: MathNode[][]; environment: MatrixEnvironment; columns?: string } + | { type: "brace"; body: MathNode; position: "over" | "under" } + | { type: "accent"; accent: AccentKind; body: MathNode } + | { type: "variant"; variant: MathVariant; body: MathNode } + | { type: "operator"; value: string; limits: boolean } + | { type: "overunder"; base: MathNode; over?: MathNode; under?: MathNode } + | { type: "color"; color: string; body: MathNode } + +export type SymbolRole = "ordinary" | "binary" | "relation" | "operator" | "punctuation" | "opening" | "closing" + +export type MatrixEnvironment = + | "matrix" + | "pmatrix" + | "bmatrix" + | "Bmatrix" + | "vmatrix" + | "Vmatrix" + | "cases" + | "aligned" + | "align" + | "gathered" + | "gather" + | "smallmatrix" + | "array" + +export type AccentKind = "hat" | "widehat" | "bar" | "overline" | "underline" | "vec" | "tilde" | "dot" | "ddot" + +export interface ParseOptions { + macros?: Readonly> + maxExpand?: number + /** + * Maximum accepted input length. This guards interactive and AI-generated + * formulas against accidentally exhausting the terminal process. + */ + maxSourceLength?: number + /** + * Maximum length after user-macro expansion. Defaults to + * `maxSourceLength`. + */ + maxExpandedLength?: number + /** Maximum structural nesting depth. */ + maxDepth?: number + strict?: boolean +} + +export class LatexParseError extends Error { + public readonly position: number + + constructor(message: string, position: number) { + super(`${message} at offset ${position}`) + this.name = "LatexParseError" + this.position = position + } +} + +export interface MathStyle { + color?: string + bold?: boolean + italic?: boolean + dim?: boolean +} + +export interface MathCell { + char: string + style?: MathStyle +} + +export interface MathLayout { + readonly width: number + readonly height: number + readonly baseline: number + readonly cells: ReadonlyArray> + toString(): string +} + +export interface RenderLatexOptions extends ParseOptions { + displayMode?: boolean + compactScripts?: boolean + color?: string +} diff --git a/packages/latex/tsconfig.json b/packages/latex/tsconfig.json new file mode 100644 index 000000000000..00ef12546856 --- /dev/null +++ b/packages/latex/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 140b4ad8c4e1..c6fe116d07ea 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -78,6 +78,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/latex": "workspace:*", "@opencode-ai/merman": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 3555a390df23..fabfaff68f3b 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" import Storybook from "../feature-plugins/system/storybook" +import Latex from "@opencode-ai/latex/plugin" import Merman from "@opencode-ai/merman/plugin" export const builtins = [ @@ -18,6 +19,7 @@ export const builtins = [ Notifications, Plugins, Merman, + Latex, // The storybook is a development tool; keep its route and palette commands out of // normal launches and register it only for OPENCODE_STORY runs. ...(process.env.OPENCODE_STORY ? [Storybook] : []), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6c7a1310f9ec..3f0d0de7aff8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2543,11 +2543,12 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes return ( + {/* Apply content before streaming so completion does not freeze the previous Markdown tokens. */} >["renderer"][] = [] +const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }) + +afterEach(() => { + renderers.splice(0).forEach((renderer) => renderer.destroy()) +}) + +test.each(["completion-first", "content-first"])("applies final fence text in a Solid batch: %s", async (order) => { + const [content, setContent] = createSignal("```text\ninitial") + const [streaming, setStreaming] = createSignal(true) + const output = await testRender( + () => ( + + ), + { width: 80, height: 12, remote: true, useThread: false }, + ) + renderers.push(output.renderer) + await output.renderOnce() + + batch(() => { + if (order === "completion-first") setStreaming(false) + setContent("```text\ninitial final\n```") + if (order === "content-first") setStreaming(false) + }) + await output.renderOnce() + + const markdown = output.renderer.root.getChildren()[0] + expect(markdown).toBeInstanceOf(MarkdownRenderable) + const block = markdown?.getChildren()[0] + expect(block).toBeInstanceOf(CodeRenderable) + if (!(block instanceof CodeRenderable)) throw new Error("Expected a code fence") + expect(block.content).toBe("initial final") + expect(markdown?.getChildren()).toHaveLength(1) +}) diff --git a/turbo.json b/turbo.json index 89ae3dc0daf0..a79aa3a83f82 100644 --- a/turbo.json +++ b/turbo.json @@ -49,6 +49,10 @@ "dependsOn": ["^build"], "outputs": [] }, + "@opencode-ai/latex#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": []