diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 42b2163b7..22903fe27 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. + ## [0.84.0] - 2026-08-06 ### Added diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index 02be3b7ac..58b6633b4 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -288,6 +288,90 @@ const DISPLAY_LIMIT_SYMBOLS = new Set([ "sum", ]); +const RELATION_COMMANDS = new Set([ + "Leftarrow", + "Leftrightarrow", + "Longleftarrow", + "Longleftrightarrow", + "Longrightarrow", + "Rightarrow", + "Vdash", + "Vvdash", + "approx", + "asymp", + "cong", + "dashv", + "doteq", + "downarrow", + "equiv", + "ge", + "geq", + "geqslant", + "gets", + "gg", + "hookleftarrow", + "hookrightarrow", + "iff", + "implies", + "in", + "leadsto", + "le", + "leftarrow", + "leftharpoondown", + "leftharpoonup", + "leftrightarrow", + "leftrightharpoons", + "leq", + "leqslant", + "ll", + "longleftarrow", + "longleftrightarrow", + "longmapsto", + "longrightarrow", + "mapsto", + "mid", + "models", + "ne", + "nearrow", + "neq", + "ni", + "notin", + "nvdash", + "nvDash", + "nwarrow", + "parallel", + "perp", + "prec", + "preceq", + "propto", + "rightharpoondown", + "rightharpoonup", + "rightleftharpoons", + "rightarrow", + "rightsquigarrow", + "searrow", + "sim", + "simeq", + "sqsubset", + "sqsubseteq", + "sqsupset", + "sqsupseteq", + "subset", + "subseteq", + "succ", + "succeq", + "supset", + "supseteq", + "swarrow", + "to", + "triangleleft", + "triangleright", + "twoheadleftarrow", + "twoheadrightarrow", + "uparrow", + "vdash", +]); + const NEGATED_SYMBOLS: Readonly> = { "<": "≮", ">": "≯", @@ -515,7 +599,7 @@ function replaceCharacters(value: string, replacements: Readonly line.replace(/[ \t]+/g, " ").trim()) .filter((line, index, lines) => line.length > 0 || (index > 0 && index < lines.length - 1)) @@ -562,7 +655,13 @@ interface OperatorNode { upper?: string; } -type LayoutNode = FractionNode | OperatorNode; +interface MatrixNode { + type: "matrix"; + lines: string[]; + baseline: number; +} + +type LayoutNode = FractionNode | OperatorNode | MatrixNode; interface Layout { lines: string[]; @@ -573,7 +672,8 @@ interface Layout { 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"; +const TRAILING_LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}$/u; +const PROTECTED_SPACE = "\u{f0002}"; function padLayoutLine(line: string, width: number, centered = false): string { const padding = Math.max(0, width - visibleWidth(line)); @@ -612,18 +712,25 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { for (const sourceLine of source.split("\n")) { const layouts: Layout[] = []; let position = 0; - let previousWasNode = false; + let previousNode: LayoutNode | undefined; 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 (index > position) { + const sliced = sourceLine.slice(position, index); + const trimmed = (previousNode ? sliced.trimStart() : sliced).trimEnd(); + const preserveLeadingSpace = previousNode?.type === "matrix" && /^\s/.test(sliced); + const preserveTrailingSpace = node.type === "matrix" && /\s$/.test(sliced); + const text = trimmed + ? `${preserveLeadingSpace ? " " : ""}${trimmed}${preserveTrailingSpace ? " " : ""}` + : preserveLeadingSpace || preserveTrailingSpace + ? " " + : ""; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } if (node.type === "fraction") { const numerator = renderLayout(node.numerator, nodes); const denominator = renderLayout(node.denominator, nodes); @@ -638,7 +745,7 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width, baseline: numerator.lines.length, }); - } else { + } else if (node.type === "operator") { const contentWidth = Math.max( visibleWidth(node.operator), node.lower === undefined ? 0 : visibleWidth(node.lower), @@ -657,13 +764,21 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { width: contentWidth + 1, baseline: node.upper === undefined ? 0 : 1, }); + } else { + const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); + layouts.push({ + lines: node.lines.map((line) => padLayoutLine(line, width)), + width, + baseline: node.baseline, + }); } position = index + match[0].length; - previousWasNode = true; + previousNode = node; } if (position < sourceLine.length) { const sliced = sourceLine.slice(position); - const text = previousWasNode ? sliced.trimStart() : sliced; + const trimmed = previousNode ? sliced.trimStart() : sliced; + const text = previousNode?.type === "matrix" && /^\s/.test(sliced) ? ` ${trimmed}` : trimmed; layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); } const lineLayout = joinLayouts(layouts); @@ -681,14 +796,16 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { class LatexParser { private readonly source: string; - private readonly layoutNodes: LayoutNode[] | undefined; + private readonly layoutNodes: LayoutNode[]; + private readonly display: boolean; private position = 0; private supported = true; private stackFractions = true; - constructor(source: string, layoutNodes?: LayoutNode[]) { + constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { this.source = source; this.layoutNodes = layoutNodes; + this.display = display; } render(): string | undefined { @@ -723,6 +840,9 @@ class LatexParser { const command = this.parseCommand(); if (command === NEGATIVE_SPACE) { result = result.trimEnd(); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = result.slice(0, -NAMED_OPERATOR_END.length); + } } else { result += command; } @@ -732,7 +852,12 @@ class LatexParser { if (character === "^" || character === "_") { this.position++; result = result.trimEnd(); - result += formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; + } else { + result += script; + } continue; } @@ -741,6 +866,12 @@ class LatexParser { continue; } + if (character === "=" || character === "<" || character === ">") { + result = `${result.trimEnd()} ${character} `; + this.position++; + continue; + } + if (character === "&") { this.position++; continue; @@ -752,6 +883,17 @@ class LatexParser { continue; } + if (character === ".") { + const marker = TRAILING_LAYOUT_MARKER_PATTERN.exec(result); + const node = marker ? this.layoutNodes[Number(marker[1])] : undefined; + if (node?.type === "matrix") { + const lastLine = node.lines.length - 1; + node.lines[lastLine] = `${node.lines[lastLine] ?? ""}${character}`; + this.position++; + continue; + } + } + result += character; this.position++; } @@ -819,14 +961,14 @@ class LatexParser { const value = this.parseRequiredArgument(false).trim(); const negated = NEGATED_SYMBOLS[value]; if (negated !== undefined) { - return negated; + return ` ${negated} `; } const characters = Array.from(value); if (characters.length === 0) { this.supported = false; return ""; } - return `${characters[0]}\u0338${characters.slice(1).join("")}`; + return ` ${characters[0]}\u0338${characters.slice(1).join("")} `; } if (LIMIT_OPERATORS.has(command)) { return this.parseOperator(command, "bracket", true, true); @@ -834,10 +976,13 @@ class LatexParser { const symbol = SYMBOLS[command]; if (symbol !== undefined) { - return DISPLAY_LIMIT_SYMBOLS.has(command) ? this.parseOperator(symbol, "script", true) : symbol; + if (DISPLAY_LIMIT_SYMBOLS.has(command)) { + return this.parseOperator(symbol, "script", true); + } + return command === "cdot" || command === "times" || RELATION_COMMANDS.has(command) ? ` ${symbol} ` : symbol; } if (NAMED_OPERATORS.has(command)) { - return ` ${command} `; + return `${NAMED_OPERATOR_START}${command}${NAMED_OPERATOR_END}`; } if (SIZE_COMMANDS.has(command)) { return ""; @@ -849,10 +994,10 @@ class LatexParser { return ""; } if (command === "frac" || command === "dfrac" || command === "tfrac") { - const shouldStack = this.layoutNodes !== undefined && this.stackFractions && command !== "tfrac"; + const shouldStack = this.display && this.stackFractions && command !== "tfrac"; const numerator = this.parseRequiredArgument(!shouldStack); const denominator = this.parseRequiredArgument(!shouldStack); - if (shouldStack && this.layoutNodes) { + if (shouldStack) { const index = this.layoutNodes.push({ type: "fraction", @@ -976,7 +1121,7 @@ class LatexParser { } } - if (this.layoutNodes && useDisplayLimits && (lower !== undefined || upper !== undefined)) { + if (this.display && useDisplayLimits && (lower !== undefined || upper !== undefined)) { const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } @@ -1157,36 +1302,39 @@ class LatexParser { 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) => { + let lines: string[]; + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + lines = rows; + } else { + const delimiters: Readonly> = { + pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], + bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], + Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], + vmatrix: ["│", "│", "│", "│", "│", "│"], + Vmatrix: ["║", "║", "║", "║", "║", "║"], + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + lines = 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"); + }); + } + + if (lines.length <= 1) { + return lines[0] ?? ""; + } + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: 0 }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; } private renderNested(source: string, stackFractions = true): string { - const rendered = new LatexParser(source, stackFractions ? this.layoutNodes : undefined).render(); + const rendered = new LatexParser(source, this.layoutNodes, this.display && stackFractions).render(); if (rendered === undefined) { this.supported = false; return source; @@ -1205,12 +1353,12 @@ export interface RenderLatexOptions { * 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(); + const layoutNodes: LayoutNode[] = []; + const rendered = new LatexParser(source, layoutNodes, options.display === true).render(); if (rendered === undefined) { return undefined; } - if (!layoutNodes || layoutNodes.length === 0) { + if (layoutNodes.length === 0) { return rendered.replaceAll(PROTECTED_SPACE, " "); } const lines = renderLayout(rendered, layoutNodes).lines; diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index d6d65e5f2..3644ec42b 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -37,7 +37,7 @@ describe("renderLatex", () => { ["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"], + ["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"], @@ -94,7 +94,7 @@ describe("renderLatex", () => { 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.", + "det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.", ], [ String.raw`\begin{aligned} @@ -102,9 +102,9 @@ 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(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_1,F_2,F_3)", "F = (F₁,F₂,F₃)"], ["F", "F"], ["3", "3"], ]); @@ -118,7 +118,7 @@ F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). \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) ⎠", + "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} @@ -166,7 +166,7 @@ f_3 = x\,(2 - 3u - t) describe("extended formulas from a renderer stress-test session", () => { defineCases([ - [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1=0"], + [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"], [ String.raw`\boxed{ \mathcal{Z}(\beta) @@ -193,7 +193,7 @@ 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_(μν).", + "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", ], [ String.raw`f(z) @@ -208,7 +208,11 @@ R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} 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.", + [ + "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.", + `${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`, + `${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`, + ].join("\n"), ], [ String.raw`\Psi(x,t)= @@ -226,27 +230,27 @@ c_n \Psi^\ast\Psi, & 0 { @@ -315,12 +319,12 @@ c_n 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 }"); + 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`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ₙ+=ᵈᵉᶠ"); }); @@ -339,18 +343,18 @@ c_n 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", + "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", + "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", + "⎧ 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}`), @@ -358,6 +362,59 @@ c_n ); }); + it("composes matrices with fractions and adjacent matrices", () => { + assert.strictEqual( + renderLatex( + String.raw`R\left(\frac{\pi}{4}\right) += +\begin{pmatrix} +\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\ +\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} +\end{pmatrix}.`, + { display: true }, + ), + " π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`\mathbf w += +R\left(\frac{\pi}{4}\right) +\begin{pmatrix}1\\0\end{pmatrix} += +\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`, + { display: true }, + ), + " π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`, + { display: true }, + ), + "Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.", + ); + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }), + " n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.", + ); + }); + + it("normalizes relation, multiplication, and named-operator spacing", () => { + for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) { + assert.strictEqual(renderLatex(source), "x = y"); + } + assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀"); + assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0"); + assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B"); + assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π"); + assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ"); + assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x"); + assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ"); + assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ"); + assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)"); + }); + 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"); @@ -374,7 +431,7 @@ c_n 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", + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", ); }); @@ -383,7 +440,7 @@ c_n renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, { display: true, }), - " -b±√(b²-4ac)\nx= ────────────\n 2a", + " -b±√(b²-4ac)\nx = ────────────\n 2a", ); assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); }); @@ -396,7 +453,7 @@ c_n ], [ 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", + " (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}}}`, diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 549b3a4fd..d45b7716d 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -743,6 +743,27 @@ after`, assert.deepStrictEqual(lines, ["Before", "", " 0.1 lux", "E ≈ ────────", " 100 lm/W", "", "after"]); }); + it("aligns matrix rows with the opening delimiter", () => { + const markdown = new Markdown( + String.raw`Consider the matrix + +\[ +A= +\begin{pmatrix} +\pi & 0\\ +0 & \frac{1}{\pi} +\end{pmatrix}. +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Consider the matrix", "", "A = ⎛ π │ 0 ⎞", " ⎝ 0 │ 1/π ⎠."]); + }); + it("renders lower limits beneath display operators", () => { const markdown = new Markdown( String.raw`\[ @@ -755,7 +776,7 @@ after`, 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"]); + assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── = 0", "x→0 (eˣ-1)/x-1"]); }); it("renders math inside lists and tables", () => {