mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 06:26:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8af99e5f0 |
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user