@oh-my-pi/pi-tui 16.3.15 → 16.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.1] - 2026-07-10
6
+
7
+ ### Added
8
+
9
+ - 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)
10
+ - Added support for `\left`, `\right`, and `\middle` stretchy delimiters in display blocks
11
+ - Added rendering for `cases`, `matrix`, `pmatrix`, `bmatrix`, and `vmatrix` environments
12
+ - Added support for block-level scripts and big-operator limits (e.g., `\sum`, `\int\limits`)
13
+ - Added cross-box styling for `\color`, `\textcolor`, and math font commands (e.g., `\mathbf`)
14
+
15
+ ### Changed
16
+
17
+ - Improved row alignment and spacing for `align`, `gather`, and `array` environments
18
+ - Updated matrix environments to render as baseline-aligned grids with stretched brackets
19
+
20
+ ## [16.4.0] - 2026-07-10
21
+
22
+ ### Fixed
23
+
24
+ - Fixed terminal flickering during session resume, replacement, or resizing on terminals that do not support synchronized output.
25
+
5
26
  ## [16.3.14] - 2026-07-09
6
27
 
7
28
  ### Fixed
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Render a display LaTeX math fragment to lines, stacking `\frac` vertically.
3
- * Top-level source newlines become vertical rows (so a `lhs =` line stays above
4
- * its block); each row stacks fractions via `parseExpr`. Inline math should use
5
- * `latexToUnicode` instead fractions there stay single-line.
2
+ * Render a display LaTeX math fragment to lines with full 2-D layout: stacked
3
+ * fractions, stretchy delimiters, matrix grids, operator limits, drawn
4
+ * radicals. Top-level source newlines and `\\` become vertical rows (so a
5
+ * `lhs =` line stays above its block). Inline math should use `latexToUnicode`
6
+ * instead — fractions there stay single-line.
6
7
  */
