Compare commits

...
Author SHA1 Message Date
Kit Langton 9dec8f03fd fix(latex): preserve notation and connected geometry
Preserve norm delimiters, command boundaries, empty cells, array column formats, and continued-fraction options. Size indexed radicals safely, join their strokes, and place scripts and brace annotations around the full expression.
2026-08-26 15:39:15 -04:00
Kit Langton 6a6d80e4da refactor(latex): simplify parser and layout internals 2026-08-26 14:33:23 -04:00
Kit Langton 860b9c4208 fix(latex): use one color for radical strokes 2026-08-26 14:33:03 -04:00
Kit Langton e70fbcef60 chore(tui): remove the LaTeX playground story 2026-08-26 14:30:13 -04:00
Kit Langton 6600074b68 refactor(latex): match the merman package boundary
Move the engine, Markdown adapter, plugin, and tests into a private workspace package. Remove the standalone license file and dependency patch; apply final Markdown content before completion at the TUI call site.
2026-08-26 14:21:53 -04:00
Kit Langton 8b0623d174 refactor(tui): inline the Unicode math engine
Keep the Unicode parser, symbols, layout, and input limits in the TUI source tree. Remove the math package and its graphics dependencies, and retain parser and layout regression coverage.
2026-08-26 14:12:05 -04:00
Kit Langton 17b3454c2e refactor(tui): keep LaTeX rendering Unicode-only 2026-08-26 14:03:22 -04:00
Kit Langton 90fcfae247 fix(tui): reparse final markdown updates 2026-08-26 13:33:57 -04:00
Kit Langton d58689b79b feat(tui): render LaTeX math in the terminal 2026-08-26 13:14:23 -04:00
22 changed files with 2744 additions and 1 deletions
+17
View File
@@ -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"],
+25
View File
@@ -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:"
}
}
+87
View File
@@ -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)
})
})
+673
View File
@@ -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<Array<MathCell | undefined>>
}
interface LayoutContext {
displayMode: boolean
compactScripts: boolean
style?: MathStyle
variant?: MathVariant
}
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
const superscripts: Readonly<Record<string, string>> = {
"0": "⁰",
"1": "¹",
"2": "²",
"3": "³",
"4": "⁴",
"5": "⁵",
"6": "⁶",
"7": "⁷",
"8": "⁸",
"9": "⁹",
"+": "⁺",
"-": "⁻",
"=": "⁼",
"(": "⁽",
")": "⁾",
n: "ⁿ",
i: "ⁱ",
}
const subscripts: Readonly<Record<string, string>> = {
"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<MathNode, { type: "fraction" }>, 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<MathNode, { type: "scripts" }>, 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<MathNode, { type: "matrix" }>, 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<MathNode, { type: "brace" }>, 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<MathNode, { type: "accent" }>["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<MathCell | undefined>(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<Record<string, string>>): 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<Record<MathVariant, Readonly<Record<string, string>>>> = {
"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<Record<MathVariant, readonly [number, number, number?]>> = {
"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")
},
}
}
+40
View File
@@ -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)
}
}
}
+245
View File
@@ -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<ReturnType<typeof createTestRenderer>>["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)
},
)
+120
View File
@@ -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<string, LatexFrame>()
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
}
}
+23
View File
@@ -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 }),
)
})
})
+273
View File
@@ -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/)
})
})
+598
View File
@@ -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<Record<string, AccentKind>> = {
hat: "hat",
widehat: "widehat",
bar: "bar",
overline: "overline",
underline: "underline",
vec: "vec",
tilde: "tilde",
widetilde: "tilde",
dot: "dot",
ddot: "ddot",
}
const variants: Readonly<Record<string, MathVariant>> = {
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<string, string> = {
"=": "≠",
"∈": "∉",
"∋": "∌",
"≡": "≢",
"≈": "≉",
"": "≁",
"<": "≮",
">": "≯",
"≤": "≰",
"≥": "≱",
"⊂": "⊄",
"⊃": "⊅",
"⊆": "⊈",
"⊇": "⊉",
"": "∤",
"∥": "∦",
}
return negated[value] ?? `${value}̸`
}
+14
View File
@@ -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)
},
})
+87
View File
@@ -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]")
})
})
+11
View File
@@ -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()
}
+69
View File
@@ -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"))
})
})
+309
View File
@@ -0,0 +1,309 @@
import type { SymbolRole } from "./types"
export interface SymbolDefinition {
value: string
role?: SymbolRole
}
const ordinary: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
cdots: "⋯",
ldots: "…",
dots: "…",
vdots: "⋮",
ddots: "⋱",
colon: ":",
}
export const symbolTable: Readonly<Record<string, SymbolDefinition>> = {
...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<Record<string, string>> = {
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<Record<string, string>> = {
"(": "(",
")": ")",
"[": "[",
"]": "]",
"\\{": "{",
"\\}": "}",
"{": "{",
"}": "}",
"|": "│",
"\\|": "║",
vert: "│",
Vert: "║",
lvert: "│",
rvert: "│",
lVert: "║",
rVert: "║",
lbrace: "{",
rbrace: "}",
langle: "⟨",
rangle: "⟩",
lfloor: "⌊",
rfloor: "⌋",
lceil: "⌈",
rceil: "⌉",
".": "",
}
export const spacingCommands: Readonly<Record<string, number>> = {
",": 0,
":": 1,
";": 1,
"!": 0,
quad: 2,
qquad: 4,
enspace: 1,
thinspace: 0,
}
+97
View File
@@ -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<Record<string, string>>
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<ReadonlyArray<MathCell | undefined>>
toString(): string
}
export interface RenderLatexOptions extends ParseOptions {
displayMode?: boolean
compactScripts?: boolean
color?: string
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+1
View File
@@ -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:*",
+2
View File
@@ -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] : []),
+2 -1
View File
@@ -2543,11 +2543,12 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Apply content before streaming so completion does not freeze the previous Markdown tokens. */}
<markdown
syntaxStyle={syntax()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
@@ -0,0 +1,39 @@
import { afterEach, expect, test } from "bun:test"
import { CodeRenderable, MarkdownRenderable, SyntaxStyle } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
const renderers: Awaited<ReturnType<typeof testRender>>["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(
() => (
<markdown syntaxStyle={syntaxStyle} content={content()} streaming={streaming()} internalBlockMode="top-level" />
),
{ 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)
})
+4
View File
@@ -49,6 +49,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/latex#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []