Compare commits

...
Author SHA1 Message Date
Kit Langton c8af99e5f0 fix(tui): render boxed LaTeX with local fallback 2026-08-31 19:59:20 -04:00
7 changed files with 223 additions and 19 deletions
+23
View File
@@ -88,12 +88,33 @@ function layoutNode(node: MathNode, context: LayoutContext): Box {
case "text":
case "operator":
return textBox(applyVariant(node.value, context.variant), context.style)
case "raw": {
const lines = node.value.split(/\r\n?|\n/).map((line) => textBox(line.replace(/\t/g, " "), context.style))
const result = blank(Math.max(...lines.map((line) => line.width)), lines.length, 0)
lines.forEach((line, y) => overlay(result, line, 0, y))
return result
}
case "space":
return blank(node.width, 1, 0)
case "fraction":
return layoutFraction(node, context)
case "root":
return layoutRoot(node.body, node.index, context)
case "boxed": {
const body = layoutNode(node.body, context)
const result = blank(body.width + 4, body.height + 2, body.baseline + 1)
overlay(result, body, 2, 1)
for (const y of [0, result.height - 1]) {
drawHorizontal(result, y, 1, result.width - 2, "─", context.style)
setCell(result, 0, y, y === 0 ? "┌" : "└", context.style)
setCell(result, result.width - 1, y, y === 0 ? "┐" : "┘", context.style)
}
for (let y = 1; y < result.height - 1; y++) {
setCell(result, 0, y, "│", context.style)
setCell(result, result.width - 1, y, "│", context.style)
}
return result
}
case "scripts":
return layoutScripts(node, context)
case "delimited":
@@ -453,6 +474,8 @@ function textBox(text: string, style?: MathStyle): Box {
}
function blank(width: number, height: number, baseline: number): Box {
// Short source can still produce a huge rectangle, especially with uneven raw lines.
if (width * height > 1_000_000) throw new RangeError("LaTeX layout exceeds the 1000000-cell limit")
return {
width: Math.max(0, width),
height: Math.max(1, height),
+86 -4
View File
@@ -54,9 +54,76 @@ test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence",
expect(output.captureCharFrame()).not.toContain("\\frac")
})
test.each([32, 120])("renders a boxed cube root after an ordinary cube-root fence at width %s", async (width) => {
const output = await setup(
"```latex\n" +
String.raw`\sqrt[3]{3}\cdot\sqrt[3]{3}=\sqrt[3]{9}` +
"\n```\n\n```latex\n" +
String.raw`\sqrt[3]{3}\cdot\sqrt[3]{\boxed{?}}
=
\sqrt[3]{27}
=
3` +
"\n```",
width,
)
expect(output.markdown.getChildren().map((child) => child.constructor.name)).toEqual([
"ScrollBoxRenderable",
"ScrollBoxRenderable",
])
expect(output.captureCharFrame()).not.toContain("\\")
expect(output.captureCharFrame()).toContain("?")
expect(output.captureCharFrame()).toContain("= 3")
expect(output.captureCharFrame()).toContain("\u250c\u2500\u2500\u2500\u2510")
})
test.each([
String.raw`\sqrt[3]{3}\cdot\sqrt[3]{\boxed{?}}=\sqrt[3]{27}=3`,
String.raw`\sqrt [3] {3} \cdot
\sqrt [3] { \boxed
{ ? } } = \sqrt [3] {27} = 3`,
])("ignores harmless whitespace around boxed roots: %s", async (source) => {
const output = await setup(`\`\`\`latex\n${source}\n\`\`\``)
expect(output.markdown.getChildren()[0]?.constructor.name).toBe("ScrollBoxRenderable")
expect(output.captureCharFrame()).not.toContain("\\")
expect(output.captureCharFrame()).toContain("?")
expect(output.captureCharFrame()).toContain("= 3")
})
test.each([
String.raw`\unsupported{x}`,
String.raw`\cancel{\frac{a}{b}}`,
String.raw`\unknown*[label]{a}{b}`,
String.raw`\mystery`,
String.raw`\constructor{x}`,
])("degrades unknown commands locally without losing their source: %s", async (source) => {
const output = await setup(`\`\`\`latex\n\\sqrt[3]{${source}}=\\frac{1}{2}\n\`\`\``)
expect(output.markdown.getChildren()[0]?.constructor.name).toBe("ScrollBoxRenderable")
expect(output.captureCharFrame()).toContain(source)
expect(output.captureCharFrame()).toContain("3\u256d")
expect(output.captureCharFrame()).toContain("\u2570\u256f")
expect(output.captureCharFrame()).toContain("\u2500\u2500\u2500")
expect(output.captureCharFrame()).not.toContain("\\sqrt")
expect(output.captureCharFrame().match(/\\frac/g) ?? []).toHaveLength(source.includes("\\frac") ? 1 : 0)
})
test("preserves line breaks and comments inside opaque commands", async () => {
const output = await setup("```latex\n\\sqrt{\\unknown{a % comment\n b}}=1\n```")
expect(output.markdown.getChildren()[0]?.constructor.name).toBe("ScrollBoxRenderable")
const frame = output.captureCharFrame()
expect(frame).toContain("\\unknown{a % comment")
expect(frame).toContain(" b}")
expect(frame).not.toContain("\\sqrt")
expect(frame.indexOf(" b}")).toBeGreaterThan(frame.indexOf("% comment"))
})
test.each([
String.raw`\frac{1}{`,
String.raw`\unsupported{x}`,
String.raw`\boxed{?`,
String.raw`\boxed}`,
String.raw`\unsupported{x`,
String.raw`\unsupported[option`,
"x + \\",
String.raw`\cfrac[x]{1}{2}`,
String.raw`\left\unknown x\right)`,
String.raw`\begin{array}{p{2cm}}x\end{array}`,
@@ -72,6 +139,15 @@ Hello
expect(block.content).toBe(source)
})
test("falls back to source when a literal layout exceeds the cell budget", async () => {
const source = "\\unknown{" + "x".repeat(2000) + "\nx".repeat(500) + "}"
const output = await setup(`\`\`\`latex\n${source}\n\`\`\``)
const block = output.markdown.getChildren()[0]
expect(block?.constructor.name).toBe("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\|`,
@@ -149,14 +225,20 @@ test("does not reuse another fence's preview or keep a removed fence's preview",
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
})
test("does not leave a stale formula when a stream ends with invalid math", async () => {
test("renders locally preserved commands instead of a stale streaming preview", async () => {
const output = await setup("```latex\nx^2")
expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable)
const previous = output.captureCharFrame()
output.markdown.content += " + \\unsupported{x}\n```"
output.markdown.content += " + \\unsupported{"
await output.renderOnce()
expect(output.captureCharFrame()).toBe(previous)
output.markdown.content += "x}\n```"
output.markdown.streaming = false
await output.renderOnce()
expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable)
expect(output.markdown.getChildren()[0]?.constructor.name).toBe("ScrollBoxRenderable")
expect(output.captureCharFrame()).toContain("x\u00b2 + \\unsupported{x}")
})
test("keeps a matrix and surrounding Markdown intact in a narrow terminal", async () => {
+2 -2
View File
@@ -111,9 +111,9 @@ export function createLatexCodeBlockRenderer(
function layoutLatex(source: string) {
try {
return renderLatex(source, { strict: true, displayMode: true })
return renderLatex(source, { strict: true, unknownCommands: "preserve", displayMode: true })
} catch (error) {
// Preserve the exact source for incomplete math, unsupported commands, and oversized input.
// Preserve the exact source for incomplete math, unsupported structures, and oversized input.
if (error instanceof LatexParseError || error instanceof RangeError) return undefined
throw error
}
+39
View File
@@ -208,6 +208,45 @@ describe("parseLatex", () => {
expect(() => parseLatex(String.raw`\definitelyUnknown{x}`, { strict: true })).toThrow(LatexParseError)
})
test("parses boxed math structurally", () => {
expect(parseLatex(String.raw`\boxed{\frac{1}{\boxed{x}}}`, { strict: true })).toMatchObject({
type: "boxed",
body: { type: "fraction", denominator: { type: "boxed", body: { value: "x" } } },
})
})
test.each([
String.raw`\unknown{x}`,
String.raw`\constructor{x}`,
String.raw`\toString{x}`,
String.raw`\unknown*[a{[b]}]{x}{\frac{1}{2}}`,
String.raw`\unknown{left \{ only}`,
String.raw`\unknown{right \} only}`,
])("keeps unknown commands and grouped arguments opaque: %s", (source) => {
expect(parseLatex(source, { strict: true, unknownCommands: "preserve" })).toEqual({ type: "raw", value: source })
expect(() => parseLatex(source, { unknownCommands: "error" })).toThrow(LatexParseError)
})
test.each([
String.raw`\unknown{x`,
String.raw`\unknown[option`,
String.raw`\left\unknown x\right)`,
String.raw`\begin{unknown}x\end{unknown}`,
String.raw`\boxed}`,
"x + \\",
])("still rejects malformed or unsupported structures with local fallback: %s", (source) => {
expect(() => parseLatex(source, { strict: true, unknownCommands: "preserve" })).toThrow(LatexParseError)
})
test("bounds locally preserved input", () => {
expect(() =>
parseLatex(String.raw`\unknown{` + "x".repeat(100_000) + "}", { unknownCommands: "preserve" }),
).toThrow(/character limit/)
expect(() => parseLatex(String.raw`\unknown{` + "{".repeat(300), { unknownCommands: "preserve" })).toThrow(
/level limit/,
)
})
test("keeps escaped braces inside raw text groups", () => {
expect(parseLatex(String.raw`\text{left \{ only}`)).toMatchObject({
type: "text",
+31 -13
View File
@@ -63,7 +63,7 @@ const variants: Readonly<Record<string, MathVariant>> = {
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()
return new Parser(expanded, options.strict ?? false, maxDepth, options.unknownCommands).parse()
}
export function expandLatexMacros(source: string, options: ParseOptions = {}): string {
@@ -135,6 +135,7 @@ class Parser {
private readonly source: string,
private readonly strict: boolean,
private readonly maxDepth: number,
private readonly unknownCommands: ParseOptions["unknownCommands"],
) {}
public parse(): MathNode {
@@ -233,16 +234,17 @@ class Parser {
if (index) result.index = index
return result
}
if (command === "boxed") return { type: "boxed", body: this.parseArgument() }
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) {
if (Object.hasOwn(accents, command)) {
return { type: "accent", accent: accents[command], body: this.parseArgument() }
}
if (command in variants) {
if (Object.hasOwn(variants, command)) {
return {
type: "variant",
variant: variants[command],
@@ -311,12 +313,12 @@ class Parser {
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) {
if (Object.hasOwn(spacingCommands, command)) return { type: "space", width: spacingCommands[command] }
if (Object.hasOwn(symbolTable, command)) {
const symbol = symbolTable[command]
return { type: "symbol", value: symbol.value, ...(symbol.role ? { role: symbol.role } : {}) }
}
if (command in largeOperators) {
if (Object.hasOwn(largeOperators, command)) {
return { type: "operator", value: largeOperators[command], limits: !command.includes("int") }
}
if (namedOperators.has(command)) {
@@ -327,15 +329,30 @@ class Parser {
}
}
if (command === "backslash") return { type: "symbol", value: "\\" }
const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command]
const delimiter =
delimiterTable[`\\${command}`] ?? (Object.hasOwn(delimiterTable, command) ? delimiterTable[command] : undefined)
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}` }
if (this.unknownCommands === "error" || (this.strict && this.unknownCommands !== "preserve")) {
this.fail(`Unsupported command \\${command}`, start)
}
// Keep the command and its grouped arguments opaque; interpreting an unknown
// macro's contents could erase grouping or change its mathematical meaning.
if (this.peek() === "*") this.position++
let end = this.position
while (!this.done()) {
this.skipMathWhitespace()
if (this.peek() === "{") this.readRawGroup()
else if (this.peek() === "[") this.parseOptionalArgument()
else break
end = this.position
}
this.position = end
return { type: "raw", value: this.source.slice(start, end) }
}
private parseEnvironment(): MathNode {
@@ -410,7 +427,7 @@ class Parser {
private parseArgument(): MathNode {
this.skipMathWhitespace()
if (this.peek() === "{") return this.parseGroup()
if (this.done()) this.fail("Expected an argument")
if (this.done() || this.peek() === "}") this.fail("Expected an argument")
return this.parseAtom()
}
@@ -447,7 +464,8 @@ class Parser {
const start = this.position
if (this.peek() === "\\") {
const command = this.readCommand()
const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command]
const delimiter =
delimiterTable[`\\${command}`] ?? (Object.hasOwn(delimiterTable, command) ? delimiterTable[command] : undefined)
if (delimiter !== undefined) return delimiter
if (this.strict) this.fail(`Unsupported delimiter \\${command}`, start)
return `\\${command}`
@@ -458,7 +476,7 @@ class Parser {
private readCommand(): string {
this.expect("\\")
if (this.done()) return "\\"
if (this.done()) this.fail("Incomplete command")
const next = this.peek()
if (!/[A-Za-z@]/.test(next)) {
this.position++
@@ -514,7 +532,7 @@ class Parser {
if ("{}%#$&_ ".includes(command)) return command
if (command === "textbackslash") return "\\"
if (command === "!") return ""
if (command in spacingCommands) return " ".repeat(Math.max(1, spacingCommands[command]))
if (Object.hasOwn(spacingCommands, command)) return " ".repeat(Math.max(1, spacingCommands[command]))
return match
})
.replace(/~/g, " ")
+38
View File
@@ -34,6 +34,44 @@ describe("renderLatexToString", () => {
expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "╰╯x² + y²"].join("\n"))
})
test.each([
[String.raw`\boxed{?}`, ["┌───┐", "│ ? │", "└───┘"]],
[String.raw`\boxed{\frac{1}{2}}`, ["┌─────┐", "│ 1 │", "│ ─── │", "│ 2 │", "└─────┘"]],
[String.raw`\boxed{\boxed{x}}`, ["┌───────┐", "│ ┌───┐ │", "│ │ x │ │", "│ └───┘ │", "└───────┘"]],
[String.raw`\sqrt[3]{\boxed{?}}`, ["3╭─────", " │┌───┐", " ││ ? │", "╰╯└───┘"]],
])("renders boxed math without overwriting its body: %s", (source, expected) => {
expect(renderLatexToString(source, { strict: true })).toBe(expected.join("\n"))
})
test("preserves a box's baseline and style", () => {
const layout = renderLatex(String.raw`\boxed{\frac{1}{2}}=x`, { color: "red" })
expect(layout.baseline).toBe(2)
expect(layout.toString().split("\n")[layout.baseline]).toBe("│ ─── │ = x")
expect(
layout.cells
.flat()
.filter(Boolean)
.every((cell) => cell?.style?.color === "red"),
).toBe(true)
})
test("does not apply mathematical alphabets to locally preserved TeX", () => {
expect(renderLatexToString(String.raw`\mathbb{\unknown{R}}`)).toBe(String.raw`\unknown{R}`)
})
test("keeps multiline unknown content readable without dropping grouping", () => {
expect(renderLatexToString("\\unknown{a\n+ b}")).toBe("\\unknown{a\n+ b}")
expect(renderLatexToString("\\unknown{a % comment\n b}")).toBe("\\unknown{a % comment\n b}")
})
test("renders known math alongside an opaque command", () => {
expect(renderLatexToString(String.raw`\frac{1}{2}=\unknown{x}`)).toBe(" 1\n─── = \\unknown{x}\n 2")
})
test("bounds rectangular layouts from uneven literal lines", () => {
expect(() => renderLatexToString("\\unknown{" + "x".repeat(2000) + "\nx".repeat(500) + "}")).toThrow(/cell limit/)
})
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"),
+4
View File
@@ -4,6 +4,7 @@ export type MathNode =
| { type: "row"; body: MathNode[] }
| { type: "symbol"; value: string; role?: SymbolRole }
| { type: "text"; value: string }
| { type: "raw"; value: string }
| { type: "space"; width: number }
| {
type: "fraction"
@@ -13,6 +14,7 @@ export type MathNode =
numeratorAlign?: "left" | "right"
}
| { type: "root"; body: MathNode; index?: MathNode }
| { type: "boxed"; body: 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 }
@@ -58,6 +60,8 @@ export interface ParseOptions {
/** Maximum structural nesting depth. */
maxDepth?: number
strict?: boolean
/** Preserve unknown commands verbatim without relaxing structural validation. */
unknownCommands?: "error" | "preserve"
}
export class LatexParseError extends Error {