7
8
  export declare function latexToBlock(src: string): string[];
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Math font command names (`\mathbf`, `\mathbb`, …) whose single brace argument
3
+ * restyles glyphs. Exported for the display block engine (`latex-block`), which
4
+ * re-wraps inline runs inside these commands when their argument contains 2-D
5
+ * layout (fractions, matrices) so styling survives box boundaries.
6
+ */
7
+ export declare const MATH_FONT_COMMANDS: ReadonlySet<string>;
8
+ /**
9
+ * Painter for a LaTeX color scope (optional model + spec, e.g. `rgb`/`1,0,0` or
10
+ * `red`): returns a function that paints already-rendered text with the scope's
11
+ * foreground, re-asserting it after embedded foreground resets so nested color
12
+ * runs restore to the scope color; null when the color cannot be resolved. Used
13
+ * by the display block engine (`latex-block`) to paint structural glyphs
14
+ * (fraction bars, stretched delimiters, matrix brackets) inside
15
+ * `\color`/`\textcolor` scopes.
16
+ */
17
+ export declare function latexColorScope(model: string | null, spec: string): ((text: string) => string) | null;
1
18
  /**
2
19
  * Convert a bare LaTeX math fragment (no surrounding `$`/`\(` delimiters) to its
3
20
  * best-effort Unicode rendering. Unknown commands degrade to their bare name;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.3.15",
4
+ "version": "16.4.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.3.15",
41
- "@oh-my-pi/pi-utils": "16.3.15",
40
+ "@oh-my-pi/pi-natives": "16.4.1",
41
+ "@oh-my-pi/pi-utils": "16.4.1",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -1,16 +1,27 @@
1
- // Two-dimensional layout for *display* LaTeX math: stacks `\frac` numerator over
2
- // denominator with a horizontal bar, aligning surrounding text to the bar's row.
1
+ // Two-dimensional layout engine for *display* LaTeX math.
3
2
  //
4
- // b ± √(b² − 4ac)
5
- // x = ────────────────
6
- // 2a
3
+ // ┌───────── n ⎛ a+b ⎞²
4
+ // −b ± ╲│ b² − 4ac ∑ xᵢ ⎜ ───── ⎟ ⎡ 1 2 ⎤
5
+ // x = ────────────────── i=0 ⎝ c ⎠ ⎣ 3 4 ⎦
6
+ // 2a
7
7
  //
8
8
  // Only display blocks (`$$…$$`, `\[…\]`) use this; inline `$…$` stays single-line
9
- // (`½`, `(a+b)/c`). Everything that is not a fraction symbols, scripts, roots,
10
- // matrices, environments is delegated to `latexToUnicode`, so this engine only
11
- // adds the vertical stacking the flat string form can't express.
9
+ // via `latexToUnicode` (`½`, `(a+b)/c`). The engine lays out a `Box` tree
10
+ // rectangles of padded lines with a `baseline` row and knows how to stack
11
+ // fractions and `\binom`, stretch delimiters (`\left…\right`, tall bare parens,
12
+ // matrix brackets), render matrix/cases/array environments as baseline-aligned
13
+ // grids, place big-operator limits (`\sum`, `\lim`, `\int\limits`) above and
14
+ // below the symbol, draw radicals, raise/lower block scripts, and
15
+ // align `&` columns in `align`-family environments. Flat runs — symbols, fonts,
16
+ // colors, inline scripts — are delegated to `latexToUnicode`.
17
+ //
18
+ // The 2-D layout approach (stretchy delimiter piecing, stacked operator limits,
19
+ // baseline-aligned matrix grids, drawn radicals, block scripts) is modeled on
20
+ // txm — Terminal TeX Math — by @thatmagicalcat
21
+ // (https://github.com/thatmagicalcat/txm, MIT/Apache-2.0), reimplemented from
22
+ // scratch here on this module's ANSI-aware Box model.
12
23
 
13
- import { latexToUnicode } from "./latex-to-unicode";
24
+ import { latexColorScope, latexToUnicode, MATH_FONT_COMMANDS } from "./latex-to-unicode";
14
25
  import { visibleWidth } from "./utils";
15
26
 
16
27
  /**
@@ -24,13 +35,15 @@ interface Box {
24
35
  width: number;
25
36
  }
26
37
 
38
+ type CellAlign = "l" | "c" | "r";
39
+
27
40
  const BAR = "─";
28
41
  const FRAC_COMMANDS: Record<string, true> = { frac: true, dfrac: true, tfrac: true, cfrac: true };
42
+ const BINOM_COMMANDS: Record<string, true> = { binom: true, dbinom: true, tbinom: true };
29
43
 
30
44
  // Display "wrapper" environments whose body is an expression (possibly with `\\`
31
- // row breaks and `&` alignment). Their bodies are parsed so fractions inside
32
- // stack; grid/structure environments (matrix/array/cases) stay opaque and are
33
- // rendered flat by `latexToUnicode`.
45
+ // row breaks and `&` alignment). Their rows are parsed so fractions inside stack
46
+ // and `&` columns align.
34
47
  const DISPLAY_ROW_ENVIRONMENTS: Record<string, true> = {
35
48
  equation: true,
36
49
  eqnarray: true,
@@ -48,6 +61,140 @@ const DISPLAY_ROW_ENVIRONMENTS: Record<string, true> = {
48
61
  math: true,
49
62
  };
50
63
 
64
+ // Environments laid out as 2-D grids of parsed cells: [open, close] delimiter.
65
+ const GRID_ENVIRONMENTS: Record<string, readonly [string, string]> = {
66
+ matrix: ["", ""],
67
+ smallmatrix: ["", ""],
68
+ array: ["", ""],
69
+ pmatrix: ["(", ")"],
70
+ bmatrix: ["[", "]"],
71
+ Bmatrix: ["{", "}"],
72
+ vmatrix: ["|", "|"],
73
+ Vmatrix: ["‖", "‖"],
74
+ cases: ["{", ""],
75
+ dcases: ["{", ""],
76
+ rcases: ["", "}"],
77
+ drcases: ["", "}"],
78
+ };
79
+
80
+ // Operators whose display-style scripts stack above/below the symbol.
81
+ const LIMIT_OPERATORS: Record<string, true> = {
82
+ sum: true,
83
+ prod: true,
84
+ coprod: true,
85
+ bigcup: true,
86
+ bigcap: true,
87
+ bigsqcup: true,
88
+ bigvee: true,
89
+ bigwedge: true,
90
+ bigoplus: true,
91
+ bigotimes: true,
92
+ bigodot: true,
93
+ biguplus: true,
94
+ lim: true,
95
+ limsup: true,
96
+ liminf: true,
97
+ projlim: true,
98
+ injlim: true,
99
+ varlimsup: true,
100
+ varliminf: true,
101
+ varprojlim: true,
102
+ varinjlim: true,
103
+ max: true,
104
+ min: true,
105
+ sup: true,
106
+ inf: true,
107
+ det: true,
108
+ gcd: true,
109
+ Pr: true,
110
+ argmax: true,
111
+ argmin: true,
112
+ };
113
+
114
+ // Integral-family operators: scripts stay beside the symbol (LaTeX display
115
+ // convention) unless an explicit `\limits` follows.
116
+ const INTEGRAL_OPERATORS: Record<string, true> = {
117
+ int: true,
118
+ iint: true,
119
+ iiint: true,
120
+ iiiint: true,
121
+ oint: true,
122
+ oiint: true,
123
+ oiiint: true,
124
+ idotsint: true,
125
+ intop: true,
126
+ smallint: true,
127
+ };
128
+
129
+ // Vertical delimiter piece characters: `only` for single-line content, then
130
+ // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
131
+ // baseline row (the brace point).
132
+ interface DelimPieces {
133
+ only: string;
134
+ top: string;
135
+ mid: string;
136
+ bot: string;
137
+ axis?: string;
138
+ }
139
+
140
+ const DELIM_PIECES: Record<string, DelimPieces> = {
141
+ "(": { only: "(", top: "⎛", mid: "⎜", bot: "⎝" },
142
+ ")": { only: ")", top: "⎞", mid: "⎟", bot: "⎠" },
143
+ "[": { only: "[", top: "⎡", mid: "⎢", bot: "⎣" },
144
+ "]": { only: "]", top: "⎤", mid: "⎥", bot: "⎦" },
145
+ "{": { only: "{", top: "⎧", mid: "⎪", bot: "⎩", axis: "⎨" },
146
+ "}": { only: "}", top: "⎫", mid: "⎪", bot: "⎭", axis: "⎬" },
147
+ "|": { only: "|", top: "│", mid: "│", bot: "│" },
148
+ "‖": { only: "‖", top: "║", mid: "║", bot: "║" },
149
+ "⌈": { only: "⌈", top: "⎡", mid: "⎢", bot: "⎢" },
150
+ "⌉": { only: "⌉", top: "⎤", mid: "⎥", bot: "⎥" },
151
+ "⌊": { only: "⌊", top: "⎢", mid: "⎢", bot: "⎣" },
152
+ "⌋": { only: "⌋", top: "⎥", mid: "⎥", bot: "⎦" },
153
+ };
154
+
155
+ // `\left`/`\right`/`\middle` delimiter token → piece-table key. Unknown tokens
156
+ // fall back to `latexToUnicode` and render at the baseline row only.
157
+ const DELIM_KEYS: Record<string, string> = {
158
+ "(": "(",
159
+ ")": ")",
160
+ "[": "[",
161
+ "]": "]",
162
+ "\\{": "{",
163
+ "\\}": "}",
164
+ "\\lbrace": "{",
165
+ "\\rbrace": "}",
166
+ "|": "|",
167
+ "\\vert": "|",
168
+ "\\lvert": "|",
169
+ "\\rvert": "|",
170
+ "\\|": "‖",
171
+ "\\Vert": "‖",
172
+ "\\lVert": "‖",
173
+ "\\rVert": "‖",
174
+ "\\langle": "⟨",
175
+ "\\rangle": "⟩",
176
+ "<": "⟨",
177
+ ">": "⟩",
178
+ "\\lceil": "⌈",
179
+ "\\rceil": "⌉",
180
+ "\\lfloor": "⌊",
181
+ "\\rfloor": "⌋",
182
+ "\\lbrack": "[",
183
+ "\\rbrack": "]",
184
+ ".": "",
185
+ };
186
+
187
+ /**
188
+ * Inline-run conversion context. `wrap` re-applies the scoped commands (math
189
+ * fonts, colors) active at this point in the parse, so each flat run handed to
190
+ * `latexToUnicode` renders with the same styling it would have had in one piece.
191
+ */
192
+ interface Ctx {
193
+ wrap: (run: string) => string;
194
+ }
195
+
196
+ const ROOT_CTX: Ctx = { wrap: run => run };
197
+
51
198
  function spaces(n: number): string {
52
199
  return n > 0 ? " ".repeat(n) : "";
53
200
  }
@@ -73,6 +220,19 @@ function textBox(text: string): Box {
73
220
  return { lines: raw.map(line => padRight(line, width)), baseline: (raw.length - 1) >> 1, width };
74
221
  }
75
222
 
223
+ /** Pad every line of `b` to `width` per `align`, keeping the baseline. */
224
+ function padBox(b: Box, width: number, align: CellAlign): Box {
225
+ if (b.width >= width) return b;
226
+ const lines = b.lines.map(line => {
227
+ const extra = width - visibleWidth(line);
228
+ if (align === "l") return line + spaces(extra);
229
+ if (align === "r") return spaces(extra) + line;
230
+ const left = extra >> 1;
231
+ return spaces(left) + line + spaces(extra - left);
232
+ });
233
+ return { lines, baseline: b.baseline, width };
234
+ }
235
+
76
236
  /** Place boxes side by side, aligning their baselines. */
