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.
This commit is contained in:
can1357
2026-07-10 20:40:04 +02:00
parent 60c9ad625e
commit 7cbe9fc0a8
6 changed files with 1065 additions and 89 deletions
+13
View File
@@ -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
File diff suppressed because it is too large Load Diff
+23
View File
@@ -317,6 +317,13 @@ const FONTS: Record<string, FontStyle> = {
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<string> = new Set(Object.keys(FONTS));
// Text-mode commands whose argument is passed through literally (no math).
const TEXT_COMMANDS: Record<string, true> = {
@@ -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,
+177 -7
View File
@@ -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"]);
});
});
+14 -13
View File
@@ -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);
});
+3 -3
View File
@@ -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");
});