From 7cbe9fc0a88965a870719655b2f8e68ef196df6a Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 10 Jul 2026 20:40:04 +0200 Subject: [PATCH] feat(tui): implemented grid-based rendering engine for complex math layouts - Introduced a grid-based rendering engine to support complex LaTeX environments like matrices, cases, and arrays. - Added advanced layout capabilities including stretched delimiters, multi-line radicals, and limits-based stacking for operators. - Implemented logic to propagate color scopes and font styles across box boundaries. - Exported math font commands and structural glyph painting to facilitate consistent rendering in 2-D contexts. --- packages/tui/CHANGELOG.md | 13 + packages/tui/src/latex-block.ts | 901 ++++++++++++++++++++++-- packages/tui/src/latex-to-unicode.ts | 23 + packages/tui/test/latex-block.test.ts | 184 ++++- packages/tui/test/markdown-math.test.ts | 27 +- packages/tui/test/markdown.test.ts | 6 +- 6 files changed, 1065 insertions(+), 89 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e794cb8c5..7e6b2eb9e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Added + +- Added full 2-D layout support for display LaTeX math (fractions, matrices, radicals, limits), modeled on the layout approach of [txm](https://github.com/thatmagicalcat/txm) (Terminal TeX Math) by [@thatmagicalcat](https://github.com/thatmagicalcat) +- Added support for `\left`, `\right`, and `\middle` stretchy delimiters in display blocks +- Added rendering for `cases`, `matrix`, `pmatrix`, `bmatrix`, and `vmatrix` environments +- Added support for block-level scripts and big-operator limits (e.g., `\sum`, `\int\limits`) +- Added cross-box styling for `\color`, `\textcolor`, and math font commands (e.g., `\mathbf`) + +### Changed + +- Improved row alignment and spacing for `align`, `gather`, and `array` environments +- Updated matrix environments to render as baseline-aligned grids with stretched brackets + ## [16.4.0] - 2026-07-10 ### Fixed diff --git a/packages/tui/src/latex-block.ts b/packages/tui/src/latex-block.ts index 43477fec7..32a9ce190 100644 --- a/packages/tui/src/latex-block.ts +++ b/packages/tui/src/latex-block.ts @@ -1,16 +1,27 @@ -// Two-dimensional layout for *display* LaTeX math: stacks `\frac` numerator over -// denominator with a horizontal bar, aligning surrounding text to the bar's row. +// Two-dimensional layout engine for *display* LaTeX math. // -// −b ± √(b² − 4ac) -// x = ──────────────── -// 2a +// ┌───────── n ⎛ a+b ⎞² +// −b ± ╲│ b² − 4ac ∑ xᵢ ⎜ ───── ⎟ ⎡ 1 2 ⎤ +// x = ────────────────── i=0 ⎝ c ⎠ ⎣ 3 4 ⎦ +// 2a // // Only display blocks (`$$…$$`, `\[…\]`) use this; inline `$…$` stays single-line -// (`½`, `(a+b)/c`). Everything that is not a fraction — symbols, scripts, roots, -// matrices, environments — is delegated to `latexToUnicode`, so this engine only -// adds the vertical stacking the flat string form can't express. +// via `latexToUnicode` (`½`, `(a+b)/c`). The engine lays out a `Box` tree — +// rectangles of padded lines with a `baseline` row — and knows how to stack +// fractions and `\binom`, stretch delimiters (`\left…\right`, tall bare parens, +// matrix brackets), render matrix/cases/array environments as baseline-aligned +// grids, place big-operator limits (`\sum`, `\lim`, `\int\limits`) above and +// below the symbol, draw radicals, raise/lower block scripts, and +// align `&` columns in `align`-family environments. Flat runs — symbols, fonts, +// colors, inline scripts — are delegated to `latexToUnicode`. +// +// The 2-D layout approach (stretchy delimiter piecing, stacked operator limits, +// baseline-aligned matrix grids, drawn radicals, block scripts) is modeled on +// txm — Terminal TeX Math — by @thatmagicalcat +// (https://github.com/thatmagicalcat/txm, MIT/Apache-2.0), reimplemented from +// scratch here on this module's ANSI-aware Box model. -import { latexToUnicode } from "./latex-to-unicode"; +import { latexColorScope, latexToUnicode, MATH_FONT_COMMANDS } from "./latex-to-unicode"; import { visibleWidth } from "./utils"; /** @@ -24,13 +35,15 @@ interface Box { width: number; } +type CellAlign = "l" | "c" | "r"; + const BAR = "─"; const FRAC_COMMANDS: Record = { frac: true, dfrac: true, tfrac: true, cfrac: true }; +const BINOM_COMMANDS: Record = { binom: true, dbinom: true, tbinom: true }; // Display "wrapper" environments whose body is an expression (possibly with `\\` -// row breaks and `&` alignment). Their bodies are parsed so fractions inside -// stack; grid/structure environments (matrix/array/cases) stay opaque and are -// rendered flat by `latexToUnicode`. +// row breaks and `&` alignment). Their rows are parsed so fractions inside stack +// and `&` columns align. const DISPLAY_ROW_ENVIRONMENTS: Record = { equation: true, eqnarray: true, @@ -48,6 +61,140 @@ const DISPLAY_ROW_ENVIRONMENTS: Record = { math: true, }; +// Environments laid out as 2-D grids of parsed cells: [open, close] delimiter. +const GRID_ENVIRONMENTS: Record = { + matrix: ["", ""], + smallmatrix: ["", ""], + array: ["", ""], + pmatrix: ["(", ")"], + bmatrix: ["[", "]"], + Bmatrix: ["{", "}"], + vmatrix: ["|", "|"], + Vmatrix: ["‖", "‖"], + cases: ["{", ""], + dcases: ["{", ""], + rcases: ["", "}"], + drcases: ["", "}"], +}; + +// Operators whose display-style scripts stack above/below the symbol. +const LIMIT_OPERATORS: Record = { + sum: true, + prod: true, + coprod: true, + bigcup: true, + bigcap: true, + bigsqcup: true, + bigvee: true, + bigwedge: true, + bigoplus: true, + bigotimes: true, + bigodot: true, + biguplus: true, + lim: true, + limsup: true, + liminf: true, + projlim: true, + injlim: true, + varlimsup: true, + varliminf: true, + varprojlim: true, + varinjlim: true, + max: true, + min: true, + sup: true, + inf: true, + det: true, + gcd: true, + Pr: true, + argmax: true, + argmin: true, +}; + +// Integral-family operators: scripts stay beside the symbol (LaTeX display +// convention) unless an explicit `\limits` follows. +const INTEGRAL_OPERATORS: Record = { + int: true, + iint: true, + iiint: true, + iiiint: true, + oint: true, + oiint: true, + oiiint: true, + idotsint: true, + intop: true, + smallint: true, +}; + +// Vertical delimiter piece characters: `only` for single-line content, then +// top/mid/bot columns for stretched forms; `axis` replaces `mid` at the +// baseline row (the brace point). +interface DelimPieces { + only: string; + top: string; + mid: string; + bot: string; + axis?: string; +} + +const DELIM_PIECES: Record = { + "(": { only: "(", top: "⎛", mid: "⎜", bot: "⎝" }, + ")": { only: ")", top: "⎞", mid: "⎟", bot: "⎠" }, + "[": { only: "[", top: "⎡", mid: "⎢", bot: "⎣" }, + "]": { only: "]", top: "⎤", mid: "⎥", bot: "⎦" }, + "{": { only: "{", top: "⎧", mid: "⎪", bot: "⎩", axis: "⎨" }, + "}": { only: "}", top: "⎫", mid: "⎪", bot: "⎭", axis: "⎬" }, + "|": { only: "|", top: "│", mid: "│", bot: "│" }, + "‖": { only: "‖", top: "║", mid: "║", bot: "║" }, + "⌈": { only: "⌈", top: "⎡", mid: "⎢", bot: "⎢" }, + "⌉": { only: "⌉", top: "⎤", mid: "⎥", bot: "⎥" }, + "⌊": { only: "⌊", top: "⎢", mid: "⎢", bot: "⎣" }, + "⌋": { only: "⌋", top: "⎥", mid: "⎥", bot: "⎦" }, +}; + +// `\left`/`\right`/`\middle` delimiter token → piece-table key. Unknown tokens +// fall back to `latexToUnicode` and render at the baseline row only. +const DELIM_KEYS: Record = { + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "\\{": "{", + "\\}": "}", + "\\lbrace": "{", + "\\rbrace": "}", + "|": "|", + "\\vert": "|", + "\\lvert": "|", + "\\rvert": "|", + "\\|": "‖", + "\\Vert": "‖", + "\\lVert": "‖", + "\\rVert": "‖", + "\\langle": "⟨", + "\\rangle": "⟩", + "<": "⟨", + ">": "⟩", + "\\lceil": "⌈", + "\\rceil": "⌉", + "\\lfloor": "⌊", + "\\rfloor": "⌋", + "\\lbrack": "[", + "\\rbrack": "]", + ".": "", +}; + +/** + * Inline-run conversion context. `wrap` re-applies the scoped commands (math + * fonts, colors) active at this point in the parse, so each flat run handed to + * `latexToUnicode` renders with the same styling it would have had in one piece. + */ +interface Ctx { + wrap: (run: string) => string; +} + +const ROOT_CTX: Ctx = { wrap: run => run }; + function spaces(n: number): string { return n > 0 ? " ".repeat(n) : ""; } @@ -73,6 +220,19 @@ function textBox(text: string): Box { return { lines: raw.map(line => padRight(line, width)), baseline: (raw.length - 1) >> 1, width }; } +/** Pad every line of `b` to `width` per `align`, keeping the baseline. */ +function padBox(b: Box, width: number, align: CellAlign): Box { + if (b.width >= width) return b; + const lines = b.lines.map(line => { + const extra = width - visibleWidth(line); + if (align === "l") return line + spaces(extra); + if (align === "r") return spaces(extra) + line; + const left = extra >> 1; + return spaces(left) + line + spaces(extra - left); + }); + return { lines, baseline: b.baseline, width }; +} + /** Place boxes side by side, aligning their baselines. */ function hconcat(boxes: Box[]): Box { if (boxes.length === 1) return boxes[0]; @@ -97,6 +257,18 @@ function hconcat(boxes: Box[]): Box { return { lines, baseline: above, width }; } +/** Stack boxes vertically, e.g. the rows of an aligned block. */ +function vconcat(boxes: Box[], align: CellAlign = "l"): Box { + if (boxes.length === 1) return boxes[0]; + let width = 0; + for (const b of boxes) width = Math.max(width, b.width); + const lines: string[] = []; + for (const b of boxes) { + for (const line of b.lines) lines.push(align === "c" ? center(line, width) : padRight(line, width)); + } + return { lines, baseline: (lines.length - 1) >> 1, width }; +} + /** Stack `num` over `den`, separated by a bar; the bar becomes the baseline. */ function fracBox(num: Box, den: Box): Box { const width = Math.max(num.width, den.width) + 2; @@ -108,14 +280,157 @@ function fracBox(num: Box, den: Box): Box { return { lines, baseline: num.lines.length, width }; } -/** Stack boxes vertically (left-aligned), e.g. the rows of an aligned block. */ -function vconcat(boxes: Box[]): Box { - if (boxes.length === 1) return boxes[0]; - let width = 0; - for (const b of boxes) width = Math.max(width, b.width); +/** + * One vertical delimiter column of `height` rows for piece-table key `key` + * (`"("`, `"{"`, …); null when `key` is empty (`\left.`). Unknown keys render a + * single glyph at the baseline row. + */ +function delimColumn(key: string, height: number, baseline: number): Box | null { + if (!key) return null; + const pieces = DELIM_PIECES[key]; + if (height <= 1) { + const only = pieces?.only ?? key; + return only ? { lines: [only], baseline: 0, width: visibleWidth(only) } : null; + } + const width = visibleWidth(pieces?.only ?? key); + const blank = spaces(width); const lines: string[] = []; - for (const b of boxes) for (const line of b.lines) lines.push(padRight(line, width)); - return { lines, baseline: (lines.length - 1) >> 1, width }; + if (!pieces) { + for (let y = 0; y < height; y++) lines.push(y === baseline ? key : blank); + return { lines, baseline, width }; + } + const axisRow = Math.min(Math.max(baseline, 1), height - 2); + for (let y = 0; y < height; y++) { + if (y === 0) lines.push(pieces.top); + else if (y === height - 1) lines.push(pieces.bot); + else if (y === axisRow && pieces.axis) lines.push(pieces.axis); + else lines.push(pieces.mid); + } + return { lines, baseline, width }; +} + +/** Wrap `inner` in (possibly stretched) delimiters, padding tall content. */ +function delimBox(inner: Box, left: string, right: string): Box { + const height = inner.lines.length; + const lcol = delimColumn(left, height, inner.baseline); + const rcol = delimColumn(right, height, inner.baseline); + if (!lcol && !rcol) return inner; + const pad: Box | null = height > 1 ? textBox(" ") : null; + const parts: Box[] = []; + if (lcol) parts.push(lcol); + if (pad) parts.push(pad); + parts.push(inner); + if (pad) parts.push(pad); + if (rcol) parts.push(rcol); + return hconcat(parts); +} + +/** `\binom{n}{k}`: `n` over `k` (no bar) inside stretched parentheses. */ +function binomBox(top: Box, bottom: Box): Box { + const width = Math.max(top.width, bottom.width); + const lines = [ + ...top.lines.map(line => center(line, width)), + spaces(width), + ...bottom.lines.map(line => center(line, width)), + ]; + return delimBox({ lines, baseline: top.lines.length, width }, "(", ")"); +} + +/** + * A drawn radical for a multi-line radicand: overline row on top, bar column + * on the left, hook at the bottom. Single-line radicands stay flat (`√x̄`). + */ +function radicalBox(inner: Box, degree: string | null): Box { + const lines: string[] = [` ┌${BAR.repeat(inner.width + 1)}`]; + for (let y = 0; y < inner.lines.length; y++) { + lines.push((y === inner.lines.length - 1 ? "╲│ " : " │ ") + inner.lines[y]); + } + const box: Box = { lines, baseline: inner.baseline + 1, width: inner.width + 3 }; + if (!degree) return box; + const deg = latexToUnicode(`^{${degree}}`); + // Degree sits one row above the baseline, at the radical's upper left. + return hconcat([{ lines: [deg, spaces(visibleWidth(deg))], baseline: 1, width: visibleWidth(deg) }, box]); +} + +/** Big operator with limits: `sup` centered above `glyph`, `sub` below. */ +function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box { + const width = Math.max(glyph.width, sub?.width ?? 0, sup?.width ?? 0); + const lines: string[] = []; + if (sup) for (const line of sup.lines) lines.push(center(line, width)); + const baseline = lines.length + glyph.baseline; + for (const line of glyph.lines) lines.push(center(line, width)); + if (sub) for (const line of sub.lines) lines.push(center(line, width)); + return { lines, baseline, width }; +} + +/** + * Attach block scripts to `base` as one shared right-hand column: the + * superscript ends level with the base's top row (raised one row above a + * single-line base), the subscript starts level with its bottom row (lowered + * one row below a single-line base). + */ +function attachScripts(base: Box, sub: Box | null, sup: Box | null): Box { + if (sub === null && sup === null) return base; + const single = base.lines.length === 1; + const width = Math.max(sub?.width ?? 0, sup?.width ?? 0); + const blank = spaces(width); + const lines: string[] = []; + let baseline = 0; + if (sup) { + const lift = single ? 1 : base.baseline; + for (const line of sup.lines) lines.push(padRight(line, width)); + for (let k = 0; k < lift; k++) lines.push(blank); + baseline = lines.length - 1; + } + if (sub) { + const below = base.lines.length - 1 - base.baseline - (sub.lines.length - 1); + let drop = Math.max(below, single ? 1 : 0); + if (sup && drop < 1) drop = 1; + // Rows between the baseline row and the subscript's top row. + const gap = lines.length === 0 ? drop : drop - 1; + for (let k = 0; k < gap; k++) lines.push(blank); + for (const line of sub.lines) lines.push(padRight(line, width)); + } + return hconcat([base, { lines, baseline, width }]); +} + +/** + * Lay out parsed cells as a grid: per-column width/alignment, per-gap width. + * With `rowGap > 0` (matrix-family environments), blank rows separate the grid + * rows and the total height is forced odd, so the baseline sits at the true + * vertical center — `A = [matrix]` centers on the brackets, and stretched + * braces get a real middle piece even for two content rows. + */ +function gridBox(rows: Box[][], align: (col: number) => CellAlign, gap: (col: number) => number, rowGap = 0): Box { + let ncols = 0; + for (const row of rows) ncols = Math.max(ncols, row.length); + if (ncols === 0 || rows.length === 0) return textBox(""); + const widths = new Array(ncols).fill(0); + for (const row of rows) { + row.forEach((cell, j) => { + widths[j] = Math.max(widths[j], cell.width); + }); + } + const rowBoxes: Box[] = []; + for (const row of rows) { + if (rowGap > 0 && rowBoxes.length > 0) { + for (let g = 0; g < rowGap; g++) rowBoxes.push({ lines: [""], baseline: 0, width: 0 }); + } + const parts: Box[] = []; + for (let j = 0; j < ncols; j++) { + if (j > 0) { + const g = gap(j); + if (g > 0) parts.push({ lines: [spaces(g)], baseline: 0, width: g }); + } + parts.push(padBox(row[j] ?? { lines: [""], baseline: 0, width: 0 }, widths[j], align(j))); + } + rowBoxes.push(hconcat(parts)); + } + const grid = vconcat(rowBoxes); + if (rowGap > 0 && rows.length > 1 && grid.lines.length % 2 === 0) { + return { lines: [...grid.lines, spaces(grid.width)], baseline: grid.lines.length >> 1, width: grid.width }; + } + return grid; } interface Span { @@ -155,7 +470,7 @@ function readBraceGroup(src: string, i: number): Span { } /** - * Read one fraction argument: a `{…}` group, a single char, or a `\command` + * Read one command argument: a `{…}` group, a single char, or a `\command` * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end` * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator. */ @@ -186,6 +501,100 @@ function readArg(src: string, i: number): Span { return { text: src.slice(i, end), end }; } +/** Read a `\left`/`\right`/`\middle` delimiter token (char or `\command`). */ +function readDelimToken(src: string, i: number): Span | null { + while (src[i] === " ") i++; + if (i >= src.length) return null; + if (src[i] !== "\\") return { text: src[i], end: i + 1 }; + let j = i + 1; + if (!/[A-Za-z]/.test(src[j] ?? "")) return { text: src.slice(i, j + 1), end: j + 1 }; + while (/[A-Za-z]/.test(src[j] ?? "")) j++; + return { text: src.slice(i, j), end: j }; +} + +/** Piece-table key for a delimiter token; unknown commands resolve via Unicode. */ +function delimKey(token: string): string { + const mapped = DELIM_KEYS[token]; + if (mapped !== undefined) return mapped; + return token.startsWith("\\") ? latexToUnicode(token).trim() : token; +} + +interface LeftRightParts { + left: string; + /** Inner source split at top-level `\middle` delimiters. */ + segments: string[]; + middles: string[]; + right: string; + end: number; +} + +/** Parse `\left⟨tok⟩ … \right⟨tok⟩` starting at the backslash of `\left`. */ +function readLeftRight(src: string, start: number): LeftRightParts | null { + const left = readDelimToken(src, start + 5); + if (!left) return null; + const segments: string[] = []; + const middles: string[] = []; + let depth = 1; + let k = left.end; + let segStart = k; + while (k < src.length) { + if (src[k] !== "\\") { + k++; + continue; + } + if (src.startsWith("\\left", k) && !/[A-Za-z]/.test(src[k + 5] ?? "")) { + depth++; + const tok = readDelimToken(src, k + 5); + k = tok ? tok.end : k + 5; + continue; + } + if (src.startsWith("\\right", k) && !/[A-Za-z]/.test(src[k + 6] ?? "")) { + depth--; + const tok = readDelimToken(src, k + 6); + if (depth === 0) { + segments.push(src.slice(segStart, k)); + return { left: left.text, segments, middles, right: tok ? tok.text : ".", end: tok ? tok.end : k + 6 }; + } + k = tok ? tok.end : k + 6; + continue; + } + if (depth === 1 && src.startsWith("\\middle", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) { + segments.push(src.slice(segStart, k)); + const tok = readDelimToken(src, k + 7); + middles.push(tok ? tok.text : "|"); + k = segStart = tok ? tok.end : k + 7; + continue; + } + k += 2; // escaped char / other command head — never a boundary + } + return null; // unbalanced +} + +/** + * Index of the `close` matching the `open` at `i`, skipping escapes and brace + * groups; −1 when unbalanced (e.g. interval notation `[0, 1)`). + */ +function matchDelim(src: string, i: number, open: string, close: string): number { + let depth = 0; + for (let k = i; k < src.length; k++) { + const c = src[k]; + if (c === "\\") { + k++; + continue; + } + if (c === "{") { + k = readBraceGroup(src, k).end - 1; + continue; + } + if (c === open) depth++; + else if (c === close) { + depth--; + if (depth === 0) return k; + } + } + return -1; +} + interface EnvParts { env: string; bodyStart: number; @@ -270,31 +679,39 @@ function splitRows(body: string): string[] { return rows; } -/** - * Render a `\begin{env}…\end{env}` block. Expression "wrapper" environments - * (`equation`, `align`, `gather`, …) have their rows parsed so fractions stack; - * grid/structure environments (matrix/array/cases) render flat via - * `latexToUnicode`. - */ -function parseEnvironment(src: string, start: number): { box: Box; end: number } | null { - const env = readEnvironment(src, start); - if (env === null) return null; - const base = env.env.endsWith("*") ? env.env.slice(0, -1) : env.env; - if (!DISPLAY_ROW_ENVIRONMENTS[base]) { - return { box: textBox(latexToUnicode(src.slice(start, env.end))), end: env.end }; +/** Split a row on top-level `&` column separators (depth-aware), trimming cells. */ +function splitCells(row: string): string[] { + const cells: string[] = []; + let braceDepth = 0; + let envDepth = 0; + let last = 0; + let i = 0; + while (i < row.length) { + if (row.startsWith("\\begin", i)) { + envDepth++; + i += 6; + continue; + } + if (row.startsWith("\\end", i)) { + envDepth--; + i += 4; + continue; + } + const c = row[i]; + if (c === "\\") { + i += 2; // `\&` and command heads never split + continue; + } + if (c === "{") braceDepth++; + else if (c === "}") braceDepth--; + else if (c === "&" && braceDepth === 0 && envDepth === 0) { + cells.push(row.slice(last, i)); + last = i + 1; + } + i++; } - let bodyStart = env.bodyStart; - if (base === "alignat" || base === "alignedat" || base === "gatheredat") { - // These carry a required column-count argument `{n}` before the body. - let p = bodyStart; - while (src[p] === " " || src[p] === "\n") p++; - if (src[p] === "{") bodyStart = readBraceGroup(src, p).end; - } - const rows = splitRows(src.slice(bodyStart, env.bodyEnd)) - .map(row => row.trim()) - .filter(row => row !== "") - .map(row => parseExpr(row)); - return { box: rows.length > 0 ? vconcat(rows) : textBox(""), end: env.end }; + cells.push(row.slice(last)); + return cells.map(cell => cell.trim()); } /** Append a script (`^`/`_`) and its argument to the inline run verbatim. */ @@ -319,21 +736,122 @@ function readScript(src: string, i: number): Span { return { text: out, end: i }; } +/** Bare argument of a script read by `readScript` (`^{ab}` → `ab`, `^a` → `a`). */ +function scriptArgOf(text: string): string { + let arg = text.slice(1).trimStart(); + if (arg.startsWith("{") && arg.endsWith("}")) arg = arg.slice(1, -1); + return arg; +} + /** - * Parse a math fragment into a layout box, stacking top-level fractions (and - * fractions nested inside other fractions' arguments). Non-fraction runs — - * including scripts, roots, environments, and command arguments — are gathered - * into inline strings and rendered through `latexToUnicode`. + * Render a `\begin{env}…\end{env}` block. Grid environments (matrix family, + * cases, array) become baseline-aligned 2-D grids in stretched delimiters; + * wrapper environments (`align`, `gather`, …) parse each `\\` row, aligning `&` + * columns; anything else (tabular, …) renders flat via `latexToUnicode`. */ -function parseExpr(src: string): Box { +function parseEnvironment(src: string, start: number, ctx: Ctx): { box: Box; end: number } | null { + const env = readEnvironment(src, start); + if (env === null) return null; + const starred = env.env.endsWith("*"); + const base = starred ? env.env.slice(0, -1) : env.env; + const gridDelims = GRID_ENVIRONMENTS[base]; + if (gridDelims) { + let p = env.bodyStart; + while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++; + if (starred && src[p] === "[") { + // Starred matrix variants take an optional alignment argument. + const close = src.indexOf("]", p); + if (close !== -1 && close < env.bodyEnd) { + p = close + 1; + while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++; + } + } + let colSpec: CellAlign[] | null = null; + if (base === "array" && src[p] === "{") { + const spec = readBraceGroup(src, p); + colSpec = [...spec.text].filter((ch): ch is CellAlign => ch === "l" || ch === "c" || ch === "r"); + p = spec.end; + } + const cells = splitRows(src.slice(p, env.bodyEnd)) + .map(row => row.trim()) + .filter(row => row !== "") + .map(row => splitCells(row).map(cell => parseExpr(cell, ctx))); + const isCases = base === "cases" || base === "dcases" || base === "rcases" || base === "drcases"; + const align: (col: number) => CellAlign = colSpec ? col => colSpec[col] ?? "c" : isCases ? () => "l" : () => "c"; + const grid = gridBox(cells, align, () => 2, 1); + return { box: delimBox(grid, gridDelims[0], gridDelims[1]), end: env.end }; + } + if (!DISPLAY_ROW_ENVIRONMENTS[base]) { + return { box: textBox(latexToUnicode(ctx.wrap(src.slice(start, env.end)))), end: env.end }; + } + let bodyStart = env.bodyStart; + if (base === "alignat" || base === "alignedat" || base === "gatheredat") { + // These carry a required column-count argument `{n}` before the body. + let p = bodyStart; + while (src[p] === " " || src[p] === "\n") p++; + if (src[p] === "{") bodyStart = readBraceGroup(src, p).end; + } + const rows = splitRows(src.slice(bodyStart, env.bodyEnd)) + .map(row => row.trim()) + .filter(row => row !== ""); + if (rows.length === 0) return { box: textBox(""), end: env.end }; + const cellRows = rows.map(splitCells); + let ncols = 0; + for (const row of cellRows) ncols = Math.max(ncols, row.length); + if (ncols <= 1) { + const centered = base === "gather" || base === "gathered" || base === "multline"; + return { + box: vconcat( + rows.map(row => parseExpr(row, ctx)), + centered ? "c" : "l", + ), + end: env.end, + }; + } + // `align`-family semantics: columns alternate right/left in `rl` pairs, a + // thin gap inside each pair and a wide gap between pairs. + const grid = gridBox( + cellRows.map(row => row.map(cell => parseExpr(cell, ctx))), + col => (col % 2 === 0 ? "r" : "l"), + col => (col % 2 === 1 ? 1 : 3), + ); + return { box: grid, end: env.end }; +} + +/** + * Paint every line of `box` through a `latexColorScope` painter so structural + * glyphs (fraction bars, stretched delimiters, matrix brackets) inherit the + * enclosing color scope while nested color runs still restore to it. + */ +function colorizeBox(box: Box, scope: (text: string) => string): Box { + return { lines: box.lines.map(scope), baseline: box.baseline, width: box.width }; +} + +/** + * Parse a math fragment into a layout box. 2-D constructs — fractions, binomials, + * radicals over tall content, `\left…\right` and tall bare parens, environments, + * big-operator limits, block scripts — become stacked boxes; everything between + * them is gathered into inline runs rendered through `latexToUnicode` under the + * active scope wrapper (`ctx`), with `\color` state re-applied per run. + */ +function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box { const boxes: Box[] = []; let inline = ""; + let color = ""; + let colorScope: ((text: string) => string) | null = null; const flush = (): void => { - if (inline) { - boxes.push(textBox(latexToUnicode(inline))); - inline = ""; - } + if (!inline) return; + boxes.push(textBox(latexToUnicode(ctx.wrap(color + inline)))); + inline = ""; }; + /** Child context carrying the enclosing wrapper plus current color state. */ + const inner = (): Ctx => { + if (!color) return ctx; + const pre = color; + return { wrap: run => ctx.wrap(pre + run) }; + }; + /** Apply the active `\color` scope to a structural box's glyphs. */ + const paint = (box: Box): Box => (colorScope === null ? box : colorizeBox(box, colorScope)); let i = 0; while (i < src.length) { const c = src[i]; @@ -348,19 +866,204 @@ function parseExpr(src: string): Box { flush(); const num = readArg(src, j); const den = readArg(src, num.end); - boxes.push(fracBox(parseExpr(num.text), parseExpr(den.text))); + boxes.push(paint(fracBox(parseExpr(num.text, inner()), parseExpr(den.text, inner())))); i = den.end; continue; } + if (name && BINOM_COMMANDS[name]) { + flush(); + const top = readArg(src, j); + const bottom = readArg(src, top.end); + boxes.push(paint(binomBox(parseExpr(top.text, inner()), parseExpr(bottom.text, inner())))); + i = bottom.end; + continue; + } + if (name === "sqrt") { + let k = j; + while (src[k] === " ") k++; + let degree: string | null = null; + if (src[k] === "[") { + const close = src.indexOf("]", k); + degree = src.slice(k + 1, close === -1 ? src.length : close); + k = close === -1 ? src.length : close + 1; + } + const arg = readArg(src, k); + // Display style always draws the roof (like LaTeX); inline math + // keeps the flat `√(…)` form via latexToUnicode. + flush(); + boxes.push(paint(radicalBox(parseExpr(arg.text, inner()), degree))); + i = arg.end; + continue; + } + if (name === "left") { + const lr = readLeftRight(src, i); + if (lr) { + const segBoxes = lr.segments.map(segment => parseExpr(segment, inner())); + let above = 0; + let below = 0; + for (const b of segBoxes) { + above = Math.max(above, b.baseline); + below = Math.max(below, b.lines.length - 1 - b.baseline); + } + const height = above + below + 1; + if (height === 1) { + // Single-line: keep the whole span inline so converter + // state (fonts, colors, spacing) is preserved. + inline += src.slice(i, lr.end); + i = lr.end; + continue; + } + flush(); + const parts: Box[] = []; + const push = (col: Box | null): void => { + if (col) parts.push(col); + }; + push(delimColumn(delimKey(lr.left), height, above)); + segBoxes.forEach((segment, s) => { + parts.push(segment); + if (s < lr.middles.length) push(delimColumn(delimKey(lr.middles[s]), height, above)); + }); + push(delimColumn(delimKey(lr.right), height, above)); + boxes.push(paint(hconcat(parts))); + i = lr.end; + continue; + } + } + if (name && (LIMIT_OPERATORS[name] || INTEGRAL_OPERATORS[name])) { + let k = j; + while (src[k] === " ") k++; + let stack = LIMIT_OPERATORS[name] === true; + let resume = j; // resume point when the operator stays inline + if (src.startsWith("\\limits", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) { + stack = true; + resume = k = k + 7; + } else if (src.startsWith("\\nolimits", k) && !/[A-Za-z]/.test(src[k + 9] ?? "")) { + stack = false; + resume = k + 9; + } + if (stack) { + let subText: string | null = null; + let supText: string | null = null; + let m = k; + for (;;) { + // Peek past spaces without consuming them, so a run + // following the operator keeps its leading space. + let n = m; + while (src[n] === " ") n++; + if (src[n] === "_" && subText === null) { + const arg = readArg(src, n + 1); + subText = arg.text; + m = arg.end; + continue; + } + if (src[n] === "^" && supText === null) { + const arg = readArg(src, n + 1); + supText = arg.text; + m = arg.end; + continue; + } + break; + } + if (subText !== null || supText !== null) { + flush(); + const glyph = textBox(latexToUnicode(ctx.wrap(`${color}\\${name}`))); + boxes.push( + paint( + limitsBox( + glyph, + subText === null ? null : parseExpr(subText, inner()), + supText === null ? null : parseExpr(supText, inner()), + ), + ), + ); + i = m; + continue; + } + } + inline += `\\${name}`; + i = resume; + continue; + } + if (name === "color" || name === "normalcolor") { + flush(); // preceding run keeps the previous color + if (name === "normalcolor") { + color = ""; + colorScope = null; + i = j; + continue; + } + let k = j; + while (src[k] === " ") k++; + let opt = ""; + if (src[k] === "[") { + const close = src.indexOf("]", k); + if (close !== -1) { + opt = src.slice(k, close + 1); + k = close + 1; + while (src[k] === " ") k++; + } + } + if (src[k] === "{") { + const spec = readBraceGroup(src, k); + color = `\\color${opt}{${spec.text}}`; + colorScope = latexColorScope(opt ? opt.slice(1, -1).trim() : null, spec.text); + i = spec.end; + } else { + color = ""; + colorScope = null; + i = k; + } + continue; + } if (name === "begin") { - const env = parseEnvironment(src, i); + const env = parseEnvironment(src, i, inner()); if (env) { flush(); - boxes.push(env.box); + boxes.push(paint(env.box)); i = env.end; continue; } } + if (name && (MATH_FONT_COMMANDS.has(name) || name === "textcolor")) { + // Scoped wrapper around 2-D content: recurse with the wrapper + // re-applied to every inline run, so styling crosses boxes. + let k = j; + while (src[k] === " ") k++; + let prefix = `\\${name}`; + let scope: ((text: string) => string) | null = null; + if (name === "textcolor") { + let model: string | null = null; + if (src[k] === "[") { + const close = src.indexOf("]", k); + if (close !== -1) { + model = src.slice(k + 1, close).trim(); + prefix += src.slice(k, close + 1); + k = close + 1; + while (src[k] === " ") k++; + } + } + if (src[k] !== "{") { + inline += `\\${name}`; + i = j; + continue; + } + const spec = readBraceGroup(src, k); + prefix += `{${spec.text}}`; + scope = latexColorScope(model, spec.text); + k = spec.end; + while (src[k] === " ") k++; + } + if (src[k] === "{") { + const content = readBraceGroup(src, k); + flush(); + const pre = color; + let box = parseExpr(content.text, { wrap: run => ctx.wrap(`${pre}${prefix}{${run}}`) }); + if (scope !== null) box = colorizeBox(box, scope); + boxes.push(paint(box)); + i = content.end; + continue; + } + } if (!name) { // Non-letter command (`\\`, `\,`, `\{`, …): keep the 2-char token inline. inline += `\\${src[j] ?? ""}`; @@ -386,18 +1089,72 @@ function parseExpr(src: string): Box { continue; } if (c === "^" || c === "_") { - const script = readScript(src, i); - inline += script.text; - i = script.end; + const first = readScript(src, i); + // Consume an immediately following opposite script (`M_i^j`) so both + // land in one shared column instead of two successive ones. + let second: Span | null = null; + let n = first.end; + while (src[n] === " ") n++; + if (src[n] === (c === "^" ? "_" : "^")) second = readScript(src, n); + const end = second === null ? first.end : second.end; + const supText = c === "^" ? first.text : second?.text; + const subText = c === "_" ? first.text : second?.text; + const supBox = supText === undefined ? null : parseExpr(scriptArgOf(supText), inner()); + const subBox = subText === undefined ? null : parseExpr(scriptArgOf(subText), inner()); + // The converter falls back to `^(…)`/`_(…)` when any character lacks a + // Unicode script form; those scripts get real raised/lowered boxes. + const unconvertible = (raw: string | undefined): boolean => { + if (raw === undefined) return false; + const flat = latexToUnicode(raw); + return flat.startsWith("^") || flat.startsWith("_"); + }; + const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1); + if (tall || unconvertible(supText) || unconvertible(subText)) { + // Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes + // against the run or box they follow. + flush(); + const base = boxes.pop() ?? textBox(""); + boxes.push(paint(attachScripts(base, subBox, supBox))); + i = end; + continue; + } + const last = boxes[boxes.length - 1]; + if (inline === "" && last !== undefined && last.lines.length > 1) { + // Scripts directly on a tall box (`M^T`, `\right|_{x=a}`): pin + // the Unicode script glyphs (guaranteed convertible here after + // the gate above) to its corners. + const corner = (raw: string | undefined): Box | null => + raw === undefined ? null : textBox(latexToUnicode(ctx.wrap(color + raw))); + boxes[boxes.length - 1] = paint(attachScripts(last, corner(subText), corner(supText))); + i = end; + continue; + } + inline += src.slice(i, end); + i = end; continue; } if (c === "{") { const group = readBraceGroup(src, i); flush(); - boxes.push(parseExpr(group.text)); + boxes.push(paint(parseExpr(group.text, inner()))); i = group.end; continue; } + if (c === "(" || c === "[") { + // Bare delimiters stretch when their content is tall (common in + // model output that omits `\left`/`\right`). + const closeCh = c === "(" ? ")" : "]"; + const close = matchDelim(src, i, c, closeCh); + if (close !== -1) { + const innerBox = parseExpr(src.slice(i + 1, close), inner()); + if (innerBox.lines.length > 1) { + flush(); + boxes.push(paint(delimBox(innerBox, c, closeCh))); + i = close + 1; + continue; + } + } + } inline += c; i++; } @@ -406,7 +1163,7 @@ function parseExpr(src: string): Box { return hconcat(boxes); } -/** Split on top-level `\n` row separators (outside braces and environments). */ +/** Split on top-level `\n` and `\\` row separators (outside braces and environments). */ function splitLines(src: string): string[] { const lines: string[] = []; let braceDepth = 0; @@ -426,7 +1183,18 @@ function splitLines(src: string): string[] { } const c = src[i]; if (c === "\\") { - i += 2; // escaped char / second backslash — never a logical-line break + if (src[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) { + lines.push(src.slice(last, i)); + i += 2; + while (src[i] === " ") i++; + if (src[i] === "[") { + const close = src.indexOf("]", i); + i = close === -1 ? src.length : close + 1; + } + last = i; + continue; + } + i += 2; // escaped char — never a logical-line break continue; } if (c === "{") braceDepth++; @@ -442,10 +1210,11 @@ function splitLines(src: string): string[] { } /** - * Render a display LaTeX math fragment to lines, stacking `\frac` vertically. - * Top-level source newlines become vertical rows (so a `lhs =` line stays above - * its block); each row stacks fractions via `parseExpr`. Inline math should use - * `latexToUnicode` instead — fractions there stay single-line. + * Render a display LaTeX math fragment to lines with full 2-D layout: stacked + * fractions, stretchy delimiters, matrix grids, operator limits, drawn + * radicals. Top-level source newlines and `\\` become vertical rows (so a + * `lhs =` line stays above its block). Inline math should use `latexToUnicode` + * instead — fractions there stay single-line. */ export function latexToBlock(src: string): string[] { if (typeof src !== "string" || src.trim() === "") return []; diff --git a/packages/tui/src/latex-to-unicode.ts b/packages/tui/src/latex-to-unicode.ts index c4c0fcf1d..35a39a93b 100644 --- a/packages/tui/src/latex-to-unicode.ts +++ b/packages/tui/src/latex-to-unicode.ts @@ -317,6 +317,13 @@ const FONTS: Record = { texttt: "mono", textsf: "sans", }; +/** + * Math font command names (`\mathbf`, `\mathbb`, …) whose single brace argument + * restyles glyphs. Exported for the display block engine (`latex-block`), which + * re-wraps inline runs inside these commands when their argument contains 2-D + * layout (fractions, matrices) so styling survives box boundaries. + */ +export const MATH_FONT_COMMANDS: ReadonlySet = new Set(Object.keys(FONTS)); // Text-mode commands whose argument is passed through literally (no math). const TEXT_COMMANDS: Record = { @@ -1136,6 +1143,22 @@ function ansiColor(model: string | null, spec: string): AnsiColor | null { return { foreground, background: foreground.replace("\x1b[38;", "\x1b[48;") }; } +/** + * Painter for a LaTeX color scope (optional model + spec, e.g. `rgb`/`1,0,0` or + * `red`): returns a function that paints already-rendered text with the scope's + * foreground, re-asserting it after embedded foreground resets so nested color + * runs restore to the scope color; null when the color cannot be resolved. Used + * by the display block engine (`latex-block`) to paint structural glyphs + * (fraction bars, stretched delimiters, matrix brackets) inside + * `\color`/`\textcolor` scopes. + */ +export function latexColorScope(model: string | null, spec: string): ((text: string) => string) | null { + const color = ansiColor(model, spec); + if (color === null) return null; + const { foreground } = color; + return text => foreground + text.replaceAll(ANSI_FG_RESET, foreground) + ANSI_FG_RESET; +} + function restoreAnsi( text: string, fromForeground: string | null, diff --git a/packages/tui/test/latex-block.test.ts b/packages/tui/test/latex-block.test.ts index 21e02b03c..2cf3a8168 100644 --- a/packages/tui/test/latex-block.test.ts +++ b/packages/tui/test/latex-block.test.ts @@ -26,8 +26,12 @@ describe("latexToBlock (stacked display fractions)", () => { expect(latexToBlock("\\frac{\\frac{a}{b}}{c}")).toEqual([" a ", " ─── ", " b ", "─────", " c "]); }); - it("keeps a plain expression on a single line", () => { - expect(latexToBlock("e^{i\\pi} + 1 = 0")).toEqual(["e^(iπ) + 1 = 0"]); + it("keeps a fully convertible expression on a single line", () => { + expect(latexToBlock("x^2 + y_1 = 0")).toEqual(["x² + y₁ = 0"]); + }); + + it("raises a non-convertible exponent as a block (Euler's identity)", () => { + expect(latexToBlock("e^{i\\pi} + 1 = 0").map(line => line.trimEnd())).toEqual([" iπ", "e + 1 = 0"]); }); it("stacks fractions inside wrapper environments (equation)", () => { @@ -56,11 +60,19 @@ describe("latexToBlock (stacked display fractions)", () => { expect(stripVTControlCharacters(lines[5])).toContain("4"); }); - it("renders matrices flat (grid environments are not stacked as fractions)", () => { - const lines = latexToBlock("\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}"); - expect(lines.length).toBe(2); - expect(lines[0].startsWith("[")).toBe(true); - expect(lines[lines.length - 1].endsWith("]")).toBe(true); + it("renders matrix environments as center-baselined grids in stretched brackets", () => { + expect(latexToBlock("\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}")).toEqual([ + "⎡ a b ⎤", + "⎢ ⎥", + "⎣ c d ⎦", + ]); + expect(latexToBlock("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}")).toEqual([ + "⎛ a b ⎞", + "⎜ ⎟", + "⎝ c d ⎠", + ]); + // Single-row matrices stay flat. + expect(latexToBlock("\\begin{pmatrix} a & b & c \\end{pmatrix}")).toEqual(["(a b c)"]); }); it("centers using visible width, ignoring ANSI color codes in a numerator", () => { @@ -77,3 +89,161 @@ describe("latexToBlock (stacked display fractions)", () => { expect(latexToBlock(" ")).toEqual([]); }); }); +describe("latexToBlock (2-D layout)", () => { + it("baseline-aligns matrix cells containing fractions", () => { + expect(latexToBlock("\\begin{bmatrix} \\frac{1}{2} & x \\\\ y & z \\end{bmatrix}")).toEqual([ + "⎡ 1 ⎤", + "⎢ ─── x ⎥", + "⎢ 2 ⎥", + "⎢ ⎥", + "⎣ y z ⎦", + ]); + }); + + it("centers surrounding text on the matrix middle", () => { + expect(latexToBlock("A = \\begin{bmatrix} a \\\\ b \\end{bmatrix}")).toEqual([ + " ⎡ a ⎤", + "A = ⎢ ⎥", + " ⎣ b ⎦", + ]); + }); + + it("renders vmatrix with full-height bars", () => { + expect(latexToBlock("\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}")).toEqual([ + "│ a b │", + "│ │", + "│ c d │", + ]); + }); + + it("honors the array column specification", () => { + expect( + latexToBlock("\\begin{array}{lcr} 1 & 22 & 333 \\\\ aaa & b & c \\end{array}").map(line => line.trimEnd()), + ).toEqual(["1 22 333", "", "aaa b c"]); + }); + + it("renders cases with a stretched left brace and left-aligned columns", () => { + const lines = latexToBlock("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & \\text{otherwise} \\end{cases}"); + expect(lines.map(line => line.trimEnd())).toEqual([" ⎧ x x > 0", "f(x) = ⎨", " ⎩ 0 otherwise"]); + }); + + it("stacks big-operator limits above and below the symbol", () => { + expect(latexToBlock("\\sum_{i=0}^{n} i^2")).toEqual([" n ", " ∑ i²", "i=0 "]); + }); + + it("places \\lim scripts underneath", () => { + const lines = latexToBlock("\\lim_{x \\to 0} \\frac{\\sin x}{x}"); + expect(lines[1]).toContain("lim"); + expect(lines[2]).toContain("x → 0"); + expect(lines[1]).toContain("───"); // fraction bar on the lim baseline row + }); + + it("keeps integral bounds beside the symbol unless \\limits is given", () => { + expect(latexToBlock("\\int_a^b f(x) dx")).toEqual(["∫ₐᵇ f(x) dx"]); + expect(latexToBlock("\\int\\limits_a^b f(x) dx").map(line => line.trimEnd())).toEqual(["b", "∫ f(x) dx", "a"]); + }); + + it("stretches \\left…\\right delimiters around tall content and pins corner scripts", () => { + expect(latexToBlock("\\left( \\frac{a+b}{c} \\right)^2").map(line => line.trimEnd())).toEqual([ + "⎛ a+b ⎞²", + "⎜ ───── ⎟", + "⎝ c ⎠", + ]); + }); + + it("stretches bare parentheses around a fraction", () => { + expect(latexToBlock("( \\frac{a}{b} )")).toEqual(["⎛ a ⎞", "⎜ ─── ⎟", "⎝ b ⎠"]); + }); + + it("leaves unbalanced interval brackets on the baseline", () => { + expect(latexToBlock("[0, 1)")).toEqual(["[0, 1)"]); + }); + + it("renders \\middle delimiters at full height inside \\left…\\right", () => { + const lines = latexToBlock("\\left\\{ x \\middle| \\frac{x}{2} \\in \\mathbb{Z} \\right\\}"); + expect(lines.length).toBe(3); + expect(lines[1].startsWith("⎨")).toBe(true); + expect(lines[1]).toContain("│"); + expect(lines[1].endsWith("⎬")).toBe(true); + }); + + it("always draws the radical roof in display math", () => { + expect(latexToBlock("\\sqrt{\\frac{a+1}{b}}").map(line => line.trimEnd())).toEqual([ + " ┌──────", + " │ a+1", + " │ ─────", + "╲│ b", + ]); + expect(latexToBlock("\\sqrt{x}").map(line => line.trimEnd())).toEqual([" ┌──", "╲│ x"]); + }); + + it("stacks \\binom inside stretched parentheses", () => { + expect(latexToBlock("\\binom{n}{k}")).toEqual(["⎛ n ⎞", "⎜ ⎟", "⎝ k ⎠"]); + }); + + it("raises a block superscript containing a fraction", () => { + expect(latexToBlock("e^{\\frac{x}{2}}").map(line => line.trimEnd())).toEqual([" x", " ───", " 2", "e"]); + }); + it("raises/lowers one-line scripts that have no Unicode form", () => { + // `q` has no superscript/subscript code point; a real box replaces `^(q)`. + expect(latexToBlock("x^q").map(line => line.trimEnd())).toEqual([" q", "x"]); + expect(latexToBlock("x_q").map(line => line.trimEnd())).toEqual(["x", " q"]); + expect(latexToBlock("x_q^q").map(line => line.trimEnd())).toEqual([" q", "x", " q"]); + }); + + it("pins both scripts of a tall base in one shared column", () => { + expect(latexToBlock("\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}_0^T").map(line => line.trimEnd())).toEqual([ + "⎡ a b ⎤ᵀ", + "⎢ ⎥", + "⎣ c d ⎦₀", + ]); + }); + + it("aligns align-environment rows on the & column", () => { + expect( + latexToBlock("\\begin{align} f(x) &= x^2 + 1 \\\\ g(x) &= \\frac{x}{2} \\end{align}").map(line => + line.trimEnd(), + ), + ).toEqual(["f(x) = x² + 1", " x", "g(x) = ───", " 2"]); + }); + + it("centers gather-environment rows", () => { + expect(latexToBlock("\\begin{gather} a = b \\\\ longer = expression \\end{gather}")).toEqual([ + " a = b ", + "longer = expression", + ]); + }); + + it("splits top-level \\\\ into vertical rows", () => { + expect(latexToBlock("a \\\\ b")).toEqual(["a", "b"]); + }); + + it("keeps \\color scope across a stacked fraction, painting the bar", () => { + Object.assign(TERMINAL, { trueColor: true }); + const lines = latexToBlock("\\color{red} x + \\frac{a}{b}"); + expect(lines.map(stripVTControlCharacters).map(line => line.trimEnd())).toEqual([ + " a", + " x + ───", + " b", + ]); + expect(lines[0]).toContain("\x1b[38;"); // numerator run is colored + expect(lines[1]).toContain("\x1b[38;"); // "x + " run and the bar are colored + }); + + it("paints textcolor-scoped structural glyphs (fraction bar)", () => { + Object.assign(TERMINAL, { trueColor: true }); + const lines = latexToBlock("\\textcolor{red}{\\frac{a}{b}}"); + expect(lines.map(stripVTControlCharacters)).toEqual([" a ", "───", " b "]); + expect(lines[1]).toContain("\x1b[38;"); // the synthesized bar inherits the scope color + }); + + it("styles fonts across a stacked fraction (\\mathbf)", () => { + expect(latexToBlock("\\mathbf{\\frac{a}{b}}")).toEqual([" 𝐚 ", "───", " 𝐛 "]); + }); + + it("stacks limit operators inside styling wrappers", () => { + Object.assign(TERMINAL, { trueColor: true }); + const lines = latexToBlock("\\textcolor{red}{\\sum_{i=1}^n}"); + expect(lines.map(stripVTControlCharacters).map(line => line.trimEnd())).toEqual([" n", " ∑", "i=1"]); + }); +}); diff --git a/packages/tui/test/markdown-math.test.ts b/packages/tui/test/markdown-math.test.ts index b80dce53d..e302feadd 100644 --- a/packages/tui/test/markdown-math.test.ts +++ b/packages/tui/test/markdown-math.test.ts @@ -23,21 +23,22 @@ describe("Markdown math rendering", () => { expect(line).toBe("energy xᵢ² + yⱼ² done"); }); - it("renders an own-line $$…$$ matrix block across multiple lines", () => { + it("renders an own-line $$…$$ matrix block as a bracketed grid", () => { const lines = renderLines("$$\n\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}\n$$"); - // Two rows, not collapsed onto one line. - expect(lines.length).toBe(2); - expect(lines[0].startsWith("[")).toBe(true); - expect(lines[lines.length - 1].endsWith("]")).toBe(true); - expect(lines.join("").replace(/[\s[\]]/g, "")).toBe("abcd"); + // Two content rows around a centering gap row, in stretched brackets. + expect(lines.length).toBe(3); + expect(lines[0].startsWith("⎡")).toBe(true); + expect(lines[lines.length - 1].endsWith("⎦")).toBe(true); + expect(lines.join("").replace(/[\s⎡⎤⎢⎥⎣⎦]/g, "")).toBe("abcd"); }); - it("stacks a \\[…\\] display fraction (quadratic formula)", () => { + it("stacks a \\[…\\] display quadratic formula with a drawn radical", () => { const lines = renderLines("\\[\nx = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n\\]"); - const barRow = lines.findIndex(line => line.includes("─")); - expect(barRow).toBeGreaterThan(0); - expect(lines[barRow]).toContain("x ="); - expect(lines[barRow - 1]).toContain("-b ± √(b² - 4ac)"); + const barRow = lines.findIndex(line => line.includes("x =")); + expect(barRow).toBeGreaterThan(1); + expect(lines[barRow]).toContain("───"); + expect(lines[barRow - 1]).toContain("-b ± ╲│ b² - 4ac"); + expect(lines[barRow - 2]).toContain("┌"); expect(lines[barRow + 1]).toContain("2a"); }); @@ -53,8 +54,8 @@ describe("Markdown math rendering", () => { it("keeps display math inside a list item multi-line", () => { const lines = renderLines("- result:\n\n $$\n \\begin{bmatrix} a \\\\ b \\end{bmatrix}\n $$"); // The matrix rows must land on distinct lines (not flattened to "[a b]"). - const openRow = lines.findIndex(line => line.includes("[a")); - const closeRow = lines.findIndex(line => line.includes("b]")); + const openRow = lines.findIndex(line => line.includes("⎡ a")); + const closeRow = lines.findIndex(line => line.includes("b ⎦")); expect(openRow).toBeGreaterThanOrEqual(0); expect(closeRow).toBeGreaterThan(openRow); }); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index dc33d4420..233c15b6e 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1830,11 +1830,11 @@ describe("Math rendering", () => { expect(out).not.toContain("begin{cases}"); }); - it("converts a $$-delimited matrix block to multi-line Unicode", () => { + it("converts a $$-delimited matrix block to a parenthesized grid", () => { const md = new Markdown("$$\n\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}\n$$", 0, 0, defaultMarkdownTheme); const out = plain(md); - expect(out).toContain("(a"); - expect(out).toContain("d)"); + expect(out).toContain("⎛ a"); + expect(out).toContain("d ⎠"); expect(out).not.toContain("pmatrix"); });