From 05e89b4188d1b0b4631d470300ce5960baf85a62 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 5 Aug 2026 21:41:19 +0200 Subject: [PATCH] feat(tui): render LaTeX math in Markdown --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/markdown.ts | 151 ++- packages/tui/src/index.ts | 2 + packages/tui/src/latex.ts | 1225 +++++++++++++++++++++++ packages/tui/test/latex.test.ts | 426 ++++++++ packages/tui/test/markdown.test.ts | 165 +++ 6 files changed, 1969 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/latex.ts create mode 100644 packages/tui/test/latex.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b46827fd8..29afd50c5 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -18,6 +18,7 @@ ### Added +- Added terminal-friendly Unicode rendering for LaTeX expressions in Markdown, including inline and display math, fractions, scripts, common symbols, aligned equations, cases, and matrices. - Added the shared `TuiMode` type and `mode` discriminants to the main-screen and alternate-screen TUI renderers. - Added TUI lifecycle and render-state handoff APIs for replacing renderers without replaying main-screen content. - Exported the bundled `Marked` parser and token types. diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 390df9634..608936c7b 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,4 +1,5 @@ -import { Marked, type Token, Tokenizer, type Tokens } from "marked"; +import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } from "marked"; +import { renderLatex } from "../latex.ts"; import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; import type { Component } from "../tui.ts"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; @@ -22,6 +23,126 @@ class StrictStrikethroughTokenizer extends Tokenizer { } } +interface LatexToken extends Tokens.Generic { + type: "latex" | "latexBlock"; + text: string; + pending?: boolean; +} + +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) { + backslashes++; + } + return backslashes % 2 === 1; +} + +function findClosingDelimiter(source: string, closing: string, start: number): number { + let index = source.indexOf(closing, start); + while (index >= 0 && isEscaped(source, index)) { + index = source.indexOf(closing, index + closing.length); + } + return index; +} + +function looksLikePendingDollarMath(source: string): boolean { + return /\\[A-Za-z]+|[_^=+*/<>()[\]|±≤≥≠≈∈→⇒∞∫∑√-]/.test(source); +} + +function tokenizeInlineLatex(source: string): LatexToken | undefined { + let opening = ""; + let closing = ""; + if (source.startsWith("$$")) { + opening = "$$"; + closing = "$$"; + } else if (source.startsWith("\\(")) { + opening = "\\("; + closing = "\\)"; + } else if (source.startsWith("\\[")) { + opening = "\\["; + closing = "\\]"; + } else if (source.startsWith("$") && !/^\$\s/.test(source)) { + opening = "$"; + closing = "$"; + } else { + return undefined; + } + + const closingIndex = findClosingDelimiter(source, closing, opening.length); + if ( + closingIndex >= 0 && + opening === "$" && + (/\s$/.test(source.slice(opening.length, closingIndex)) || + /^\d/.test(source.slice(closingIndex + 1)) || + (/^[A-Z_][A-Z0-9_]*(?:[^A-Za-z0-9_\s])?$/.test(source.slice(opening.length, closingIndex)) && + /^[A-Za-z_][A-Za-z0-9_]*/.test(source.slice(closingIndex + 1))) || + source.slice(opening.length, closingIndex).includes("`")) + ) { + return undefined; + } + + if (closingIndex < 0) { + const pendingSource = source.slice(opening.length); + if (opening.startsWith("\\") || looksLikePendingDollarMath(pendingSource)) { + return { type: "latex", raw: source, text: pendingSource, pending: true }; + } + return undefined; + } + + const text = source.slice(opening.length, closingIndex); + if (!text || text.includes("\n")) { + return undefined; + } + + const raw = source.slice(0, closingIndex + closing.length); + return { type: "latex", raw, text }; +} + +function tokenizeBlockLatex(source: string): LatexToken | undefined { + const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source); + if (dollarMatch?.[1]) { + return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() }; + } + + const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source); + if (bracketMatch?.[1]) { + return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() }; + } + + const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingBracket) { + return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1], pending: true }; + } + const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) { + return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true }; + } + return undefined; +} + +const LATEX_MARKDOWN_EXTENSIONS: readonly TokenizerExtension[] = [ + { + name: "latexBlock", + level: "block", + start(source) { + const match = /(?:^|\n) {0,3}(?:\$\$|\\\[)/.exec(source); + return match ? match.index + (match[0].startsWith("\n") ? 1 : 0) : undefined; + }, + tokenizer: tokenizeBlockLatex, + }, + { + name: "latex", + level: "inline", + start(source) { + const indices = [source.indexOf("$"), source.indexOf("\\("), source.indexOf("\\[")].filter( + (index) => index >= 0, + ); + return indices.length > 0 ? Math.min(...indices) : undefined; + }, + tokenizer: tokenizeInlineLatex, + }, +]; + function trimPartialClosingFences(tokens: readonly Token[]): void { const token = tokens[tokens.length - 1]; if (token?.type === "list") { @@ -51,6 +172,7 @@ const markdownParser = new Marked(); markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer(), }); +markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] }); /** * Default text styling for markdown content. @@ -102,6 +224,8 @@ export interface MarkdownOptions { preserveBackslashEscapes?: boolean; /** Transform source Markdown before parsing, with the exact width available for content. */ transform?: (markdown: string, availableWidth: number) => string; + /** Render supported LaTeX math expressions as Unicode text (default: true). */ + renderLatex?: boolean; } interface InlineStyleContext { @@ -378,6 +502,21 @@ export class Markdown implements Component { lines.push(this.renderInlineTokens([token], styleContext)); break; + case "latexBlock": { + const latexToken = token as LatexToken; + const rendered = + !latexToken.pending && this.options.renderLatex !== false + ? (renderLatex(latexToken.text, { display: true }) ?? latexToken.raw.trim()) + : latexToken.raw.trim(); + for (const line of rendered.split("\n")) { + lines.push(this.applyDefaultStyle(line)); + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "code": { const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); @@ -503,6 +642,16 @@ export class Markdown implements Component { for (const token of tokens) { switch (token.type) { + case "latex": { + const latexToken = token as LatexToken; + const rendered = + !latexToken.pending && this.options.renderLatex !== false + ? (renderLatex(latexToken.text) ?? latexToken.raw) + : latexToken.raw; + result += applyTextWithNewlines(rendered); + break; + } + case "escape": result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text); break; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 708063f2b..00e9d414f 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -67,6 +67,8 @@ export { parseKey, setKittyProtocolActive, } from "./keys.ts"; +// LaTeX rendering +export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts new file mode 100644 index 000000000..02be3b7ac --- /dev/null +++ b/packages/tui/src/latex.ts @@ -0,0 +1,1225 @@ +import { visibleWidth } from "./utils.ts"; + +const SYMBOLS: Readonly> = { + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ϵ", + varepsilon: "ε", + zeta: "ζ", + eta: "η", + theta: "θ", + vartheta: "ϑ", + iota: "ι", + kappa: "κ", + varkappa: "ϰ", + lambda: "λ", + mu: "μ", + nu: "ν", + xi: "ξ", + pi: "π", + varpi: "ϖ", + rho: "ρ", + varrho: "ϱ", + sigma: "σ", + varsigma: "ς", + tau: "τ", + upsilon: "υ", + phi: "ϕ", + varphi: "φ", + chi: "χ", + psi: "ψ", + omega: "ω", + Gamma: "Γ", + Delta: "Δ", + Theta: "Θ", + Lambda: "Λ", + Xi: "Ξ", + Pi: "Π", + Sigma: "Σ", + Upsilon: "Υ", + Phi: "Φ", + Psi: "Ψ", + Omega: "Ω", + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + ast: "∗", + star: "⋆", + circ: "∘", + bullet: "•", + oplus: "⊕", + ominus: "⊖", + otimes: "⊗", + oslash: "⊘", + odot: "⊙", + bigcirc: "○", + dagger: "†", + ddagger: "‡", + amalg: "⨿", + uplus: "⊎", + sqcap: "⊓", + sqcup: "⊔", + triangleleft: "◁", + triangleright: "▷", + wr: "≀", + cap: "∩", + cup: "∪", + bigcap: "⋂", + bigcup: "⋃", + bigwedge: "⋀", + bigvee: "⋁", + bigsqcup: "⨆", + biguplus: "⨄", + bigoplus: "⨁", + bigotimes: "⨂", + bigodot: "⨀", + setminus: "∖", + in: "∈", + notin: "∉", + ni: "∋", + subset: "⊂", + supset: "⊃", + subseteq: "⊆", + supseteq: "⊇", + sqsubset: "⊏", + sqsupset: "⊐", + sqsubseteq: "⊑", + sqsupseteq: "⊒", + prec: "≺", + preceq: "≼", + succ: "≻", + succeq: "≽", + ll: "≪", + gg: "≫", + le: "≤", + leq: "≤", + leqslant: "≤", + ge: "≥", + geq: "≥", + geqslant: "≥", + ne: "≠", + neq: "≠", + equiv: "≡", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + asymp: "≍", + doteq: "≐", + propto: "∝", + parallel: "∥", + perp: "⊥", + mid: "∣", + vdash: "⊢", + dashv: "⊣", + models: "⊨", + Vdash: "⊩", + Vvdash: "⊪", + nvdash: "⊬", + nvDash: "⊭", + forall: "∀", + exists: "∃", + nexists: "∄", + neg: "¬", + land: "∧", + wedge: "∧", + lor: "∨", + vee: "∨", + to: "→", + rightarrow: "→", + longrightarrow: "→", + leftarrow: "←", + longleftarrow: "←", + gets: "←", + leftrightarrow: "↔", + longleftrightarrow: "↔", + hookleftarrow: "↩", + hookrightarrow: "↪", + twoheadleftarrow: "↞", + twoheadrightarrow: "↠", + leftharpoonup: "↼", + leftharpoondown: "↽", + rightharpoonup: "⇀", + rightharpoondown: "⇁", + rightleftharpoons: "⇌", + leftrightharpoons: "⇋", + nearrow: "↗", + searrow: "↘", + swarrow: "↙", + nwarrow: "↖", + rightsquigarrow: "⇝", + leadsto: "⇝", + Rightarrow: "⇒", + Longrightarrow: "⇒", + Leftarrow: "⇐", + Longleftarrow: "⇐", + Leftrightarrow: "⇔", + Longleftrightarrow: "⇔", + implies: "⇒", + iff: "⇔", + mapsto: "↦", + longmapsto: "↦", + uparrow: "↑", + downarrow: "↓", + partial: "∂", + nabla: "∇", + int: "∫", + iint: "∬", + iiint: "∭", + oint: "∮", + sum: "∑", + prod: "∏", + coprod: "∐", + infty: "∞", + emptyset: "∅", + varnothing: "∅", + angle: "∠", + therefore: "∴", + because: "∵", + aleph: "ℵ", + beth: "ℶ", + gimel: "ℷ", + daleth: "ℸ", + top: "⊤", + bot: "⊥", + triangle: "△", + square: "□", + lozenge: "◊", + checkmark: "✓", + complement: "∁", + wp: "℘", + prime: "′", + ldots: "…", + dots: "…", + cdots: "⋯", + vdots: "⋮", + ddots: "⋱", + ell: "ℓ", + hbar: "ℏ", + Im: "ℑ", + Re: "ℜ", + langle: "⟨", + rangle: "⟩", + vert: "|", + lvert: "|", + rvert: "|", + Vert: "‖", + lVert: "‖", + rVert: "‖", + lbrace: "{", + rbrace: "}", + backslash: "\\", + lfloor: "⌊", + rfloor: "⌋", + lceil: "⌈", + rceil: "⌉", + colon: ":", +}; + +const NAMED_OPERATORS = 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", + "Pr", + "sec", + "sin", + "sinh", + "sup", + "tan", + "tanh", +]); + +const LIMIT_OPERATORS = new Set([ + "argmax", + "argmin", + "inf", + "injlim", + "lim", + "liminf", + "limsup", + "max", + "min", + "projlim", + "sup", +]); + +const DISPLAY_LIMIT_SYMBOLS = new Set([ + "bigcap", + "bigcup", + "bigodot", + "bigoplus", + "bigotimes", + "bigsqcup", + "biguplus", + "bigvee", + "bigwedge", + "coprod", + "int", + "iint", + "iiint", + "oint", + "prod", + "sum", +]); + +const NEGATED_SYMBOLS: Readonly> = { + "<": "≮", + ">": "≯", + "=": "≠", + "∈": "∉", + "∋": "∌", + "∣": "∤", + "∥": "∦", + "∼": "≁", + "≃": "≄", + "≅": "≇", + "≈": "≉", + "≡": "≢", + "≤": "≰", + "≥": "≱", + "≺": "⊀", + "≻": "⊁", + "⊂": "⊄", + "⊃": "⊅", + "⊆": "⊈", + "⊇": "⊉", + "⊢": "⊬", + "⊨": "⊭", + "↔": "↮", + "←": "↚", + "→": "↛", + "⇒": "⇏", + "⇐": "⇍", + "⇔": "⇎", + "≼": "⋠", + "≽": "⋡", +}; + +const BLACKBOARD: Readonly> = { + C: "ℂ", + H: "ℍ", + N: "ℕ", + P: "ℙ", + Q: "ℚ", + R: "ℝ", + Z: "ℤ", +}; + +const SUPERSCRIPTS: Readonly> = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "+": "⁺", + "-": "⁻", + "=": "⁼", + "(": "⁽", + ")": "⁾", + a: "ᵃ", + b: "ᵇ", + c: "ᶜ", + d: "ᵈ", + e: "ᵉ", + f: "ᶠ", + g: "ᵍ", + h: "ʰ", + i: "ⁱ", + j: "ʲ", + k: "ᵏ", + l: "ˡ", + m: "ᵐ", + n: "ⁿ", + o: "ᵒ", + p: "ᵖ", + r: "ʳ", + s: "ˢ", + t: "ᵗ", + u: "ᵘ", + v: "ᵛ", + w: "ʷ", + x: "ˣ", + y: "ʸ", + z: "ᶻ", +}; + +const SUBSCRIPTS: Readonly> = { + "0": "₀", + "1": "₁", + "2": "₂", + "3": "₃", + "4": "₄", + "5": "₅", + "6": "₆", + "7": "₇", + "8": "₈", + "9": "₉", + "+": "₊", + "-": "₋", + "=": "₌", + "(": "₍", + ")": "₎", + a: "ₐ", + e: "ₑ", + h: "ₕ", + i: "ᵢ", + j: "ⱼ", + k: "ₖ", + l: "ₗ", + m: "ₘ", + n: "ₙ", + o: "ₒ", + p: "ₚ", + r: "ᵣ", + s: "ₛ", + t: "ₜ", + u: "ᵤ", + v: "ᵥ", + x: "ₓ", +}; + +const SPACING_COMMANDS = new Set([ + ",", + ":", + ";", + " ", + ">", + "enspace", + "enskip", + "medspace", + "quad", + "qquad", + "thickspace", + "thinspace", +]); +const NEGATIVE_SPACING_COMMANDS = new Set(["!", "negmedspace", "negthickspace", "negthinspace"]); +const NEGATIVE_SPACE = "\u0000"; +const IGNORED_COMMANDS = new Set([ + "displaystyle", + "limits", + "nolimits", + "scriptstyle", + "scriptscriptstyle", + "textstyle", +]); +const SIZE_COMMANDS = new Set([ + "big", + "Big", + "bigg", + "Bigg", + "bigl", + "Bigl", + "biggl", + "Biggl", + "bigr", + "Bigr", + "biggr", + "Biggr", +]); +const PLAIN_WRAPPERS = new Set([ + "emph", + "mathcal", + "mathbf", + "mathfrak", + "mathit", + "mathrm", + "mathnormal", + "mathscr", + "mathsf", + "mathtt", + "mathup", + "mbox", + "overbrace", + "pmb", + "smash", + "substack", + "text", + "textbf", + "textit", + "textmd", + "textnormal", + "textrm", + "textsc", + "textsf", + "textsl", + "texttt", + "textup", + "underbrace", + "bm", + "boldsymbol", +]); +const ACCENTS: Readonly> = { + acute: "\u0301", + bar: "\u0305", + breve: "\u0306", + check: "\u030c", + ddot: "\u0308", + dot: "\u0307", + grave: "\u0300", + hat: "\u0302", + mathring: "\u030a", + overleftarrow: "\u20d6", + overleftrightarrow: "\u20e1", + overline: "\u0305", + overrightarrow: "\u20d7", + tilde: "\u0303", + underline: "\u0332", + vec: "\u20d7", + widehat: "\u0302", + widetilde: "\u0303", +}; + +function replaceCharacters(value: string, replacements: Readonly>): string | undefined { + let result = ""; + for (const character of value) { + const replacement = replacements[character]; + if (replacement === undefined) { + return undefined; + } + result += replacement; + } + return result; +} + +function formatScript(value: string, kind: "sub" | "sup"): string { + value = value.trim(); + const replacements = kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS; + const unicode = replaceCharacters(value, replacements); + if (unicode !== undefined) { + return unicode; + } + + const prefix = kind === "sub" ? "_" : "^"; + if (Array.from(value).length === 1 || (kind === "sub" && /^[A-Za-z]+$/.test(value))) { + return `${prefix}${value}`; + } + return `${prefix}(${value})`; +} + +function formatFraction(numerator: string, denominator: string): string { + numerator = numerator.trim(); + denominator = denominator.trim(); + const simpleNumerator = /^[\p{L}\p{N}.]+$/u.test(numerator); + const simpleDenominator = /^[\p{N}.]+$/u.test(denominator) || Array.from(denominator).length === 1; + return `${simpleNumerator ? numerator : `(${numerator})`}/${simpleDenominator ? denominator : `(${denominator})`}`; +} + +function formatRoot(value: string, symbol = "√"): string { + value = value.trim(); + return /^[\p{L}\p{N}.]+$/u.test(value) ? `${symbol}${value}` : `${symbol}(${value})`; +} + +function normalizeOutput(value: string): string { + return value + .split("\n") + .map((line) => line.replace(/[ \t]+/g, " ").trim()) + .filter((line, index, lines) => line.length > 0 || (index > 0 && index < lines.length - 1)) + .join("\n") + .trim(); +} + +interface FractionNode { + type: "fraction"; + numerator: string; + denominator: string; +} + +interface OperatorNode { + type: "operator"; + operator: string; + lower?: string; + upper?: string; +} + +type LayoutNode = FractionNode | OperatorNode; + +interface Layout { + lines: string[]; + width: number; + baseline: number; +} + +const LAYOUT_MARKER_START = "\u{f0000}"; +const LAYOUT_MARKER_END = "\u{f0001}"; +const LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}/gu; +const PROTECTED_SPACE = "\u00a0"; + +function padLayoutLine(line: string, width: number, centered = false): string { + const padding = Math.max(0, width - visibleWidth(line)); + const left = centered ? Math.floor(padding / 2) : 0; + return `${" ".repeat(left)}${line}${" ".repeat(padding - left)}`; +} + +function joinLayouts(layouts: readonly Layout[]): Layout { + if (layouts.length === 0) { + return { lines: [""], width: 0, baseline: 0 }; + } + const baseline = Math.max(...layouts.map((layout) => layout.baseline)); + const below = Math.max(...layouts.map((layout) => layout.lines.length - layout.baseline - 1)); + const lines: string[] = []; + for (let row = 0; row <= baseline + below; row++) { + let line = ""; + for (const layout of layouts) { + const sourceRow = row - baseline + layout.baseline; + line += + sourceRow >= 0 && sourceRow < layout.lines.length + ? padLayoutLine(layout.lines[sourceRow] ?? "", layout.width) + : " ".repeat(layout.width); + } + lines.push(line.trimEnd()); + } + return { + lines, + width: layouts.reduce((width, layout) => width + layout.width, 0), + baseline, + }; +} + +function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { + const renderedLines: string[] = []; + let firstBaseline = 0; + for (const sourceLine of source.split("\n")) { + const layouts: Layout[] = []; + let position = 0; + let previousWasNode = false; + for (const match of sourceLine.matchAll(LAYOUT_MARKER_PATTERN)) { + const index = match.index; + if (index > position) { + const sliced = sourceLine.slice(position, index); + const text = (previousWasNode ? sliced.trimStart() : sliced).trimEnd(); + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + const node = nodes[Number(match[1])]; + if (!node) { + continue; + } + if (node.type === "fraction") { + const numerator = renderLayout(node.numerator, nodes); + const denominator = renderLayout(node.denominator, nodes); + const contentWidth = Math.max(numerator.width, denominator.width, 1); + const width = contentWidth + 2; + layouts.push({ + lines: [ + ...numerator.lines.map((line) => padLayoutLine(line, width, true)), + ` ${"─".repeat(contentWidth)} `, + ...denominator.lines.map((line) => padLayoutLine(line, width, true)), + ], + width, + baseline: numerator.lines.length, + }); + } else { + const contentWidth = Math.max( + visibleWidth(node.operator), + node.lower === undefined ? 0 : visibleWidth(node.lower), + node.upper === undefined ? 0 : visibleWidth(node.upper), + ); + const lines: string[] = []; + if (node.upper !== undefined) { + lines.push(`${padLayoutLine(node.upper, contentWidth, true)} `); + } + lines.push(`${padLayoutLine(node.operator, contentWidth, true)} `); + if (node.lower !== undefined) { + lines.push(`${padLayoutLine(node.lower, contentWidth, true)} `); + } + layouts.push({ + lines, + width: contentWidth + 1, + baseline: node.upper === undefined ? 0 : 1, + }); + } + position = index + match[0].length; + previousWasNode = true; + } + if (position < sourceLine.length) { + const sliced = sourceLine.slice(position); + const text = previousWasNode ? sliced.trimStart() : sliced; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + const lineLayout = joinLayouts(layouts); + if (renderedLines.length === 0) { + firstBaseline = lineLayout.baseline; + } + renderedLines.push(...lineLayout.lines); + } + return { + lines: renderedLines, + width: Math.max(0, ...renderedLines.map((line) => visibleWidth(line))), + baseline: firstBaseline, + }; +} + +class LatexParser { + private readonly source: string; + private readonly layoutNodes: LayoutNode[] | undefined; + private position = 0; + private supported = true; + private stackFractions = true; + + constructor(source: string, layoutNodes?: LayoutNode[]) { + this.source = source; + this.layoutNodes = layoutNodes; + } + + render(): string | undefined { + const rendered = this.parseSequence(); + if (!this.supported || this.position !== this.source.length) { + return undefined; + } + return normalizeOutput(rendered); + } + + private parseSequence(endCharacter?: string): string { + let result = ""; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (endCharacter && character === endCharacter) { + this.position++; + return result; + } + + if (character === "}") { + this.supported = false; + return result; + } + + if (character === "{") { + this.position++; + result += this.parseSequence("}"); + continue; + } + + if (character === "\\") { + const command = this.parseCommand(); + if (command === NEGATIVE_SPACE) { + result = result.trimEnd(); + } else { + result += command; + } + continue; + } + + if (character === "^" || character === "_") { + this.position++; + result = result.trimEnd(); + result += formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + continue; + } + + if (/\s/.test(character)) { + result += this.parseWhitespace(); + continue; + } + + if (character === "&") { + this.position++; + continue; + } + + if (character === "~") { + this.position++; + result += " "; + continue; + } + + result += character; + this.position++; + } + + if (endCharacter) { + this.supported = false; + } + return result; + } + + private parseWhitespace(): string { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + return " "; + } + + private parseCommand(): string { + this.position++; + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + + let command = ""; + const first = this.source[this.position] ?? ""; + if (/[A-Za-z]/.test(first)) { + const start = this.position; + while (this.position < this.source.length && /[A-Za-z]/.test(this.source[this.position] ?? "")) { + this.position++; + } + command = this.source.slice(start, this.position); + } else { + command = first; + this.position++; + } + + if (command === "\\") { + return "\n"; + } + if (SPACING_COMMANDS.has(command)) { + return " "; + } + if (NEGATIVE_SPACING_COMMANDS.has(command)) { + return NEGATIVE_SPACE; + } + if (IGNORED_COMMANDS.has(command)) { + return ""; + } + if ( + command === "{" || + command === "}" || + command === "$" || + command === "%" || + command === "#" || + command === "_" || + command === "&" + ) { + return command; + } + if (command === "|") { + return "‖"; + } + if (command === "not") { + const value = this.parseRequiredArgument(false).trim(); + const negated = NEGATED_SYMBOLS[value]; + if (negated !== undefined) { + return negated; + } + const characters = Array.from(value); + if (characters.length === 0) { + this.supported = false; + return ""; + } + return `${characters[0]}\u0338${characters.slice(1).join("")}`; + } + if (LIMIT_OPERATORS.has(command)) { + return this.parseOperator(command, "bracket", true, true); + } + + const symbol = SYMBOLS[command]; + if (symbol !== undefined) { + return DISPLAY_LIMIT_SYMBOLS.has(command) ? this.parseOperator(symbol, "script", true) : symbol; + } + if (NAMED_OPERATORS.has(command)) { + return ` ${command} `; + } + if (SIZE_COMMANDS.has(command)) { + return ""; + } + if (command === "left" || command === "middle" || command === "right") { + if (this.source[this.position] === ".") { + this.position++; + } + return ""; + } + if (command === "frac" || command === "dfrac" || command === "tfrac") { + const shouldStack = this.layoutNodes !== undefined && this.stackFractions && command !== "tfrac"; + const numerator = this.parseRequiredArgument(!shouldStack); + const denominator = this.parseRequiredArgument(!shouldStack); + if (shouldStack && this.layoutNodes) { + const index = + this.layoutNodes.push({ + type: "fraction", + numerator: normalizeOutput(numerator), + denominator: normalizeOutput(denominator), + }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + return formatFraction(numerator, denominator); + } + if (command === "sqrt") { + const degree = this.parseOptionalArgument()?.trim(); + const value = this.parseRequiredArgument(); + if (degree === undefined || degree === "2") { + return formatRoot(value); + } + if (degree === "3") { + return formatRoot(value, "∛"); + } + if (degree === "4") { + return formatRoot(value, "∜"); + } + return `${formatScript(degree, "sup")}${formatRoot(value)}`; + } + if (command === "boxed" || command === "fbox") { + return `[${this.parseRequiredArgument().trim()}]`; + } + if (command === "binom" || command === "dbinom" || command === "tbinom") { + return `(${this.parseRequiredArgument()} choose ${this.parseRequiredArgument()})`; + } + const accent = ACCENTS[command]; + if (accent !== undefined) { + const value = this.parseRequiredArgument(); + return Array.from(value).length === 1 ? `${value}${accent}` : `${command}(${value})`; + } + if (command === "mathbb") { + const value = this.parseRequiredArgument(); + return Array.from(value, (character) => BLACKBOARD[character] ?? character).join(""); + } + if (command === "operatorname") { + const starred = this.source[this.position] === "*"; + if (starred) { + this.position++; + } + const operator = normalizeOutput(this.parseRequiredArgument()).trim(); + return this.parseOperator(operator, "bracket", starred, true); + } + if (command === "mod" || command === "bmod") { + return " mod "; + } + if (command === "pmod" || command === "pod") { + const value = this.parseRequiredArgument().trim(); + return command === "pmod" ? ` (mod ${value})` : ` (${value})`; + } + if (command === "overset" || command === "stackrel") { + const upper = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(upper, "sup")}`; + } + if (command === "underset") { + const lower = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(lower, "sub")}`; + } + if (PLAIN_WRAPPERS.has(command)) { + const value = this.parseRequiredArgument(); + return command.startsWith("text") || command === "mbox" ? value : value.trim(); + } + if (command === "begin") { + return this.parseEnvironment(); + } + if (command === "end") { + this.supported = false; + return ""; + } + + this.supported = false; + return `\\${command}`; + } + + private parseOperator( + operator: string, + inlineLowerStyle: "bracket" | "script", + displayLimits: boolean, + spaced = false, + ): string { + let useDisplayLimits = displayLimits; + let modifierPosition = this.position; + while (modifierPosition < this.source.length && /[ \t]/.test(this.source[modifierPosition] ?? "")) { + modifierPosition++; + } + const modifier = /^\\(limits|nolimits)(?![A-Za-z])/.exec(this.source.slice(modifierPosition)); + if (modifier) { + useDisplayLimits = modifier[1] === "limits"; + this.position = modifierPosition + modifier[0].length; + } + + let lower: string | undefined; + let upper: string | undefined; + while (true) { + let scriptPosition = this.position; + while (scriptPosition < this.source.length && /[ \t]/.test(this.source[scriptPosition] ?? "")) { + scriptPosition++; + } + const kind = this.source[scriptPosition]; + if (kind !== "_" && kind !== "^") { + break; + } + this.position = scriptPosition + 1; + const value = normalizeOutput(this.parseRequiredArgument(false)).replaceAll(" ", ""); + if (kind === "_") { + if (lower !== undefined) { + this.supported = false; + } + lower = value; + } else { + if (upper !== undefined) { + this.supported = false; + } + upper = value; + } + } + + if (this.layoutNodes && useDisplayLimits && (lower !== undefined || upper !== undefined)) { + const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + + let rendered = operator; + if (lower !== undefined) { + rendered += inlineLowerStyle === "bracket" ? `[${lower}]` : formatScript(lower, "sub"); + } + if (upper !== undefined) { + rendered += formatScript(upper, "sup"); + } + return spaced ? ` ${rendered} ` : rendered; + } + + private parseRequiredArgument(stackFractions = true): string { + const previousStackFractions = this.stackFractions; + this.stackFractions = previousStackFractions && stackFractions; + const value = this.parseRequiredArgumentValue(); + this.stackFractions = previousStackFractions; + return value; + } + + private parseRequiredArgumentValue(): string { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + if (this.source[this.position] === "{") { + this.position++; + return this.parseSequence("}"); + } + if (this.source[this.position] === "\\") { + return this.parseCommand(); + } + const value = this.source[this.position] ?? ""; + this.position++; + return value; + } + + private parseOptionalArgument(): string | undefined { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "[") { + return undefined; + } + const end = this.source.indexOf("]", this.position + 1); + if (end < 0) { + this.supported = false; + return undefined; + } + const value = this.source.slice(this.position + 1, end); + this.position = end + 1; + return this.renderNested(value); + } + + private readRawGroup(): string | undefined { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "{") { + this.supported = false; + return undefined; + } + + const start = ++this.position; + let depth = 1; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (character === "\\") { + this.position += 2; + continue; + } + if (character === "{") depth++; + if (character === "}") depth--; + if (depth === 0) { + const value = this.source.slice(start, this.position); + this.position++; + return value; + } + this.position++; + } + this.supported = false; + return undefined; + } + + private splitEnvironmentRows(body: string): string[] { + return body.split(/\\\\(?:\[[^\]\n]*\])?/); + } + + private parseEnvironment(): string { + const environment = this.readRawGroup(); + if (!environment) { + return ""; + } + const endMarker = `\\end{${environment}}`; + const end = this.source.indexOf(endMarker, this.position); + if (end < 0) { + this.supported = false; + return ""; + } + const body = this.source.slice(this.position, end); + this.position = end + endMarker.length; + + if (environment === "equation" || environment === "equation*" || environment === "displaymath") { + return this.renderNested(body).trim(); + } + + if ( + environment === "aligned" || + environment === "align" || + environment === "align*" || + environment === "alignedat" || + environment === "alignat" || + environment === "alignat*" || + environment === "gather" || + environment === "gathered" || + environment === "multline" || + environment === "multline*" || + environment === "split" + ) { + const alignedAt = ["alignedat", "alignat", "alignat*"].includes(environment); + const alignedBody = alignedAt ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.splitEnvironmentRows(alignedBody) + .map((row) => { + const cells = row.split("&"); + const source = alignedAt + ? Array.from({ length: Math.ceil(cells.length / 2) }, (_, index) => + cells.slice(index * 2, index * 2 + 2).join(""), + ).join(" ") + : cells.join(""); + return this.renderNested(source).trim(); + }) + .filter(Boolean) + .join("\n"); + } + + if (environment === "cases" || environment === "cases*") { + const rows = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + return rows + .map((row, index) => { + const value = (row[0] ?? "").replace(/,\s*$/, ""); + const condition = row[1] ?? ""; + const delimiter = index === 0 ? "⎧" : index === rows.length - 1 ? "⎩" : "⎨"; + const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; + return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`; + }) + .join("\n"); + } + + if ( + ["array", "matrix", "smallmatrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix"].includes(environment) + ) { + const matrixBody = environment === "array" ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.renderMatrix(environment, matrixBody); + } + + this.supported = false; + return body; + } + + private renderMatrix(environment: string, body: string): string { + const matrix = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + const columnCount = Math.max(0, ...matrix.map((row) => row.length)); + const columnWidths = Array.from({ length: columnCount }, (_, column) => + Math.max(0, ...matrix.map((row) => visibleWidth(row[column] ?? ""))), + ); + const rows = matrix.map((row) => + Array.from({ length: columnCount }, (_, column) => { + const cell = row[column] ?? ""; + return `${cell}${PROTECTED_SPACE.repeat(Math.max(0, (columnWidths[column] ?? 0) - visibleWidth(cell)))}`; + }).join(" │ "), + ); + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + return rows.join("\n"); + } + + const delimiters: Readonly> = { + pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], + bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], + Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], + vmatrix: ["│", "│", "│", "│", "│", "│"], + Vmatrix: ["║", "║", "║", "║", "║", "║"], + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + if (rows.length === 1) { + return `${delimiter[0]} ${rows[0]} ${delimiter[1]}`; + } + return rows + .map((row, index) => { + const left = index === 0 ? delimiter[0] : index === rows.length - 1 ? delimiter[4] : delimiter[2]; + const right = index === 0 ? delimiter[1] : index === rows.length - 1 ? delimiter[5] : delimiter[3]; + return `${left} ${row} ${right}`; + }) + .join("\n"); + } + + private renderNested(source: string, stackFractions = true): string { + const rendered = new LatexParser(source, stackFractions ? this.layoutNodes : undefined).render(); + if (rendered === undefined) { + this.supported = false; + return source; + } + return rendered; + } +} + +export interface RenderLatexOptions { + /** Stack fractions and operator limits vertically for display math (default: false). */ + display?: boolean; +} + +/** + * Render a basic LaTeX math expression as terminal-friendly Unicode text. + * Returns undefined when the expression contains unsupported or malformed syntax. + */ +export function renderLatex(source: string, options: RenderLatexOptions = {}): string | undefined { + const layoutNodes: LayoutNode[] | undefined = options.display ? [] : undefined; + const rendered = new LatexParser(source, layoutNodes).render(); + if (rendered === undefined) { + return undefined; + } + if (!layoutNodes || layoutNodes.length === 0) { + return rendered.replaceAll(PROTECTED_SPACE, " "); + } + const lines = renderLayout(rendered, layoutNodes).lines; + const indentation = Math.min( + ...lines.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length), + ); + return lines + .map((line) => line.slice(indentation).trimEnd()) + .join("\n") + .trimEnd() + .replaceAll(PROTECTED_SPACE, " "); +} diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts new file mode 100644 index 000000000..d6d65e5f2 --- /dev/null +++ b/packages/tui/test/latex.test.ts @@ -0,0 +1,426 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { renderLatex } from "../src/index.ts"; + +type LatexCase = readonly [source: string, expected: string]; + +function defineCases(cases: readonly LatexCase[]): void { + for (const [source, expected] of cases) { + it(`renders ${JSON.stringify(source)}`, () => { + assert.strictEqual(renderLatex(source), expected); + }); + } +} + +describe("renderLatex", () => { + describe("Jacobian conjecture session using dollar delimiters", () => { + defineCases([ + [String.raw`\mathbb{C}^3 \to \mathbb{C}^3`, "ℂ³ → ℂ³"], + [ + String.raw`\{3x+2y,\; 27x^2-4z-1,\; x(x-1)(x+1)\} \quad\Rightarrow\quad x \in \{0, \pm 1\},`, + "{3x+2y, 27x²-4z-1, x(x-1)(x+1)} ⇒ x ∈ {0, ± 1},", + ], + [String.raw`F_1 = -\frac{1}{4x^2}.`, "F₁ = -1/(4x²)."], + ["-2", "-2"], + ["(0,0,-1/4)", "(0,0,-1/4)"], + ["(1,-3/2,13/2)", "(1,-3/2,13/2)"], + ["(1,1,1)", "(1,1,1)"], + ["(2,1,0)", "(2,1,0)"], + ["(-1/4, 0, 0)", "(-1/4, 0, 0)"], + [String.raw`\{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)\}`, "{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)}"], + ["(2,1,1)", "(2,1,1)"], + ["(7/3,-2/5,11/7)", "(7/3,-2/5,11/7)"], + [String.raw`\{y - p(x),\; q(x)\}`, "{y - p(x), q(x)}"], + [String.raw`\deg q = 3`, "deg q = 3"], + [String.raw`[\mathbb{C}(x,y,z):\mathbb{C}(F_1,F_2,F_3)] = 3`, "[ℂ(x,y,z):ℂ(F₁,F₂,F₃)] = 3"], + ["u = 1+xy", "u = 1+xy"], + ["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"], + ["F_1 = uG", "F₁ = uG"], + ["F_2 = y + 3xG", "F₂ = y + 3xG"], + ["x=0", "x=0"], + ["F_2 = F_3 = 0", "F₂ = F₃ = 0"], + ["xy = -3/2", "xy = -3/2"], + ["x^2 z = 13/2", "x² z = 13/2"], + [String.raw`\mathbb{C}^*`, "ℂ^*"], + [String.raw`s \mapsto (s,\, -\tfrac{3}{2s},\, \tfrac{13}{2s^2})`, "s ↦ (s, -3/(2s), 13/(2s²))"], + ["X", "X"], + [String.raw`p_\pm`, "p_±"], + ["F(-x,-y,z) = (F_1, -F_2, -F_3)", "F(-x,-y,z) = (F₁, -F₂, -F₃)"], + ["p_0", "p₀"], + [String.raw`s \to \infty`, "s → ∞"], + ["(0,0,0)", "(0,0,0)"], + [String.raw`\Rightarrow`, "⇒"], + [String.raw`\ge 2`, "≥ 2"], + [String.raw`\ge 3`, "≥ 3"], + ["1", "1"], + [String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"], + ["4+3xy", "4+3xy"], + ]); + }); + + describe("satellite calculation session using bracket delimiters", () => { + defineCases([ + [ + String.raw`E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} = 0.001\ \text{W/m}^2`, + "E ≈ (0.1 lux)/(100 lm/W) = 0.001 W/m²", + ], + [String.raw`\boxed{1\ \text{milliwatt per square metre}}`, "[1 milliwatt per square metre]"], + [String.raw`5\ \text{km}^2 = 5{,}000{,}000\ \text{m}^2`, "5 km² = 5,000,000 m²"], + [ + String.raw`P_{\text{light}} = 0.001 \times 5{,}000{,}000 += \boxed{5{,}000\ \text{W}}`, + "P_light = 0.001 × 5,000,000 = [5,000 W]", + ], + [ + String.raw`P_{\text{electric}} = 5\ \text{kW} \times 0.2 += \boxed{1\ \text{kW}}`, + "P_electric = 5 kW × 0.2 = [1 kW]", + ], + [String.raw`\pi(2.5\ \text{km})^2 = 19.6\ \text{km}^2`, "π(2.5 km)² = 19.6 km²"], + [ + String.raw`0.001\ \text{W/m}^2 \times 19.6 \times 10^6\ \text{m}^2 +\approx \boxed{20\ \text{kW optical}}`, + "0.001 W/m² × 19.6 × 10⁶ m² ≈ [20 kW optical]", + ], + [ + String.raw`1\ \text{kW} \times \frac{1}{3600}\ \text{hour} += \boxed{0.28\ \text{Wh}}`, + "1 kW × 1/3600 hour = [0.28 Wh]", + ], + ]); + }); + + describe("Jacobian conjecture sessions using parenthesis and bracket delimiters", () => { + defineCases([ + [ + String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`, + "det((∂(F₁,F₂,F₃))/(∂(x,y,z)))=-2.", + ], + [ + String.raw`\begin{aligned} +F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\ +F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\ +F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). +\end{aligned}`, + "F(0,0,-1/4)=(-1/4,0,0),\nF(1,-3/2,13/2)=(-1/4,0,0),\nF(-1,3/2,13/2)=(-1/4,0,0).", + ], + ["F=(F_1,F_2,F_3)", "F=(F₁,F₂,F₃)"], + ["F", "F"], + ["3", "3"], + ]); + }); + + describe("Jacobian matrix session using dollar delimiters", () => { + defineCases([ + [ + String.raw`J = \begin{pmatrix} +\frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} & \frac{\partial f_1}{\partial z} \\ +\frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\ +\frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z} +\end{pmatrix}`, + "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", + ], + [ + String.raw`\begin{aligned} +f_1 &= (1+xy)^3 z + y^2(1+xy)(4+3xy) \\ +f_2 &= y + 3x(1+xy)^2 z + 3xy^2(4+3xy) \\ +f_3 &= 2x - 3x^2y - x^3z +\end{aligned}`, + "f₁ = (1+xy)³ z + y²(1+xy)(4+3xy)\nf₂ = y + 3x(1+xy)² z + 3xy²(4+3xy)\nf₃ = 2x - 3x²y - x³z", + ], + ["x, y, z", "x, y, z"], + ["(x, y, z)", "(x, y, z)"], + [String.raw`(0,\; 0,\; -\tfrac14)`, "(0, 0, -1/4)"], + [String.raw`(-\tfrac14,\; 0,\; 0)`, "(-1/4, 0, 0)"], + [String.raw`(1,\; -\tfrac32,\; \tfrac{13}{2})`, "(1, -3/2, 13/2)"], + [String.raw`(-1,\; \tfrac32,\; \tfrac{13}{2})`, "(-1, 3/2, 13/2)"], + [String.raw`(-\frac14, 0, 0)`, "(-1/4, 0, 0)"], + [String.raw`F: \mathbb{C}^3 \to \mathbb{C}^3`, "F: ℂ³ → ℂ³"], + [ + String.raw`F(0,0,-\tfrac14) = F(1,-\tfrac32,\tfrac{13}{2}) = F(-1,\tfrac32,\tfrac{13}{2}) = (-\tfrac14, 0, 0)`, + "F(0,0,-1/4) = F(1,-3/2,13/2) = F(-1,3/2,13/2) = (-1/4, 0, 0)", + ], + [String.raw`\mathbb{C}^3`, "ℂ³"], + [ + String.raw`\begin{aligned} +f_1 &= \frac{f_1^{\text{ut}}(u,t)}{x^2}, \quad +f_2 = \frac{f_2^{\text{ut}}(u,t)}{x}, \quad +f_3 = x\,(2 - 3u - t) +\end{aligned}`, + "f₁ = (f₁ᵘᵗ(u,t))/(x²), f₂ = (f₂ᵘᵗ(u,t))/x, f₃ = x (2 - 3u - t)", + ], + [String.raw`\det J_F`, "det J_F"], + [String.raw`(-\tfrac14, 0, 0)`, "(-1/4, 0, 0)"], + ["u = xy", "u = xy"], + ["t = x^2z", "t = x²z"], + [String.raw`x \neq 0`, "x ≠ 0"], + [String.raw`f_1^{\text{ut}}, f_2^{\text{ut}}`, "f₁ᵘᵗ, f₂ᵘᵗ"], + ["u,t", "u,t"], + ["x", "x"], + ["x, x^2", "x, x²"], + [String.raw`\mathbb{C}^n \to \mathbb{C}^n`, "ℂⁿ → ℂⁿ"], + [String.raw`n \geq 2`, "n ≥ 2"], + [String.raw`\mathbb{P}^3`, "ℙ³"], + ]); + }); + + describe("extended formulas from a renderer stress-test session", () => { + defineCases([ + [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1=0"], + [ + String.raw`\boxed{ +\mathcal{Z}(\beta) += +\int_{\mathcal M} +\exp\!\left( +-\beta\left[ +\frac12 g^{ij}(x)\,\partial_i\phi\,\partial_j\phi ++V(\phi) +\right]\right) +\mathcal D\phi +}`, + "[Z(β) = ∫_M exp( -β[ 1/2 gⁱʲ(x) ∂ᵢϕ ∂ⱼϕ +V(ϕ) ]) Dϕ]", + ], + [ + String.raw`\begin{aligned} +\nabla_\mu T^{\mu\nu} +&= +\frac{1}{\sqrt{-g}} +\partial_\mu\!\left(\sqrt{-g}\,T^{\mu\nu}\right) ++\Gamma^\nu_{\mu\lambda}T^{\mu\lambda} +=0, \\[4pt] +R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} +&= +\frac{8\pi G}{c^4}T_{\mu\nu}. +\end{aligned}`, + "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) =0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", + ], + [ + String.raw`f(z) += +\frac{1}{2\pi i} +\oint_{\gamma} +\frac{f(\zeta)}{\zeta-z}\,d\zeta, +\qquad +\det\!\begin{pmatrix} +\lambda-a & -b & 0\\ +-c & \lambda-d & -e\\ +0 & -f & \lambda-g +\end{pmatrix} +=0.`, + "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞\n⎜ -c │ λ-d │ -e ⎟\n⎝ 0 │ -f │ λ-g ⎠ =0.", + ], + [ + String.raw`\Psi(x,t)= +\sum_{n=1}^{\infty} +\underbrace{ +c_n +\sqrt{\frac{2}{L}} +\sin\!\left(\frac{n\pi x}{L}\right) +}_{\text{spatial eigenmode}} +\exp\!\left(-\frac{i\hbar n^2\pi^2}{2mL^2}t\right), +\qquad +|\Psi(x,t)|^2 += +\begin{cases} +\Psi^\ast\Psi, & 0 { + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx = \sqrt{\pi}`), + "∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx = √π", + ); + }); + + it("renders common accents and binomial notation", () => { + assert.strictEqual( + renderLatex(String.raw`\binom{n}{k}+\vec{x}+\hat{y}+\overline{AB}`), + "(n choose k)+x⃗+ŷ+overline(AB)", + ); + }); + + it("renders extended symbols and negated relations", () => { + assert.strictEqual( + renderLatex(String.raw`\epsilon+\varepsilon+\varsigma+\varkappa+\oplus+\otimes+\therefore+\because`), + "ϵ+ε+ς+ϰ+⊕+⊗+∴+∵", + ); + assert.strictEqual(renderLatex(String.raw`A\not\subseteq B,\quad x\not\in X`), "A⊈ B, x∉ X"); + }); + + it("renders delimiter commands and invisible delimiters", () => { + assert.strictEqual( + renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`), + "|x|+‖v‖+dy/(dx)|ₓ₌₀", + ); + assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x>0 }"); + }); + + it("renders named, modular, overlaid, and underlaid operators", () => { + assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)"); + assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a≡ b (mod n)"); + assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ"); + }); + + it("renders indexed roots and additional accents and wrappers", () => { + assert.strictEqual( + renderLatex(String.raw`\sqrt[2]{x}+\sqrt[3]{x}+\sqrt[4]{x}+\sqrt[n]{x}+\sqrt[k]{x+1}`), + "√x+∛x+∜x+ⁿ√x+ᵏ√(x+1)", + ); + assert.strictEqual( + renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`), + "x́+ỳ+widehat(xyz)+overrightarrow(AB)", + ); + assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x"); + }); + + it("renders additional display environments", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`), + "a=b\n=c", + ); + assert.strictEqual( + renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`), + "a=b c=d\ne=f g=h", + ); + }); + + it("uses natural case conditions and aligns matrix columns", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), + "⎧ a if x<0\n⎨ b if x=0\n⎩ c otherwise", + ); + assert.strictEqual( + renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), + "⎛ 1 │ 200 ⎞\n⎝ 3000 │ 4 ⎠", + ); + }); + + it("stacks operator limits in display mode", () => { + assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0"); + assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X"); + assert.strictEqual( + renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`, { + display: true, + }), + "arg max f(x)\n x∈X", + ); + assert.strictEqual(renderLatex(String.raw`\int\nolimits_0^1 f(x)\,dx`, { display: true }), "∫₀¹ f(x) dx"); + assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0"); + }); + + it("uses the middle brace for intermediate case rows", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), + "⎧ a if x<0\n⎨ b if x=0\n⎩ c if x>0", + ); + }); + + it("stacks fractions in display mode", () => { + assert.strictEqual( + renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, { + display: true, + }), + " -b±√(b²-4ac)\nx= ────────────\n 2a", + ); + assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); + }); + + it("keeps nested display fractions linear", () => { + const cases: Array<[string, string]> = [ + [ + String.raw`\frac{\frac{x^2+1}{x-1}-\frac{2x}{x+1}}{\frac{x}{x^2-1}}`, + "(x²+1)/(x-1)-2x/(x+1)\n─────────────────────\n x/(x²-1)", + ], + [ + String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`, + " (sin x)/x-1\nlim ─────────── =0\nx→0 (eˣ-1)/x-1", + ], + [ + String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`, + "1+1/(1+1/x)\n───────────\n1-1/(1-1/x)", + ], + ]; + for (const [source, expected] of cases) { + assert.strictEqual(renderLatex(source, { display: true }), expected); + } + }); + + it("keeps fractions linear in scripts and text-style fractions", () => { + assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)"); + assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2"); + }); + + it("returns undefined for unsupported commands", () => { + assert.strictEqual(renderLatex(String.raw`x + \unknown{y}`), undefined); + }); + + it("returns undefined for malformed groups and environments", () => { + const malformed = [String.raw`\frac{1}{x`, "x}", String.raw`\begin{matrix}1 & 2`, "x\\"]; + for (const source of malformed) { + assert.strictEqual(renderLatex(source), undefined); + } + }); +}); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 0f6b61d88..549b3a4fd 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -693,6 +693,171 @@ describe("Markdown component", () => { }); }); + describe("LaTeX math", () => { + it("renders inline dollar and parenthesis delimiters", () => { + const markdown = new Markdown( + String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞."]); + }); + + it("renders display dollar delimiters without Markdown escape corruption", () => { + const markdown = new Markdown( + String.raw`Before + +$$\{3x+2y,\; x \in \{0, \pm 1\}\}$$ + +after`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Before", "", "{3x+2y, x ∈ {0, ± 1}}", "", "after"]); + }); + + it("renders display bracket delimiters", () => { + const markdown = new Markdown( + String.raw`Before + +\[ +E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} +\] + +after`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Before", "", " 0.1 lux", "E ≈ ────────", " 100 lm/W", "", "after"]); + }); + + it("renders lower limits beneath display operators", () => { + const markdown = new Markdown( + String.raw`\[ +\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0 +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── =0", "x→0 (eˣ-1)/x-1"]); + }); + + it("renders math inside lists and tables", () => { + const markdown = new Markdown( + String.raw`- Formula: $F_1 = u^2$ + +| Value | +| --- | +| $\mathbb{C}^3$ |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + const output = lines.join("\n"); + + assert.ok(output.includes("- Formula: F₁ = u²")); + assert.ok(output.includes("│ ℂ³")); + }); + + it("does not treat currency, shell variables, or code spans as math", () => { + const source = "Costs $5 and $10 or $8k–$12k; use `$x$`, $HOME, and $" + "{PATH}."; + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Costs $5 and $10 or $8k–$12k; use $x$, $HOME, and $" + "{PATH}."]); + + const shellVariables = "Paths: $HOME/$USER and $XDG_CONFIG_HOME/$APP_CONFIG"; + const shellLines = new Markdown(shellVariables, 0, 0, defaultMarkdownTheme) + .render(80) + .map((line) => stripAnsi(line).trimEnd()); + assert.deepStrictEqual(shellLines, [shellVariables]); + }); + + it("preserves unsupported and incomplete LaTeX exactly", () => { + const cases = [String.raw`Unknown $x + \unknown{y}$ after`, String.raw`Streaming $\mathbb{C}^3`]; + + for (const source of cases) { + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + assert.deepStrictEqual(lines, [source]); + } + }); + + it("preserves incomplete backslash delimiters while streaming", () => { + const inline = new Markdown(String.raw`Map \(\mathbb{C}^3`, 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + inline.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map \(\mathbb{C}^3`], + ); + + const display = new Markdown("\\[\nx^2", 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + display.render(80).map((line) => stripAnsi(line).trimEnd()), + ["\\[", "x^2"], + ); + }); + + it("does not render LaTeX inside escaped delimiters or code fences", () => { + const source = [String.raw`Escaped \$x-y\$.`, "", "```text", String.raw`$\mathbb{C}^3$`, "```"].join("\n"); + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Escaped $x-y$.", "", "```text", " $\\mathbb{C}^3$", "```"]); + }); + + it("allows LaTeX rendering to be disabled", () => { + const markdown = new Markdown( + String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`, + 0, + 0, + defaultMarkdownTheme, + undefined, + { + renderLatex: false, + }, + ); + + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`], + ); + }); + + it("switches from raw to rendered math when a streamed delimiter closes", () => { + const markdown = new Markdown(String.raw`Map $\mathbb{C}^3`, 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map $\mathbb{C}^3`], + ); + + markdown.setText(String.raw`Map $\mathbb{C}^3$`); + + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + ["Map ℂ³"], + ); + }); + }); + describe("Backslash escapes", () => { it("should normalize escaped punctuation by default", () => { const markdown = new Markdown(String.raw`"\"`, 0, 0, defaultMarkdownTheme);