77
237
  function hconcat(boxes: Box[]): Box {
78
238
  if (boxes.length === 1) return boxes[0];
@@ -97,6 +257,18 @@ function hconcat(boxes: Box[]): Box {
97
257
  return { lines, baseline: above, width };
98
258
  }
99
259
 
260
+ /** Stack boxes vertically, e.g. the rows of an aligned block. */
261
+ function vconcat(boxes: Box[], align: CellAlign = "l"): Box {
262
+ if (boxes.length === 1) return boxes[0];
263
+ let width = 0;
264
+ for (const b of boxes) width = Math.max(width, b.width);
265
+ const lines: string[] = [];
266
+ for (const b of boxes) {
267
+ for (const line of b.lines) lines.push(align === "c" ? center(line, width) : padRight(line, width));
268
+ }
269
+ return { lines, baseline: (lines.length - 1) >> 1, width };
270
+ }
271
+
100
272
  /** Stack `num` over `den`, separated by a bar; the bar becomes the baseline. */
101
273
  function fracBox(num: Box, den: Box): Box {
102
274
  const width = Math.max(num.width, den.width) + 2;
@@ -108,14 +280,157 @@ function fracBox(num: Box, den: Box): Box {
108
280
  return { lines, baseline: num.lines.length, width };
109
281
  }
110
282
 
111
- /** Stack boxes vertically (left-aligned), e.g. the rows of an aligned block. */
112
- function vconcat(boxes: Box[]): Box {
113
- if (boxes.length === 1) return boxes[0];
114
- let width = 0;
115
- for (const b of boxes) width = Math.max(width, b.width);
283
+ /**
284
+ * One vertical delimiter column of `height` rows for piece-table key `key`
285
+ * (`"("`, `"{"`, ); null when `key` is empty (`\left.`). Unknown keys render a
286
+ * single glyph at the baseline row.
287
+ */
288
+ function delimColumn(key: string, height: number, baseline: number): Box | null {
289
+ if (!key) return null;
290
+ const pieces = DELIM_PIECES[key];
291
+ if (height <= 1) {
292
+ const only = pieces?.only ?? key;
293
+ return only ? { lines: [only], baseline: 0, width: visibleWidth(only) } : null;
294
+ }
295
+ const width = visibleWidth(pieces?.only ?? key);
296
+ const blank = spaces(width);
116
297
  const lines: string[] = [];
117
- for (const b of boxes) for (const line of b.lines) lines.push(padRight(line, width));
118
- return { lines, baseline: (lines.length - 1) >> 1, width };
298
+ if (!pieces) {
299
+ for (let y = 0; y < height; y++) lines.push(y === baseline ? key : blank);
300
+ return { lines, baseline, width };
301
+ }
302
+ const axisRow = Math.min(Math.max(baseline, 1), height - 2);
303
+ for (let y = 0; y < height; y++) {
304
+ if (y === 0) lines.push(pieces.top);
305
+ else if (y === height - 1) lines.push(pieces.bot);
306
+ else if (y === axisRow && pieces.axis) lines.push(pieces.axis);
307
+ else lines.push(pieces.mid);
308
+ }
309
+ return { lines, baseline, width };
310
+ }
311
+
312
+ /** Wrap `inner` in (possibly stretched) delimiters, padding tall content. */
313
+ function delimBox(inner: Box, left: string, right: string): Box {
314
+ const height = inner.lines.length;
315
+ const lcol = delimColumn(left, height, inner.baseline);
316
+ const rcol = delimColumn(right, height, inner.baseline);
317
+ if (!lcol && !rcol) return inner;
318
+ const pad: Box | null = height > 1 ? textBox(" ") : null;
319
+ const parts: Box[] = [];
320
+ if (lcol) parts.push(lcol);
321
+ if (pad) parts.push(pad);
322
+ parts.push(inner);
323
+ if (pad) parts.push(pad);
324
+ if (rcol) parts.push(rcol);
325
+ return hconcat(parts);
326
+ }
327
+
328
+ /** `\binom{n}{k}`: `n` over `k` (no bar) inside stretched parentheses. */
329
+ function binomBox(top: Box, bottom: Box): Box {
330
+ const width = Math.max(top.width, bottom.width);
331
+ const lines = [
332
+ ...top.lines.map(line => center(line, width)),
333
+ spaces(width),
334
+ ...bottom.lines.map(line => center(line, width)),
335
+ ];
336
+ return delimBox({ lines, baseline: top.lines.length, width }, "(", ")");
337
+ }
338
+
339
+ /**
340
+ * A drawn radical for a multi-line radicand: overline row on top, bar column
341
+ * on the left, hook at the bottom. Single-line radicands stay flat (`√x̄`).
342
+ */
343
+ function radicalBox(inner: Box, degree: string | null): Box {
344
+ const lines: string[] = [` ┌${BAR.repeat(inner.width + 1)}`];
345
+ for (let y = 0; y < inner.lines.length; y++) {
346
+ lines.push((y === inner.lines.length - 1 ? "╲│ " : " │ ") + inner.lines[y]);
347
+ }
348
+ const box: Box = { lines, baseline: inner.baseline + 1, width: inner.width + 3 };
349
+ if (!degree) return box;
350
+ const deg = latexToUnicode(`^{${degree}}`);
351
+ // Degree sits one row above the baseline, at the radical's upper left.
352
+ return hconcat([{ lines: [deg, spaces(visibleWidth(deg))], baseline: 1, width: visibleWidth(deg) }, box]);
353
+ }
354
+
355
+ /** Big operator with limits: `sup` centered above `glyph`, `sub` below. */
356
+ function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box {
357
+ const width = Math.max(glyph.width, sub?.width ?? 0, sup?.width ?? 0);
358
+ const lines: string[] = [];
359
+ if (sup) for (const line of sup.lines) lines.push(center(line, width));
360
+ const baseline = lines.length + glyph.baseline;
361
+ for (const line of glyph.lines) lines.push(center(line, width));
362
+ if (sub) for (const line of sub.lines) lines.push(center(line, width));
363
+ return { lines, baseline, width };
364
+ }
365
+
366
+ /**
367
+ * Attach block scripts to `base` as one shared right-hand column: the
368
+ * superscript ends level with the base's top row (raised one row above a
369
+ * single-line base), the subscript starts level with its bottom row (lowered
370
+ * one row below a single-line base).
371
+ */
372
+ function attachScripts(base: Box, sub: Box | null, sup: Box | null): Box {
373
+ if (sub === null && sup === null) return base;
374
+ const single = base.lines.length === 1;
375
+ const width = Math.max(sub?.width ?? 0, sup?.width ?? 0);
376
+ const blank = spaces(width);
377
+ const lines: string[] = [];
378
+ let baseline = 0;
379
+ if (sup) {
380
+ const lift = single ? 1 : base.baseline;
381
+ for (const line of sup.lines) lines.push(padRight(line, width));
382
+ for (let k = 0; k < lift; k++) lines.push(blank);
383
+ baseline = lines.length - 1;
384
+ }
385
+ if (sub) {
386
+ const below = base.lines.length - 1 - base.baseline - (sub.lines.length - 1);
387
+ let drop = Math.max(below, single ? 1 : 0);
388
+ if (sup && drop < 1) drop = 1;
389
+ // Rows between the baseline row and the subscript's top row.
390
+ const gap = lines.length === 0 ? drop : drop - 1;
391
+ for (let k = 0; k < gap; k++) lines.push(blank);
392
+ for (const line of sub.lines) lines.push(padRight(line, width));
393
+ }
394
+ return hconcat([base, { lines, baseline, width }]);
395
+ }
396
+
397
+ /**
398
+ * Lay out parsed cells as a grid: per-column width/alignment, per-gap width.
399
+ * With `rowGap > 0` (matrix-family environments), blank rows separate the grid
400
+ * rows and the total height is forced odd, so the baseline sits at the true
401
+ * vertical center — `A = [matrix]` centers on the brackets, and stretched
402
+ * braces get a real middle piece even for two content rows.
403
+ */
404
+ function gridBox(rows: Box[][], align: (col: number) => CellAlign, gap: (col: number) => number, rowGap = 0): Box {
405
+ let ncols = 0;
406
+ for (const row of rows) ncols = Math.max(ncols, row.length);
407
+ if (ncols === 0 || rows.length === 0) return textBox("");
408
+ const widths = new Array<number>(ncols).fill(0);
409
+ for (const row of rows) {
410
+ row.forEach((cell, j) => {
411
+ widths[j] = Math.max(widths[j], cell.width);
412
+ });
413
+ }
414
+ const rowBoxes: Box[] = [];
415
+ for (const row of rows) {
416
+ if (rowGap > 0 && rowBoxes.length > 0) {
417
+ for (let g = 0; g < rowGap; g++) rowBoxes.push({ lines: [""], baseline: 0, width: 0 });
418
+ }
419
+ const parts: Box[] = [];
420
+ for (let j = 0; j < ncols; j++) {
421
+ if (j > 0) {
422
+ const g = gap(j);
423
+ if (g > 0) parts.push({ lines: [spaces(g)], baseline: 0, width: g });
424
+ }
425
+ parts.push(padBox(row[j] ?? { lines: [""], baseline: 0, width: 0 }, widths[j], align(j)));
426
+ }
427
+ rowBoxes.push(hconcat(parts));
428
+ }
429
+ const grid = vconcat(rowBoxes);
430
+ if (rowGap > 0 && rows.length > 1 && grid.lines.length % 2 === 0) {
431
+ return { lines: [...grid.lines, spaces(grid.width)], baseline: grid.lines.length >> 1, width: grid.width };
432
+ }
433
+ return grid;
119
434
  }
120
435
 
121
436
  interface Span {
@@ -155,7 +470,7 @@ function readBraceGroup(src: string, i: number): Span {
155
470
  }
156
471
 
157
472
  /**
158
- * Read one fraction argument: a `{…}` group, a single char, or a `\command`
473
+ * Read one command argument: a `{…}` group, a single char, or a `\command`
159
474
  * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
160
475
  * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
161
476
  */
@@ -186,6 +501,100 @@ function readArg(src: string, i: number): Span {
186
501
  return { text: src.slice(i, end), end };
187
502
  }
188
503
 
504
+ /** Read a `\left`/`\right`/`\middle` delimiter token (char or `\command`). */
505
+ function readDelimToken(src: string, i: number): Span | null {
506
+ while (src[i] === " ") i++;
507
+ if (i >= src.length) return null;
508
+ if (src[i] !== "\\") return { text: src[i], end: i + 1 };
509
+ let j = i + 1;
510
+ if (!/[A-Za-z]/.test(src[j] ?? "")) return { text: src.slice(i, j + 1), end: j + 1 };
511
+ while (/[A-Za-z]/.test(src[j] ?? "")) j++;
512
+ return { text: src.slice(i, j), end: j };
513
+ }
514
+
515
+ /** Piece-table key for a delimiter token; unknown commands resolve via Unicode. */
516
+ function delimKey(token: string): string {
517
+ const mapped = DELIM_KEYS[token];
518
+ if (mapped !== undefined) return mapped;
519
+ return token.startsWith("\\") ? latexToUnicode(token).trim() : token;
520
+ }
521
+
522
+ interface LeftRightParts {
523
+ left: string;
524
+ /** Inner source split at top-level `\middle` delimiters. */
525
+ segments: string[];
526
+ middles: string[];
527
+ right: string;
528
+ end: number;
529
+ }
530
+
531
+ /** Parse `\left⟨tok⟩ … \right⟨tok⟩` starting at the backslash of `\left`. */
532
+ function readLeftRight(src: string, start: number): LeftRightParts | null {
533
+ const left = readDelimToken(src, start + 5);
534
+ if (!left) return null;
535
+ const segments: string[] = [];
536
+ const middles: string[] = [];
537
+ let depth = 1;
538
+ let k = left.end;
539
+ let segStart = k;
540
+ while (k < src.length) {
541
+ if (src[k] !== "\\") {
542
+ k++;
543
+ continue;
544
+ }
545
+ if (src.startsWith("\\left", k) && !/[A-Za-z]/.test(src[k + 5] ?? "")) {
546
+ depth++;
547
+ const tok = readDelimToken(src, k + 5);
548
+ k = tok ? tok.end : k + 5;
549
+ continue;
550
+ }
551
+ if (src.startsWith("\\right", k) && !/[A-Za-z]/.test(src[k + 6] ?? "")) {
552
+ depth--;
553
+ const tok = readDelimToken(src, k + 6);
554
+ if (depth === 0) {
555
+ segments.push(src.slice(segStart, k));
556
+ return { left: left.text, segments, middles, right: tok ? tok.text : ".", end: tok ? tok.end : k + 6 };
557
+ }
558
+ k = tok ? tok.end : k + 6;
559
+ continue;
560
+ }
561
+ if (depth === 1 && src.startsWith("\\middle", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) {
562
+ segments.push(src.slice(segStart, k));
563
+ const tok = readDelimToken(src, k + 7);
564
+ middles.push(tok ? tok.text : "|");
565
+ k = segStart = tok ? tok.end : k + 7;
566
+ continue;
567
+ }
568
+ k += 2; // escaped char / other command head — never a boundary
569
+ }
570
+ return null; // unbalanced
571
+ }
572
+
573
+ /**
574
+ * Index of the `close` matching the `open` at `i`, skipping escapes and brace
575
+ * groups; −1 when unbalanced (e.g. interval notation `[0, 1)`).
576
+ */
577
+ function matchDelim(src: string, i: number, open: string, close: string): number {
578
+ let depth = 0;
579
+ for (let k = i; k < src.length; k++) {
580
+ const c = src[k];
581
+ if (c === "\\") {
582
+ k++;
583
+ continue;
584
+ }
585
+ if (c === "{") {
586
+ k = readBraceGroup(src, k).end - 1;
587
+ continue;
588
+ }
589
+ if (c === open) depth++;
590
+ else if (c === close) {
591
+ depth--;
592
+ if (depth === 0) return k;
593
+ }
594
+ }
595
+ return -1;
596
+ }
597
+
189
598
  interface EnvParts {
190
599
  env: string;
191
600
  bodyStart: number;
@@ -270,31 +679,39 @@ function splitRows(body: string): string[] {
270
679
  return rows;
271
680
  }
272
681
 
273
- /**
274
- * Render a `\begin{env}…\end{env}` block. Expression "wrapper" environments
275
- * (`equation`, `align`, `gather`, …) have their rows parsed so fractions stack;
276
- * grid/structure environments (matrix/array/cases) render flat via
277
- * `latexToUnicode`.
278
- */
279
- function parseEnvironment(src: string, start: number): { box: Box; end: number } | null {
280
- const env = readEnvironment(src, start);
281
- if (env === null) return null;
282
- const base = env.env.endsWith("*") ? env.env.slice(0, -1) : env.env;
283
- if (!DISPLAY_ROW_ENVIRONMENTS[base]) {
284
- return { box: textBox(latexToUnicode(src.slice(start, env.end))), end: env.end };
285
- }
286
- let bodyStart = env.bodyStart;
287
- if (base === "alignat" || base === "alignedat" || base === "gatheredat") {
288
- // These carry a required column-count argument `{n}` before the body.
289
- let p = bodyStart;
290
- while (src[p] === " " || src[p] === "\n") p++;
291
- if (src[p] === "{") bodyStart = readBraceGroup(src, p).end;
682
+ /** Split a row on top-level `&` column separators (depth-aware), trimming cells. */
683
+ function splitCells(row: string): string[] {
684
+ const cells: string[] = [];
685
+ let braceDepth = 0;
686
+ let envDepth = 0;
687
+ let last = 0;
688
+ let i = 0;
689
+ while (i < row.length) {
690
+ if (row.startsWith("\\begin", i)) {
691
+ envDepth++;
692
+ i += 6;
693
+ continue;
694
+ }
695
+ if (row.startsWith("\\end", i)) {
696
+ envDepth--;
697
+ i += 4;
698
+ continue;
699
+ }
700
+ const c = row[i];
701
+ if (c === "\\") {
702
+ i += 2; // `\&` and command heads never split
703
+ continue;
704
+ }
705
+ if (c === "{") braceDepth++;
706
+ else if (c === "}") braceDepth--;
707
+ else if (c === "&" && braceDepth === 0 && envDepth === 0) {
708
+ cells.push(row.slice(last, i));
709
+ last = i + 1;
710
+ }
711
+ i++;
292
712
  }
293
- const rows = splitRows(src.slice(bodyStart, env.bodyEnd))
294
- .map(row => row.trim())
295
- .filter(row => row !== "")
296
- .map(row => parseExpr(row));
297
- return { box: rows.length > 0 ? vconcat(rows) : textBox(""), end: env.end };
713
+ cells.push(row.slice(last));
714
+ return cells.map(cell => cell.trim());
298
715
  }
299
716
 
300
717
  /** Append a script (`^`/`_`) and its argument to the inline run verbatim. */
@@ -319,21 +736,122 @@ function readScript(src: string, i: number): Span {
319
736
  return { text: out, end: i };
320
737
  }
321
738
 
739
+ /** Bare argument of a script read by `readScript` (`^{ab}` → `ab`, `^a` → `a`). */
740
+ function scriptArgOf(text: string): string {
741
+ let arg = text.slice(1).trimStart();
742
+ if (arg.startsWith("{") && arg.endsWith("}")) arg = arg.slice(1, -1);
743
+ return arg;
744
+ }
745
+
746
+ /**
747
+ * Render a `\begin{env}…\end{env}` block. Grid environments (matrix family,
748
+ * cases, array) become baseline-aligned 2-D grids in stretched delimiters;
749
+ * wrapper environments (`align`, `gather`, …) parse each `\\` row, aligning `&`
750
+ * columns; anything else (tabular, …) renders flat via `latexToUnicode`.
751
+ */
752
+ function parseEnvironment(src: string, start: number, ctx: Ctx): { box: Box; end: number } | null {
753
+ const env = readEnvironment(src, start);
754
+ if (env === null) return null;
755
+ const starred = env.env.endsWith("*");
756
+ const base = starred ? env.env.slice(0, -1) : env.env;
757
+ const gridDelims = GRID_ENVIRONMENTS[base];
758
+ if (gridDelims) {
759
+ let p = env.bodyStart;
760
+ while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++;
761
+ if (starred && src[p] === "[") {
762
+ // Starred matrix variants take an optional alignment argument.
763
+ const close = src.indexOf("]", p);
764
+ if (close !== -1 && close < env.bodyEnd) {
765
+ p = close + 1;
766
+ while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++;
767
+ }
768
+ }
769
+ let colSpec: CellAlign[] | null = null;
770
+ if (base === "array" && src[p] === "{") {
771
+ const spec = readBraceGroup(src, p);
772
+ colSpec = [...spec.text].filter((ch): ch is CellAlign => ch === "l" || ch === "c" || ch === "r");
773
+ p = spec.end;
774
+ }
775
+ const cells = splitRows(src.slice(p, env.bodyEnd))
776
+ .map(row => row.trim())
777
+ .filter(row => row !== "")
778
+ .map(row => splitCells(row).map(cell => parseExpr(cell, ctx)));
779
+ const isCases = base === "cases" || base === "dcases" || base === "rcases" || base === "drcases";
780
+ const align: (col: number) => CellAlign = colSpec ? col => colSpec[col] ?? "c" : isCases ? () => "l" : () => "c";
781
+ const grid = gridBox(cells, align, () => 2, 1);
782
+ return { box: delimBox(grid, gridDelims[0], gridDelims[1]), end: env.end };
783
+ }
784
+ if (!DISPLAY_ROW_ENVIRONMENTS[base]) {
785
+ return { box: textBox(latexToUnicode(ctx.wrap(src.slice(start, env.end)))), end: env.end };
786
+ }
787
+ let bodyStart = env.bodyStart;
788
+ if (base === "alignat" || base === "alignedat" || base === "gatheredat") {
789
+ // These carry a required column-count argument `{n}` before the body.
790
+ let p = bodyStart;
791
+ while (src[p] === " " || src[p] === "\n") p++;
792
+ if (src[p] === "{") bodyStart = readBraceGroup(src, p).end;
793
+ }
794
+ const rows = splitRows(src.slice(bodyStart, env.bodyEnd))
795
+ .map(row => row.trim())
796
+ .filter(row => row !== "");
797
+ if (rows.length === 0) return { box: textBox(""), end: env.end };
798
+ const cellRows = rows.map(splitCells);
799
+ let ncols = 0;
800
+ for (const row of cellRows) ncols = Math.max(ncols, row.length);
801
+ if (ncols <= 1) {
802
+ const centered = base === "gather" || base === "gathered" || base === "multline";
803
+ return {
804
+ box: vconcat(
805
+ rows.map(row => parseExpr(row, ctx)),
806
+ centered ? "c" : "l",
807
+ ),
808
+ end: env.end,
809
+ };
810
+ }
811
+ // `align`-family semantics: columns alternate right/left in `rl` pairs, a
812
+ // thin gap inside each pair and a wide gap between pairs.
813
+ const grid = gridBox(
814
+ cellRows.map(row => row.map(cell => parseExpr(cell, ctx))),
815
+ col => (col % 2 === 0 ? "r" : "l"),
816
+ col => (col % 2 === 1 ? 1 : 3),
817
+ );
818
+ return { box: grid, end: env.end };
819
+ }
820
+
821
+ /**
822
+ * Paint every line of `box` through a `latexColorScope` painter so structural
823
+ * glyphs (fraction bars, stretched delimiters, matrix brackets) inherit the
824
+ * enclosing color scope while nested color runs still restore to it.
825
+ */
826
+ function colorizeBox(box: Box, scope: (text: string) => string): Box {
827
+ return { lines: box.lines.map(scope), baseline: box.baseline, width: box.width };
828
+ }
829
+
322
830
  /**
323
- * Parse a math fragment into a layout box, stacking top-level fractions (and
324
- * fractions nested inside other fractions' arguments). Non-fraction runs
325
- * including scripts, roots, environments, and command arguments are gathered
326
- * into inline strings and rendered through `latexToUnicode`.
831
+ * Parse a math fragment into a layout box. 2-D constructs — fractions, binomials,
832
+ * radicals over tall content, `\left…\right` and tall bare parens, environments,
833
+ * big-operator limits, block scripts become stacked boxes; everything between
834
+ * them is gathered into inline runs rendered through `latexToUnicode` under the
835
+ * active scope wrapper (`ctx`), with `\color` state re-applied per run.
327
836
  */
328
- function parseExpr(src: string): Box {
837
+ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
329
838
  const boxes: Box[] = [];
330
839
  let inline = "";
840
+ let color = "";
841
+ let colorScope: ((text: string) => string) | null = null;
331
842
  const flush = (): void => {
332
- if (inline) {
333
- boxes.push(textBox(latexToUnicode(inline)));
334
- inline = "";
335
- }
843
+ if (!inline) return;
844
+ boxes.push(textBox(latexToUnicode(ctx.wrap(color + inline))));
845
+ inline = "";
846
+ };
847
+ /** Child context carrying the enclosing wrapper plus current color state. */
848
+ const inner = (): Ctx => {
849
+ if (!color) return ctx;
850
+ const pre = color;
851
+ return { wrap: run => ctx.wrap(pre + run) };
336
852
  };
853
+ /** Apply the active `\color` scope to a structural box's glyphs. */
854
+ const paint = (box: Box): Box => (colorScope === null ? box : colorizeBox(box, colorScope));
337
855
  let i = 0;
338
856
  while (i < src.length) {
339
857
  const c = src[i];
@@ -348,19 +866,204 @@ function parseExpr(src: string): Box {
348
866
  flush();
349
867
  const num = readArg(src, j);
350
868
  const den = readArg(src, num.end);
351
- boxes.push(fracBox(parseExpr(num.text), parseExpr(den.text)));
869
+ boxes.push(paint(fracBox(parseExpr(num.text, inner()), parseExpr(den.text, inner()))));
352
870
  i = den.end;
353
871
  continue;
354
872
  }
873
+ if (name && BINOM_COMMANDS[name]) {
874
+ flush();
875
+ const top = readArg(src, j);
876
+ const bottom = readArg(src, top.end);
877
+ boxes.push(paint(binomBox(parseExpr(top.text, inner()), parseExpr(bottom.text, inner()))));
878
+ i = bottom.end;
879
+ continue;
880
+ }
881
+ if (name === "sqrt") {
882
+ let k = j;
883
+ while (src[k] === " ") k++;
884
+ let degree: string | null = null;
885
+ if (src[k] === "[") {
886
+ const close = src.indexOf("]", k);
887
+ degree = src.slice(k + 1, close === -1 ? src.length : close);
888
+ k = close === -1 ? src.length : close + 1;
889
+ }
890
+ const arg = readArg(src, k);
891
+ // Display style always draws the roof (like LaTeX); inline math
892
+ // keeps the flat `√(…)` form via latexToUnicode.
893
+ flush();
894
+ boxes.push(paint(radicalBox(parseExpr(arg.text, inner()), degree)));
895
+ i = arg.end;
896
+ continue;
897
+ }
898
+ if (name === "left") {
899
+ const lr = readLeftRight(src, i);
900
+ if (lr) {
901
+ const segBoxes = lr.segments.map(segment => parseExpr(segment, inner()));
902
+ let above = 0;
903
+ let below = 0;
904
+ for (const b of segBoxes) {
905
+ above = Math.max(above, b.baseline);
906
+ below = Math.max(below, b.lines.length - 1 - b.baseline);
907
+ }
908
+ const height = above + below + 1;
909
+ if (height === 1) {
910
+ // Single-line: keep the whole span inline so converter
911
+ // state (fonts, colors, spacing) is preserved.
912
+ inline += src.slice(i, lr.end);
913
+ i = lr.end;
914
+ continue;
915
+ }
916
+ flush();
917
+ const parts: Box[] = [];
918
+ const push = (col: Box | null): void => {
919
+ if (col) parts.push(col);
920
+ };
921
+ push(delimColumn(delimKey(lr.left), height, above));
922
+ segBoxes.forEach((segment, s) => {
923
+ parts.push(segment);
924
+ if (s < lr.middles.length) push(delimColumn(delimKey(lr.middles[s]), height, above));
925
+ });
926
+ push(delimColumn(delimKey(lr.right), height, above));
927
+ boxes.push(paint(hconcat(parts)));
928
+ i = lr.end;
929
+ continue;
930
+ }
931
+ }
932
+ if (name && (LIMIT_OPERATORS[name] || INTEGRAL_OPERATORS[name])) {
933
+ let k = j;
934
+ while (src[k] === " ") k++;
935
+ let stack = LIMIT_OPERATORS[name] === true;
936
+ let resume = j; // resume point when the operator stays inline
937
+ if (src.startsWith("\\limits", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) {
938
+ stack = true;
939
+ resume = k = k + 7;
940
+ } else if (src.startsWith("\\nolimits", k) && !/[A-Za-z]/.test(src[k + 9] ?? "")) {
941
+ stack = false;
942
+ resume = k + 9;
943
+ }
944
+ if (stack) {
945
+ let subText: string | null = null;
946
+ let supText: string | null = null;
947
+ let m = k;
948
+ for (;;) {
949
+ // Peek past spaces without consuming them, so a run
950
+ // following the operator keeps its leading space.
951
+ let n = m;
952
+ while (src[n] === " ") n++;
953
+ if (src[n] === "_" && subText === null) {
954
+ const arg = readArg(src, n + 1);
955
+ subText = arg.text;
956
+ m = arg.end;
957
+ continue;
958
+ }
959
+ if (src[n] === "^" && supText === null) {
960
+ const arg = readArg(src, n + 1);
961
+ supText = arg.text;
962
+ m = arg.end;
963
+ continue;
964
+ }
965
+ break;
966
+ }
967
+ if (subText !== null || supText !== null) {
968
+ flush();
969
+ const glyph = textBox(latexToUnicode(ctx.wrap(`${color}\\${name}`)));
970
+ boxes.push(
971
+ paint(
972
+ limitsBox(
973
+ glyph,
974
+ subText === null ? null : parseExpr(subText, inner()),
975
+ supText === null ? null : parseExpr(supText, inner()),
976
+ ),
977
+ ),
978
+ );
979
+ i = m;
980
+ continue;
981
+ }
982
+ }
983
+ inline += `\\${name}`;
984
+ i = resume;
985
+ continue;
986
+ }
987
+ if (name === "color" || name === "normalcolor") {
988
+ flush(); // preceding run keeps the previous color
989
+ if (name === "normalcolor") {
990
+ color = "";
991
+ colorScope = null;
992
+ i = j;
993
+ continue;
994
+ }
995
+ let k = j;
996
+ while (src[k] === " ") k++;
997
+ let opt = "";
998
+ if (src[k] === "[") {
999
+ const close = src.indexOf("]", k);
1000
+ if (close !== -1) {
1001
+ opt = src.slice(k, close + 1);
1002
+ k = close + 1;
1003
+ while (src[k] === " ") k++;
1004
+ }
1005
+ }
1006
+ if (src[k] === "{") {
1007
+ const spec = readBraceGroup(src, k);
1008
+ color = `\\color${opt}{${spec.text}}`;
1009
+ colorScope = latexColorScope(opt ? opt.slice(1, -1).trim() : null, spec.text);
1010
+ i = spec.end;
1011
+ } else {
1012
+ color = "";
1013
+ colorScope = null;
1014
+ i = k;
1015
+ }
1016
+ continue;
1017
+ }
355
1018
  if (name === "begin") {
356
- const env = parseEnvironment(src, i);
1019
+ const env = parseEnvironment(src, i, inner());
357
1020
  if (env) {
358
1021
  flush();
359
- boxes.push(env.box);
1022
+ boxes.push(paint(env.box));
360
1023
  i = env.end;
361
1024
  continue;
362
1025
  }
363
1026
  }
1027
+ if (name && (MATH_FONT_COMMANDS.has(name) || name === "textcolor")) {
1028
+ // Scoped wrapper around 2-D content: recurse with the wrapper
1029
+ // re-applied to every inline run, so styling crosses boxes.
1030
+ let k = j;
1031
+ while (src[k] === " ") k++;
1032
+ let prefix = `\\${name}`;
1033
+ let scope: ((text: string) => string) | null = null;
1034
+ if (name === "textcolor") {
1035
+ let model: string | null = null;
1036
+ if (src[k] === "[") {
1037
+ const close = src.indexOf("]", k);
1038
+ if (close !== -1) {
1039
+ model = src.slice(k + 1, close).trim();
1040
+ prefix += src.slice(k, close + 1);
1041
+ k = close + 1;
1042
+ while (src[k] === " ") k++;
1043
+ }
1044
+ }
1045
+ if (src[k] !== "{") {
1046
+ inline += `\\${name}`;
1047
+ i = j;
1048
+ continue;
1049
+ }
1050
+ const spec = readBraceGroup(src, k);
1051
+ prefix += `{${spec.text}}`;
1052
+ scope = latexColorScope(model, spec.text);
1053
+ k = spec.end;
1054
+ while (src[k] === " ") k++;
1055
+ }
1056
+ if (src[k] === "{") {
1057
+ const content = readBraceGroup(src, k);
1058
+ flush();
1059
+ const pre = color;
1060
+ let box = parseExpr(content.text, { wrap: run => ctx.wrap(`${pre}${prefix}{${run}}`) });
1061
+ if (scope !== null) box = colorizeBox(box, scope);
1062
+ boxes.push(paint(box));
1063
+ i = content.end;
1064
+ continue;
1065
+ }
1066
+ }
364
1067
  if (!name) {
365
1068
  // Non-letter command (`\\`, `\,`, `\{`, …): keep the 2-char token inline.
366
1069
  inline += `\\${src[j] ?? ""}`;
@@ -386,18 +1089,72 @@ function parseExpr(src: string): Box {
386
1089
  continue;
387
1090
  }
388
1091
  if (c === "^" || c === "_") {
389
- const script = readScript(src, i);
390
- inline += script.text;
391
- i = script.end;
1092
+ const first = readScript(src, i);
1093
+ // Consume an immediately following opposite script (`M_i^j`) so both
1094
+ // land in one shared column instead of two successive ones.
1095
+ let second: Span | null = null;
1096
+ let n = first.end;
1097
+ while (src[n] === " ") n++;
1098
+ if (src[n] === (c === "^" ? "_" : "^")) second = readScript(src, n);
1099
+ const end = second === null ? first.end : second.end;
1100
+ const supText = c === "^" ? first.text : second?.text;
1101
+ const subText = c === "_" ? first.text : second?.text;
1102
+ const supBox = supText === undefined ? null : parseExpr(scriptArgOf(supText), inner());
1103
+ const subBox = subText === undefined ? null : parseExpr(scriptArgOf(subText), inner());
1104
+ // The converter falls back to `^(…)`/`_(…)` when any character lacks a
1105
+ // Unicode script form; those scripts get real raised/lowered boxes.
1106
+ const unconvertible = (raw: string | undefined): boolean => {
1107
+ if (raw === undefined) return false;
1108
+ const flat = latexToUnicode(raw);
1109
+ return flat.startsWith("^") || flat.startsWith("_");
1110
+ };
1111
+ const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1);
1112
+ if (tall || unconvertible(supText) || unconvertible(subText)) {
1113
+ // Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes
1114
+ // against the run or box they follow.
1115
+ flush();
1116
+ const base = boxes.pop() ?? textBox("");
1117
+ boxes.push(paint(attachScripts(base, subBox, supBox)));
1118
+ i = end;
1119
+ continue;
1120
+ }
1121
+ const last = boxes[boxes.length - 1];
1122
+ if (inline === "" && last !== undefined && last.lines.length > 1) {
1123
+ // Scripts directly on a tall box (`M^T`, `\right|_{x=a}`): pin
1124
+ // the Unicode script glyphs (guaranteed convertible here after
1125
+ // the gate above) to its corners.
1126
+ const corner = (raw: string | undefined): Box | null =>
1127
+ raw === undefined ? null : textBox(latexToUnicode(ctx.wrap(color + raw)));
1128
+ boxes[boxes.length - 1] = paint(attachScripts(last, corner(subText), corner(supText)));
1129
+ i = end;
1130
+ continue;
1131
+ }
1132
+ inline += src.slice(i, end);
1133
+ i = end;
392
1134
  continue;
393
1135
  }
394
1136
  if (c === "{") {
395
1137
  const group = readBraceGroup(src, i);
396
1138
  flush();
397
- boxes.push(parseExpr(group.text));
1139
+ boxes.push(paint(parseExpr(group.text, inner())));
398
1140
  i = group.end;
399
1141
  continue;
400
1142
  }
1143
+ if (c === "(" || c === "[") {
1144
+ // Bare delimiters stretch when their content is tall (common in
1145
+ // model output that omits `\left`/`\right`).
1146
+ const closeCh = c === "(" ? ")" : "]";
1147
+ const close = matchDelim(src, i, c, closeCh);
1148
+ if (close !== -1) {
1149
+ const innerBox = parseExpr(src.slice(i + 1, close), inner());
1150
+ if (innerBox.lines.length > 1) {
1151
+ flush();
1152
+ boxes.push(paint(delimBox(innerBox, c, closeCh)));
1153
+ i = close + 1;
1154
+ continue;
1155
+ }
1156
+ }
1157
+ }
401
1158
  inline += c;
402
1159
  i++;
403
1160
  }
@@ -406,7 +1163,7 @@ function parseExpr(src: string): Box {
406
1163
  return hconcat(boxes);
407
1164
  }
408
1165
 
409
- /** Split on top-level `\n` row separators (outside braces and environments). */
1166
+ /** Split on top-level `\n` and `\\` row separators (outside braces and environments). */
410
1167
  function splitLines(src: string): string[] {
411
1168
  const lines: string[] = [];
412
1169
  let braceDepth = 0;
@@ -426,7 +1183,18 @@ function splitLines(src: string): string[] {
426
1183
  }
427
1184
  const c = src[i];
428
1185
  if (c === "\\") {
429
- i += 2; // escaped char / second backslash never a logical-line break
1186
+ if (src[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) {
1187
+ lines.push(src.slice(last, i));
1188
+ i += 2;
1189
+ while (src[i] === " ") i++;
1190
+ if (src[i] === "[") {
1191
+ const close = src.indexOf("]", i);
1192
+ i = close === -1 ? src.length : close + 1;
1193
+ }
1194
+ last = i;
1195
+ continue;
1196
+ }
1197
+ i += 2; // escaped char — never a logical-line break
430
1198
  continue;
431
1199
  }
432
1200
  if (c === "{") braceDepth++;
@@ -442,10 +1210,11 @@ function splitLines(src: string): string[] {
442
1210
  }
443
1211
 
444
1212
  /**
445
- * Render a display LaTeX math fragment to lines, stacking `\frac` vertically.
446
- * Top-level source newlines become vertical rows (so a `lhs =` line stays above
447
- * its block); each row stacks fractions via `parseExpr`. Inline math should use
448
- * `latexToUnicode` instead fractions there stay single-line.
1213
+ * Render a display LaTeX math fragment to lines with full 2-D layout: stacked
1214
+ * fractions, stretchy delimiters, matrix grids, operator limits, drawn
1215
+ * radicals. Top-level source newlines and `\\` become vertical rows (so a
1216
+ * `lhs =` line stays above its block). Inline math should use `latexToUnicode`
1217
+ * instead — fractions there stay single-line.
449
1218
  */
450
1219
  export function latexToBlock(src: string): string[] {
451
1220
  if (typeof src !== "string" || src.trim() === "") return [];
@@ -317,6 +317,13 @@ const FONTS: Record<string, FontStyle> = {
317
317
  texttt: "mono",
318
318
  textsf: "sans",
319
319
  };
320
+ /**
321
+ * Math font command names (`\mathbf`, `\mathbb`, …) whose single brace argument
322
+ * restyles glyphs. Exported for the display block engine (`latex-block`), which
323
+ * re-wraps inline runs inside these commands when their argument contains 2-D
324
+ * layout (fractions, matrices) so styling survives box boundaries.
325
+ */
326
+ export const MATH_FONT_COMMANDS: ReadonlySet<string> = new Set(Object.keys(FONTS));
320
327
 
321
328
  // Text-mode commands whose argument is passed through literally (no math).
322
329
  const TEXT_COMMANDS: Record<string, true> = {
@@ -1136,6 +1143,22 @@ function ansiColor(model: string | null, spec: string): AnsiColor | null {
1136
1143
  return { foreground, background: foreground.replace("\x1b[38;", "\x1b[48;") };
1137
1144
  }
1138
1145
 
1146
+ /**
1147
+ * Painter for a LaTeX color scope (optional model + spec, e.g. `rgb`/`1,0,0` or
1148
+ * `red`): returns a function that paints already-rendered text with the scope's
1149
+ * foreground, re-asserting it after embedded foreground resets so nested color
1150
+ * runs restore to the scope color; null when the color cannot be resolved. Used
1151
+ * by the display block engine (`latex-block`) to paint structural glyphs
1152
+ * (fraction bars, stretched delimiters, matrix brackets) inside
1153
+ * `\color`/`\textcolor` scopes.
1154
+ */
1155
+ export function latexColorScope(model: string | null, spec: string): ((text: string) => string) | null {
1156
+ const color = ansiColor(model, spec);
1157
+ if (color === null) return null;
1158
+ const { foreground } = color;
1159
+ return text => foreground + text.replaceAll(ANSI_FG_RESET, foreground) + ANSI_FG_RESET;
1160
+ }
1161
+
1139
1162
  function restoreAnsi(
1140
1163
  text: string,
1141
1164
  fromForeground: string | null,
package/src/tui.ts CHANGED
@@ -561,10 +561,9 @@ export class Container implements Component {
561
561
  * method owns the bytes written and the state update.
562
562
  *
563
563
  * - `fullPaint`: gesture-driven replay — initial paint, session replacement,
564
- * resize, resetDisplay. Clears the viewport and (for destructive replaces,
565
- * outside multiplexers) native scrollback via ED3, then writes the
566
- * committed prefix and the visible window. The only ED3 callsite in the
567
- * engine.
564
+ * resize, resetDisplay. Rewrites the frame from home; destructive replaces
565
+ * clear native scrollback via ED3 without first blanking the viewport. The
566
+ * only ED3 callsite in the engine.
568
567
  * - `update`: ordinary frame. Commits the newly settled chunk at the
569
568
  * scrollback seam (if any) and repaints the window with relative moves.
570
569
  */
@@ -3163,7 +3162,7 @@ export class TUI extends Container {
3163
3162
  }
3164
3163
 
3165
3164
  /**
3166
- * Clear the viewport (optionally native scrollback) and replay the frame:
3165
+ * Replay the frame from home, optionally clearing native scrollback first:
3167
3166
  * committed prefix `[0, chunkTo)` followed by the visible window. ED3
3168
3167
  * (`CSI 3 J`) is emitted here and only here, and only for gesture-driven
3169
3168
  * paints (session replace, resize, resetDisplay, or an explicit
@@ -3221,7 +3220,10 @@ export class TUI extends Container {
3221
3220
  }
3222
3221
  let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
3223
3222
  if (options.clearScrollback) {
3224
- buffer += "\x1b[2J\x1b[H\x1b[3J";
3223
+ // Clear native history without blanking the live viewport first. The
3224
+ // replay below rewrites every visible row from home, including blanks,
3225
+ // so terminals without DEC 2026 never expose an ED2-cleared frame.
3226
+ buffer += "\x1b[H\x1b[3J";
3225
3227
  } else {
3226
3228
  // Best-effort: push the pre-paint screen into scrollback on
3227
3229
  // terminals that implement kitty's ED 22
@@ -3254,21 +3256,24 @@ export class TUI extends Container {
3254
3256
  if (paintLines === null) {
3255
3257
  // Common path: emit straight from the source arrays (the
3256
3258
  // pre-merge two-loop form); byte-identical to replaying the
3257
- // merged array.
3259
+ // merged array. Destructive history clears deliberately avoid ED2, so
3260
+ // each row must self-clear stale cells left by the previous viewport.
3258
3261
  for (let i = 0; i < chunkTo; i++) {
3259
3262
  if (i > 0) buffer += "\r\n";
3260
- buffer += this.#terminalLine(frame[i] ?? "");
3263
+ buffer += options.clearScrollback
3264
+ ? this.#lineRewriteSequence(frame[i] ?? "", width)
3265
+ : this.#terminalLine(frame[i] ?? "");
3261
3266
  }
3262
3267
  for (let screenRow = 0; screenRow < height; screenRow++) {
3263
3268
  if (chunkTo + screenRow > 0) buffer += "\r\n";
3264
- buffer += this.#terminalLine(visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? ""));
3269
+ const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
3270
+ buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
3265
3271
  }
3266
3272
  } else {
3267
3273
  for (let i = 0; i < paintLines.length; i++) {
3268
3274
  if (i > 0) buffer += "\r\n";
3269
- buffer += this.#terminalLine(
3270
- visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""),
3271
- );
3275
+ const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
3276
+ buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
3272
3277
  }
3273
3278
  }
3274
3279
  buffer += fillSequence;