mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 19:46:34 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dec8f03fd | ||
|
|
6a6d80e4da | ||
|
|
860b9c4208 | ||
|
|
e70fbcef60 | ||
|
|
6600074b68 | ||
|
|
8b0623d174 | ||
|
|
17b3454c2e | ||
|
|
90fcfae247 | ||
|
|
d58689b79b |
@@ -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"],
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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}̸`
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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]")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -49,6 +49,10 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/latex#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
||||
Reference in New Issue
Block a user