@linxiraos/pi-tui 1.0.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,1338 @@
1
+ // Two-dimensional layout engine for *display* LaTeX math.
2
+ //
3
+ // ┌───────── n ⎛ a+b ⎞²
4
+ // −b ± ╲│ b² − 4ac ∑ xᵢ ⎜ ───── ⎟ ⎡ 1 2 ⎤
5
+ // x = ────────────────── i=0 ⎝ c ⎠ ⎣ 3 4 ⎦
6
+ // 2a
7
+ //
8
+ // Only display blocks (`$$…$$`, `\[…\]`) use this; inline `$…$` stays single-line
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, draw labeled
15
+ // horizontal braces (`\underbrace{x}_{lbl}`), stack `\overset`/`\underset`, and
16
+ // align `&` columns in `align`-family environments. Flat runs — symbols, fonts,
17
+ // colors, inline scripts — are delegated to `latexToUnicode`.
18
+ //
19
+ // The 2-D layout approach (stretchy delimiter piecing, stacked operator limits,
20
+ // baseline-aligned matrix grids, drawn radicals, block scripts) is modeled on
21
+ // txm — Terminal TeX Math — by @thatmagicalcat
22
+ // (https://github.com/thatmagicalcat/txm, MIT/Apache-2.0), reimplemented from
23
+ // scratch here on this module's ANSI-aware Box model.
24
+
25
+ import { latexColorScope, latexToUnicode, MATH_FONT_COMMANDS } from "./latex-to-unicode";
26
+ import { visibleWidth } from "./utils";
27
+
28
+ /**
29
+ * A rectangular block of rendered text. Every entry in `lines` is padded to
30
+ * exactly `width` visible columns; `baseline` is the row that aligns with the
31
+ * surrounding text when boxes are placed side by side (e.g. the fraction bar).
32
+ */
33
+ interface Box {
34
+ lines: string[];
35
+ baseline: number;
36
+ width: number;
37
+ }
38
+
39
+ type CellAlign = "l" | "c" | "r";
40
+
41
+ const BAR = "─";
42
+ const FRAC_COMMANDS: Record<string, true> = { frac: true, dfrac: true, tfrac: true, cfrac: true };
43
+ const BINOM_COMMANDS: Record<string, true> = { binom: true, dbinom: true, tbinom: true };
44
+
45
+ // Display "wrapper" environments whose body is an expression (possibly with `\\`
46
+ // row breaks and `&` alignment). Their rows are parsed so fractions inside stack
47
+ // and `&` columns align.
48
+ const DISPLAY_ROW_ENVIRONMENTS: Record<string, true> = {
49
+ equation: true,
50
+ eqnarray: true,
51
+ align: true,
52
+ aligned: true,
53
+ alignat: true,
54
+ alignedat: true,
55
+ flalign: true,
56
+ split: true,
57
+ gather: true,
58
+ gathered: true,
59
+ gatheredat: true,
60
+ multline: true,
61
+ displaymath: true,
62
+ math: true,
63
+ };
64
+
65
+ // Environments laid out as 2-D grids of parsed cells: [open, close] delimiter.
66
+ const GRID_ENVIRONMENTS: Record<string, readonly [string, string]> = {
67
+ matrix: ["", ""],
68
+ smallmatrix: ["", ""],
69
+ array: ["", ""],
70
+ pmatrix: ["(", ")"],
71
+ bmatrix: ["[", "]"],
72
+ Bmatrix: ["{", "}"],
73
+ vmatrix: ["|", "|"],
74
+ Vmatrix: ["‖", "‖"],
75
+ cases: ["{", ""],
76
+ dcases: ["{", ""],
77
+ rcases: ["", "}"],
78
+ drcases: ["", "}"],
79
+ };
80
+
81
+ // Operators whose display-style scripts stack above/below the symbol.
82
+ const LIMIT_OPERATORS: Record<string, true> = {
83
+ sum: true,
84
+ prod: true,
85
+ coprod: true,
86
+ bigcup: true,
87
+ bigcap: true,
88
+ bigsqcup: true,
89
+ bigvee: true,
90
+ bigwedge: true,
91
+ bigoplus: true,
92
+ bigotimes: true,
93
+ bigodot: true,
94
+ biguplus: true,
95
+ lim: true,
96
+ limsup: true,
97
+ liminf: true,
98
+ projlim: true,
99
+ injlim: true,
100
+ varlimsup: true,
101
+ varliminf: true,
102
+ varprojlim: true,
103
+ varinjlim: true,
104
+ max: true,
105
+ min: true,
106
+ sup: true,
107
+ inf: true,
108
+ det: true,
109
+ gcd: true,
110
+ Pr: true,
111
+ argmax: true,
112
+ argmin: true,
113
+ };
114
+
115
+ // Integral-family operators: scripts stay beside the symbol (LaTeX display
116
+ // convention) unless an explicit `\limits` follows.
117
+ const INTEGRAL_OPERATORS: Record<string, true> = {
118
+ int: true,
119
+ iint: true,
120
+ iiint: true,
121
+ iiiint: true,
122
+ oint: true,
123
+ oiint: true,
124
+ oiiint: true,
125
+ idotsint: true,
126
+ intop: true,
127
+ smallint: true,
128
+ };
129
+
130
+ // Horizontal brace/bracket decorations drawn as a rule row beside the content,
131
+ // with an optional limits-style label beyond the rule (`\underbrace{x}_{lbl}`).
132
+ interface HBraceSpec {
133
+ left: string;
134
+ mid: string;
135
+ center: string;
136
+ right: string;
137
+ over: boolean;
138
+ }
139
+
140
+ const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
141
+ overbrace: { left: "╭", mid: "─", center: "┴", right: "╮", over: true },
142
+ underbrace: { left: "╰", mid: "─", center: "┬", right: "╯", over: false },
143
+ overbracket: { left: "┌", mid: "─", center: "─", right: "┐", over: true },
144
+ underbracket: { left: "└", mid: "─", center: "─", right: "┘", over: false },
145
+ overparen: { left: "╭", mid: "─", center: "─", right: "╮", over: true },
146
+ underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
147
+ };
148
+
149
+ // Vertical delimiter piece characters: `only` for single-line content, then
150
+ // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
151
+ // baseline row (the brace point).
152
+ interface DelimPieces {
153
+ only: string;
154
+ top: string;
155
+ mid: string;
156
+ bot: string;
157
+ axis?: string;
158
+ }
159
+
160
+ const DELIM_PIECES: Record<string, DelimPieces> = {
161
+ "(": { only: "(", top: "⎛", mid: "⎜", bot: "⎝" },
162
+ ")": { only: ")", top: "⎞", mid: "⎟", bot: "⎠" },
163
+ "[": { only: "[", top: "⎡", mid: "⎢", bot: "⎣" },
164
+ "]": { only: "]", top: "⎤", mid: "⎥", bot: "⎦" },
165
+ "{": { only: "{", top: "⎧", mid: "⎪", bot: "⎩", axis: "⎨" },
166
+ "}": { only: "}", top: "⎫", mid: "⎪", bot: "⎭", axis: "⎬" },
167
+ "|": { only: "|", top: "│", mid: "│", bot: "│" },
168
+ "‖": { only: "‖", top: "║", mid: "║", bot: "║" },
169
+ "⌈": { only: "⌈", top: "⎡", mid: "⎢", bot: "⎢" },
170
+ "⌉": { only: "⌉", top: "⎤", mid: "⎥", bot: "⎥" },
171
+ "⌊": { only: "⌊", top: "⎢", mid: "⎢", bot: "⎣" },
172
+ "⌋": { only: "⌋", top: "⎥", mid: "⎥", bot: "⎦" },
173
+ };
174
+
175
+ // `\left`/`\right`/`\middle` delimiter token → piece-table key. Unknown tokens
176
+ // fall back to `latexToUnicode` and render at the baseline row only.
177
+ const DELIM_KEYS: Record<string, string> = {
178
+ "(": "(",
179
+ ")": ")",
180
+ "[": "[",
181
+ "]": "]",
182
+ "\\{": "{",
183
+ "\\}": "}",
184
+ "\\lbrace": "{",
185
+ "\\rbrace": "}",
186
+ "|": "|",
187
+ "\\vert": "|",
188
+ "\\lvert": "|",
189
+ "\\rvert": "|",
190
+ "\\|": "‖",
191
+ "\\Vert": "‖",
192
+ "\\lVert": "‖",
193
+ "\\rVert": "‖",
194
+ "\\langle": "⟨",
195
+ "\\rangle": "⟩",
196
+ "<": "⟨",
197
+ ">": "⟩",
198
+ "\\lceil": "⌈",
199
+ "\\rceil": "⌉",
200
+ "\\lfloor": "⌊",
201
+ "\\rfloor": "⌋",
202
+ "\\lbrack": "[",
203
+ "\\rbrack": "]",
204
+ ".": "",
205
+ };
206
+
207
+ /**
208
+ * Inline-run conversion context. `wrap` re-applies the scoped commands (math
209
+ * fonts, colors) active at this point in the parse, so each flat run handed to
210
+ * `latexToUnicode` renders with the same styling it would have had in one piece.
211
+ */
212
+ interface Ctx {
213
+ wrap: (run: string) => string;
214
+ }
215
+
216
+ const ROOT_CTX: Ctx = { wrap: run => run };
217
+
218
+ function spaces(n: number): string {
219
+ return n > 0 ? " ".repeat(n) : "";
220
+ }
221
+
222
+ /** Pad `line` on the right to `width` visible columns. */
223
+ function padRight(line: string, width: number): string {
224
+ return line + spaces(width - visibleWidth(line));
225
+ }
226
+
227
+ /** Pad `line` symmetrically (left-biased) to `width` visible columns. */
228
+ function center(line: string, width: number): string {
229
+ const extra = width - visibleWidth(line);
230
+ if (extra <= 0) return line;
231
+ const left = extra >> 1;
232
+ return spaces(left) + line + spaces(extra - left);
233
+ }
234
+
235
+ /** A single rendered string (possibly multi-line) as a baseline-centered box. */
236
+ function textBox(text: string): Box {
237
+ const raw = text.split("\n");
238
+ let width = 0;
239
+ for (const line of raw) width = Math.max(width, visibleWidth(line));
240
+ return { lines: raw.map(line => padRight(line, width)), baseline: (raw.length - 1) >> 1, width };
241
+ }
242
+
243
+ /** Pad every line of `b` to `width` per `align`, keeping the baseline. */
244
+ function padBox(b: Box, width: number, align: CellAlign): Box {
245
+ if (b.width >= width) return b;
246
+ const lines = b.lines.map(line => {
247
+ const extra = width - visibleWidth(line);
248
+ if (align === "l") return line + spaces(extra);
249
+ if (align === "r") return spaces(extra) + line;
250
+ const left = extra >> 1;
251
+ return spaces(left) + line + spaces(extra - left);
252
+ });
253
+ return { lines, baseline: b.baseline, width };
254
+ }
255
+
256
+ /** Place boxes side by side, aligning their baselines. */
257
+ function hconcat(boxes: Box[]): Box {
258
+ if (boxes.length === 1) return boxes[0];
259
+ let above = 0;
260
+ let below = 0;
261
+ for (const b of boxes) {
262
+ above = Math.max(above, b.baseline);
263
+ below = Math.max(below, b.lines.length - 1 - b.baseline);
264
+ }
265
+ const height = above + below + 1;
266
+ const lines: string[] = [];
267
+ let width = 0;
268
+ for (const b of boxes) width += b.width;
269
+ for (let row = 0; row < height; row++) {
270
+ let line = "";
271
+ for (const b of boxes) {
272
+ const local = row - (above - b.baseline);
273
+ line += local >= 0 && local < b.lines.length ? b.lines[local] : spaces(b.width);
274
+ }
275
+ lines.push(line);
276
+ }
277
+ return { lines, baseline: above, width };
278
+ }
279
+
280
+ /** Stack boxes vertically, e.g. the rows of an aligned block. */
281
+ function vconcat(boxes: Box[], align: CellAlign = "l"): Box {
282
+ if (boxes.length === 1) return boxes[0];
283
+ let width = 0;
284
+ for (const b of boxes) width = Math.max(width, b.width);
285
+ const lines: string[] = [];
286
+ for (const b of boxes) {
287
+ for (const line of b.lines) lines.push(align === "c" ? center(line, width) : padRight(line, width));
288
+ }
289
+ return { lines, baseline: (lines.length - 1) >> 1, width };
290
+ }
291
+
292
+ /** Stack `num` over `den`, separated by a bar; the bar becomes the baseline. */
293
+ function fracBox(num: Box, den: Box): Box {
294
+ const width = Math.max(num.width, den.width) + 2;
295
+ const lines = [
296
+ ...num.lines.map(line => center(line, width)),
297
+ BAR.repeat(width),
298
+ ...den.lines.map(line => center(line, width)),
299
+ ];
300
+ return { lines, baseline: num.lines.length, width };
301
+ }
302
+
303
+ /**
304
+ * One vertical delimiter column of `height` rows for piece-table key `key`
305
+ * (`"("`, `"{"`, …); null when `key` is empty (`\left.`). Unknown keys render a
306
+ * single glyph at the baseline row.
307
+ */
308
+ function delimColumn(key: string, height: number, baseline: number): Box | null {
309
+ if (!key) return null;
310
+ const pieces = DELIM_PIECES[key];
311
+ if (height <= 1) {
312
+ const only = pieces?.only ?? key;
313
+ return only ? { lines: [only], baseline: 0, width: visibleWidth(only) } : null;
314
+ }
315
+ const width = visibleWidth(pieces?.only ?? key);
316
+ const blank = spaces(width);
317
+ const lines: string[] = [];
318
+ if (!pieces) {
319
+ for (let y = 0; y < height; y++) lines.push(y === baseline ? key : blank);
320
+ return { lines, baseline, width };
321
+ }
322
+ const axisRow = Math.min(Math.max(baseline, 1), height - 2);
323
+ for (let y = 0; y < height; y++) {
324
+ if (y === 0) lines.push(pieces.top);
325
+ else if (y === height - 1) lines.push(pieces.bot);
326
+ else if (y === axisRow && pieces.axis) lines.push(pieces.axis);
327
+ else lines.push(pieces.mid);
328
+ }
329
+ return { lines, baseline, width };
330
+ }
331
+
332
+ /** Wrap `inner` in (possibly stretched) delimiters, padding tall content. */
333
+ function delimBox(inner: Box, left: string, right: string): Box {
334
+ const height = inner.lines.length;
335
+ const lcol = delimColumn(left, height, inner.baseline);
336
+ const rcol = delimColumn(right, height, inner.baseline);
337
+ if (!lcol && !rcol) return inner;
338
+ const pad: Box | null = height > 1 ? textBox(" ") : null;
339
+ const parts: Box[] = [];
340
+ if (lcol) parts.push(lcol);
341
+ if (pad) parts.push(pad);
342
+ parts.push(inner);
343
+ if (pad) parts.push(pad);
344
+ if (rcol) parts.push(rcol);
345
+ return hconcat(parts);
346
+ }
347
+
348
+ /** `\binom{n}{k}`: `n` over `k` (no bar) inside stretched parentheses. */
349
+ function binomBox(top: Box, bottom: Box): Box {
350
+ const width = Math.max(top.width, bottom.width);
351
+ const lines = [
352
+ ...top.lines.map(line => center(line, width)),
353
+ spaces(width),
354
+ ...bottom.lines.map(line => center(line, width)),
355
+ ];
356
+ return delimBox({ lines, baseline: top.lines.length, width }, "(", ")");
357
+ }
358
+
359
+ /**
360
+ * A drawn radical for a multi-line radicand: overline row on top, bar column
361
+ * on the left, hook at the bottom. Single-line radicands stay flat (`√x̄`).
362
+ */
363
+ function radicalBox(inner: Box, degree: string | null): Box {
364
+ const lines: string[] = [` ┌${BAR.repeat(inner.width + 1)}`];
365
+ for (let y = 0; y < inner.lines.length; y++) {
366
+ lines.push((y === inner.lines.length - 1 ? "╲│ " : " │ ") + inner.lines[y]);
367
+ }
368
+ const box: Box = { lines, baseline: inner.baseline + 1, width: inner.width + 3 };
369
+ if (!degree) return box;
370
+ const deg = latexToUnicode(`^{${degree}}`);
371
+ // Degree sits one row above the baseline, at the radical's upper left.
372
+ return hconcat([{ lines: [deg, spaces(visibleWidth(deg))], baseline: 1, width: visibleWidth(deg) }, box]);
373
+ }
374
+
375
+ /** Big operator with limits: `sup` centered above `glyph`, `sub` below. */
376
+ function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box {
377
+ const width = Math.max(glyph.width, sub?.width ?? 0, sup?.width ?? 0);
378
+ const lines: string[] = [];
379
+ if (sup) for (const line of sup.lines) lines.push(center(line, width));
380
+ const baseline = lines.length + glyph.baseline;
381
+ for (const line of glyph.lines) lines.push(center(line, width));
382
+ if (sub) for (const line of sub.lines) lines.push(center(line, width));
383
+ return { lines, baseline, width };
384
+ }
385
+
386
+ /**
387
+ * `\underbrace{content}_{label}` / `\overbrace{content}^{label}`: the content
388
+ * with a drawn horizontal brace beside it and the label centered beyond the
389
+ * brace. The baseline stays on the content so neighbors align with it.
390
+ */
391
+ function hbraceBox(content: Box, spec: HBraceSpec, label: Box | null): Box {
392
+ const braceWidth = Math.max(content.width, 3);
393
+ const width = Math.max(braceWidth, label?.width ?? 0);
394
+ const lead = (braceWidth - 3) >> 1;
395
+ const brace = center(
396
+ spec.left + spec.mid.repeat(lead) + spec.center + spec.mid.repeat(braceWidth - 3 - lead) + spec.right,
397
+ width,
398
+ );
399
+ const contentLines = content.lines.map(line => center(line, width));
400
+ const labelLines = label === null ? [] : label.lines.map(line => center(line, width));
401
+ if (spec.over) {
402
+ return {
403
+ lines: [...labelLines, brace, ...contentLines],
404
+ baseline: labelLines.length + 1 + content.baseline,
405
+ width,
406
+ };
407
+ }
408
+ return { lines: [...contentLines, brace, ...labelLines], baseline: content.baseline, width };
409
+ }
410
+
411
+ /**
412
+ * Attach block scripts to `base` as one shared right-hand column: the
413
+ * superscript ends level with the base's top row (raised one row above a
414
+ * single-line base), the subscript starts level with its bottom row (lowered
415
+ * one row below a single-line base).
416
+ */
417
+ function attachScripts(base: Box, sub: Box | null, sup: Box | null): Box {
418
+ if (sub === null && sup === null) return base;
419
+ const single = base.lines.length === 1;
420
+ const width = Math.max(sub?.width ?? 0, sup?.width ?? 0);
421
+ const blank = spaces(width);
422
+ const lines: string[] = [];
423
+ let baseline = 0;
424
+ if (sup) {
425
+ const lift = single ? 1 : base.baseline;
426
+ for (const line of sup.lines) lines.push(padRight(line, width));
427
+ for (let k = 0; k < lift; k++) lines.push(blank);
428
+ baseline = lines.length - 1;
429
+ }
430
+ if (sub) {
431
+ const below = base.lines.length - 1 - base.baseline - (sub.lines.length - 1);
432
+ let drop = Math.max(below, single ? 1 : 0);
433
+ if (sup && drop < 1) drop = 1;
434
+ // Rows between the baseline row and the subscript's top row.
435
+ const gap = lines.length === 0 ? drop : drop - 1;
436
+ for (let k = 0; k < gap; k++) lines.push(blank);
437
+ for (const line of sub.lines) lines.push(padRight(line, width));
438
+ }
439
+ return hconcat([base, { lines, baseline, width }]);
440
+ }
441
+
442
+ /**
443
+ * Lay out parsed cells as a grid: per-column width/alignment, per-gap width.
444
+ * With `rowGap > 0` (matrix-family environments), blank rows separate the grid
445
+ * rows and the total height is forced odd, so the baseline sits at the true
446
+ * vertical center — `A = [matrix]` centers on the brackets, and stretched
447
+ * braces get a real middle piece even for two content rows.
448
+ */
449
+ function gridBox(rows: Box[][], align: (col: number) => CellAlign, gap: (col: number) => number, rowGap = 0): Box {
450
+ let ncols = 0;
451
+ for (const row of rows) ncols = Math.max(ncols, row.length);
452
+ if (ncols === 0 || rows.length === 0) return textBox("");
453
+ const widths = new Array<number>(ncols).fill(0);
454
+ for (const row of rows) {
455
+ row.forEach((cell, j) => {
456
+ widths[j] = Math.max(widths[j], cell.width);
457
+ });
458
+ }
459
+ const rowBoxes: Box[] = [];
460
+ for (const row of rows) {
461
+ if (rowGap > 0 && rowBoxes.length > 0) {
462
+ for (let g = 0; g < rowGap; g++) rowBoxes.push({ lines: [""], baseline: 0, width: 0 });
463
+ }
464
+ const parts: Box[] = [];
465
+ for (let j = 0; j < ncols; j++) {
466
+ if (j > 0) {
467
+ const g = gap(j);
468
+ if (g > 0) parts.push({ lines: [spaces(g)], baseline: 0, width: g });
469
+ }
470
+ parts.push(padBox(row[j] ?? { lines: [""], baseline: 0, width: 0 }, widths[j], align(j)));
471
+ }
472
+ rowBoxes.push(hconcat(parts));
473
+ }
474
+ const grid = vconcat(rowBoxes);
475
+ if (rowGap > 0 && rows.length > 1 && grid.lines.length % 2 === 0) {
476
+ return { lines: [...grid.lines, spaces(grid.width)], baseline: grid.lines.length >> 1, width: grid.width };
477
+ }
478
+ return grid;
479
+ }
480
+
481
+ interface Span {
482
+ text: string;
483
+ end: number;
484
+ }
485
+
486
+ /** Read a balanced `{…}` beginning at `i` (which must point at `{`). */
487
+ function readBraceGroup(src: string, i: number): Span {
488
+ let depth = 0;
489
+ let out = "";
490
+ let j = i;
491
+ for (; j < src.length; j++) {
492
+ const c = src[j];
493
+ if (c === "\\") {
494
+ out += c + (src[j + 1] ?? "");
495
+ j++;
496
+ continue;
497
+ }
498
+ if (c === "{") {
499
+ depth++;
500
+ if (depth > 1) out += c;
501
+ continue;
502
+ }
503
+ if (c === "}") {
504
+ depth--;
505
+ if (depth === 0) {
506
+ j++;
507
+ break;
508
+ }
509
+ out += c;
510
+ continue;
511
+ }
512
+ out += c;
513
+ }
514
+ return { text: out, end: j };
515
+ }
516
+
517
+ /**
518
+ * Read one command argument: a `{…}` group, a single char, or a `\command`
519
+ * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
520
+ * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
521
+ */
522
+ function readArg(src: string, i: number): Span {
523
+ while (src[i] === " ") i++;
524
+ if (i >= src.length) return { text: "", end: i };
525
+ if (src[i] === "{") return readBraceGroup(src, i);
526
+ if (src[i] !== "\\") return { text: src[i], end: i + 1 };
527
+ let j = i + 1;
528
+ let name = "";
529
+ while (/[A-Za-z]/.test(src[j] ?? "")) {
530
+ name += src[j];
531
+ j++;
532
+ }
533
+ if (name === "begin") {
534
+ const env = consumeEnvironment(src, i);
535
+ if (env) return env;
536
+ }
537
+ if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
538
+ let end = j;
539
+ while (src[end] === "[" || src[end] === "{") {
540
+ if (src[end] === "{") end = readBraceGroup(src, end).end;
541
+ else {
542
+ const close = src.indexOf("]", end);
543
+ end = close === -1 ? src.length : close + 1;
544
+ }
545
+ }
546
+ return { text: src.slice(i, end), end };
547
+ }
548
+
549
+ /** Read a `\left`/`\right`/`\middle` delimiter token (char or `\command`). */
550
+ function readDelimToken(src: string, i: number): Span | null {
551
+ while (src[i] === " ") i++;
552
+ if (i >= src.length) return null;
553
+ if (src[i] !== "\\") return { text: src[i], end: i + 1 };
554
+ let j = i + 1;
555
+ if (!/[A-Za-z]/.test(src[j] ?? "")) return { text: src.slice(i, j + 1), end: j + 1 };
556
+ while (/[A-Za-z]/.test(src[j] ?? "")) j++;
557
+ return { text: src.slice(i, j), end: j };
558
+ }
559
+
560
+ /** Piece-table key for a delimiter token; unknown commands resolve via Unicode. */
561
+ function delimKey(token: string): string {
562
+ const mapped = DELIM_KEYS[token];
563
+ if (mapped !== undefined) return mapped;
564
+ return token.startsWith("\\") ? latexToUnicode(token).trim() : token;
565
+ }
566
+
567
+ interface LeftRightParts {
568
+ left: string;
569
+ /** Inner source split at top-level `\middle` delimiters. */
570
+ segments: string[];
571
+ middles: string[];
572
+ right: string;
573
+ end: number;
574
+ }
575
+
576
+ /** Parse `\left⟨tok⟩ … \right⟨tok⟩` starting at the backslash of `\left`. */
577
+ function readLeftRight(src: string, start: number): LeftRightParts | null {
578
+ const left = readDelimToken(src, start + 5);
579
+ if (!left) return null;
580
+ const segments: string[] = [];
581
+ const middles: string[] = [];
582
+ let depth = 1;
583
+ let k = left.end;
584
+ let segStart = k;
585
+ while (k < src.length) {
586
+ if (src[k] !== "\\") {
587
+ k++;
588
+ continue;
589
+ }
590
+ if (src.startsWith("\\left", k) && !/[A-Za-z]/.test(src[k + 5] ?? "")) {
591
+ depth++;
592
+ const tok = readDelimToken(src, k + 5);
593
+ k = tok ? tok.end : k + 5;
594
+ continue;
595
+ }
596
+ if (src.startsWith("\\right", k) && !/[A-Za-z]/.test(src[k + 6] ?? "")) {
597
+ depth--;
598
+ const tok = readDelimToken(src, k + 6);
599
+ if (depth === 0) {
600
+ segments.push(src.slice(segStart, k));
601
+ return { left: left.text, segments, middles, right: tok ? tok.text : ".", end: tok ? tok.end : k + 6 };
602
+ }
603
+ k = tok ? tok.end : k + 6;
604
+ continue;
605
+ }
606
+ if (depth === 1 && src.startsWith("\\middle", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) {
607
+ segments.push(src.slice(segStart, k));
608
+ const tok = readDelimToken(src, k + 7);
609
+ middles.push(tok ? tok.text : "|");
610
+ k = segStart = tok ? tok.end : k + 7;
611
+ continue;
612
+ }
613
+ k += 2; // escaped char / other command head — never a boundary
614
+ }
615
+ return null; // unbalanced
616
+ }
617
+
618
+ /**
619
+ * Index of the `close` matching the `open` at `i`, skipping escapes and brace
620
+ * groups; −1 when unbalanced (e.g. interval notation `[0, 1)`).
621
+ */
622
+ function matchDelim(src: string, i: number, open: string, close: string): number {
623
+ let depth = 0;
624
+ for (let k = i; k < src.length; k++) {
625
+ const c = src[k];
626
+ if (c === "\\") {
627
+ k++;
628
+ continue;
629
+ }
630
+ if (c === "{") {
631
+ k = readBraceGroup(src, k).end - 1;
632
+ continue;
633
+ }
634
+ if (c === open) depth++;
635
+ else if (c === close) {
636
+ depth--;
637
+ if (depth === 0) return k;
638
+ }
639
+ }
640
+ return -1;
641
+ }
642
+
643
+ interface EnvParts {
644
+ env: string;
645
+ bodyStart: number;
646
+ bodyEnd: number;
647
+ end: number;
648
+ }
649
+
650
+ /** Locate a `\begin{env}…\end{env}` block (balanced) starting at the backslash. */
651
+ function readEnvironment(src: string, start: number): EnvParts | null {
652
+ let i = start + 6; // past "\begin"
653
+ while (src[i] === " ") i++;
654
+ if (src[i] !== "{") return null;
655
+ const nameGroup = readBraceGroup(src, i);
656
+ let k = nameGroup.end;
657
+ let depth = 1;
658
+ let bodyEnd = src.length;
659
+ while (k < src.length && depth > 0) {
660
+ if (src.startsWith("\\begin", k)) {
661
+ depth++;
662
+ k += 6;
663
+ continue;
664
+ }
665
+ if (src.startsWith("\\end", k)) {
666
+ depth--;
667
+ if (depth === 0) bodyEnd = k;
668
+ k += 4;
669
+ while (src[k] === " ") k++;
670
+ if (src[k] === "{") k = readBraceGroup(src, k).end;
671
+ if (depth === 0) break;
672
+ continue;
673
+ }
674
+ k++;
675
+ }
676
+ return { env: nameGroup.text.trim(), bodyStart: nameGroup.end, bodyEnd, end: k };
677
+ }
678
+
679
+ /** The full `\begin{env}…\end{env}` substring as an inline run. */
680
+ function consumeEnvironment(src: string, start: number): Span | null {
681
+ const env = readEnvironment(src, start);
682
+ return env ? { text: src.slice(start, env.end), end: env.end } : null;
683
+ }
684
+
685
+ /** Split an environment body on top-level `\\` row breaks (depth-aware). */
686
+ function splitRows(body: string): string[] {
687
+ const rows: string[] = [];
688
+ let braceDepth = 0;
689
+ let envDepth = 0;
690
+ let last = 0;
691
+ let i = 0;
692
+ while (i < body.length) {
693
+ if (body.startsWith("\\begin", i)) {
694
+ envDepth++;
695
+ i += 6;
696
+ continue;
697
+ }
698
+ if (body.startsWith("\\end", i)) {
699
+ envDepth--;
700
+ i += 4;
701
+ continue;
702
+ }
703
+ const c = body[i];
704
+ if (c === "\\") {
705
+ if (body[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) {
706
+ rows.push(body.slice(last, i));
707
+ i += 2;
708
+ while (body[i] === " ") i++;
709
+ if (body[i] === "[") {
710
+ const close = body.indexOf("]", i);
711
+ i = close === -1 ? body.length : close + 1;
712
+ }
713
+ last = i;
714
+ continue;
715
+ }
716
+ i += 2; // skip escaped char / second backslash so `\{`/`\\` never skew depth
717
+ continue;
718
+ }
719
+ if (c === "{") braceDepth++;
720
+ else if (c === "}") braceDepth--;
721
+ i++;
722
+ }
723
+ rows.push(body.slice(last));
724
+ return rows;
725
+ }
726
+
727
+ /** Split a row on top-level `&` column separators (depth-aware), trimming cells. */
728
+ function splitCells(row: string): string[] {
729
+ const cells: string[] = [];
730
+ let braceDepth = 0;
731
+ let envDepth = 0;
732
+ let last = 0;
733
+ let i = 0;
734
+ while (i < row.length) {
735
+ if (row.startsWith("\\begin", i)) {
736
+ envDepth++;
737
+ i += 6;
738
+ continue;
739
+ }
740
+ if (row.startsWith("\\end", i)) {
741
+ envDepth--;
742
+ i += 4;
743
+ continue;
744
+ }
745
+ const c = row[i];
746
+ if (c === "\\") {
747
+ i += 2; // `\&` and command heads never split
748
+ continue;
749
+ }
750
+ if (c === "{") braceDepth++;
751
+ else if (c === "}") braceDepth--;
752
+ else if (c === "&" && braceDepth === 0 && envDepth === 0) {
753
+ cells.push(row.slice(last, i));
754
+ last = i + 1;
755
+ }
756
+ i++;
757
+ }
758
+ cells.push(row.slice(last));
759
+ return cells.map(cell => cell.trim());
760
+ }
761
+
762
+ /** Append a script (`^`/`_`) and its argument to the inline run verbatim. */
763
+ function readScript(src: string, i: number): Span {
764
+ let out = src[i];
765
+ i++;
766
+ while (src[i] === " ") {
767
+ out += src[i];
768
+ i++;
769
+ }
770
+ if (src[i] === "{") {
771
+ const group = readBraceGroup(src, i);
772
+ return { text: `${out}{${group.text}}`, end: group.end };
773
+ }
774
+ if (src[i] === "\\") {
775
+ let j = i + 1;
776
+ if (/[A-Za-z]/.test(src[j] ?? "")) while (/[A-Za-z]/.test(src[j] ?? "")) j++;
777
+ else j++;
778
+ return { text: out + src.slice(i, j), end: j };
779
+ }
780
+ if (i < src.length) return { text: out + src[i], end: i + 1 };
781
+ return { text: out, end: i };
782
+ }
783
+
784
+ /** Bare argument of a script read by `readScript` (`^{ab}` → `ab`, `^a` → `a`). */
785
+ function scriptArgOf(text: string): string {
786
+ let arg = text.slice(1).trimStart();
787
+ if (arg.startsWith("{") && arg.endsWith("}")) arg = arg.slice(1, -1);
788
+ return arg;
789
+ }
790
+
791
+ /**
792
+ * Render a `\begin{env}…\end{env}` block. Grid environments (matrix family,
793
+ * cases, array) become baseline-aligned 2-D grids in stretched delimiters;
794
+ * wrapper environments (`align`, `gather`, …) parse each `\\` row, aligning `&`
795
+ * columns; anything else (tabular, …) renders flat via `latexToUnicode`.
796
+ */
797
+ function parseEnvironment(src: string, start: number, ctx: Ctx): { box: Box; end: number } | null {
798
+ const env = readEnvironment(src, start);
799
+ if (env === null) return null;
800
+ const starred = env.env.endsWith("*");
801
+ const base = starred ? env.env.slice(0, -1) : env.env;
802
+ const gridDelims = GRID_ENVIRONMENTS[base];
803
+ if (gridDelims) {
804
+ let p = env.bodyStart;
805
+ while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++;
806
+ if (starred && src[p] === "[") {
807
+ // Starred matrix variants take an optional alignment argument.
808
+ const close = src.indexOf("]", p);
809
+ if (close !== -1 && close < env.bodyEnd) {
810
+ p = close + 1;
811
+ while (src[p] === " " || src[p] === "\n" || src[p] === "\t") p++;
812
+ }
813
+ }
814
+ let colSpec: CellAlign[] | null = null;
815
+ if (base === "array" && src[p] === "{") {
816
+ const spec = readBraceGroup(src, p);
817
+ colSpec = [...spec.text].filter((ch): ch is CellAlign => ch === "l" || ch === "c" || ch === "r");
818
+ p = spec.end;
819
+ }
820
+ const cells = splitRows(src.slice(p, env.bodyEnd))
821
+ .map(row => row.trim())
822
+ .filter(row => row !== "")
823
+ .map(row => splitCells(row).map(cell => parseExpr(cell, ctx)));
824
+ const isCases = base === "cases" || base === "dcases" || base === "rcases" || base === "drcases";
825
+ const align: (col: number) => CellAlign = colSpec ? col => colSpec[col] ?? "c" : isCases ? () => "l" : () => "c";
826
+ const grid = gridBox(cells, align, () => 2, 1);
827
+ return { box: delimBox(grid, gridDelims[0], gridDelims[1]), end: env.end };
828
+ }
829
+ if (!DISPLAY_ROW_ENVIRONMENTS[base]) {
830
+ return { box: textBox(latexToUnicode(ctx.wrap(src.slice(start, env.end)))), end: env.end };
831
+ }
832
+ let bodyStart = env.bodyStart;
833
+ if (base === "alignat" || base === "alignedat" || base === "gatheredat") {
834
+ // These carry a required column-count argument `{n}` before the body.
835
+ let p = bodyStart;
836
+ while (src[p] === " " || src[p] === "\n") p++;
837
+ if (src[p] === "{") bodyStart = readBraceGroup(src, p).end;
838
+ }
839
+ const rows = splitRows(src.slice(bodyStart, env.bodyEnd))
840
+ .map(row => row.trim())
841
+ .filter(row => row !== "");
842
+ if (rows.length === 0) return { box: textBox(""), end: env.end };
843
+ const cellRows = rows.map(splitCells);
844
+ let ncols = 0;
845
+ for (const row of cellRows) ncols = Math.max(ncols, row.length);
846
+ if (ncols <= 1) {
847
+ const centered = base === "gather" || base === "gathered" || base === "multline";
848
+ return {
849
+ box: vconcat(
850
+ rows.map(row => parseExpr(row, ctx)),
851
+ centered ? "c" : "l",
852
+ ),
853
+ end: env.end,
854
+ };
855
+ }
856
+ // `align`-family semantics: columns alternate right/left in `rl` pairs, a
857
+ // thin gap inside each pair and a wide gap between pairs.
858
+ const grid = gridBox(
859
+ cellRows.map(row => row.map(cell => parseExpr(cell, ctx))),
860
+ col => (col % 2 === 0 ? "r" : "l"),
861
+ col => (col % 2 === 1 ? 1 : 3),
862
+ );
863
+ return { box: grid, end: env.end };
864
+ }
865
+
866
+ /**
867
+ * Paint every line of `box` through a `latexColorScope` painter so structural
868
+ * glyphs (fraction bars, stretched delimiters, matrix brackets) inherit the
869
+ * enclosing color scope while nested color runs still restore to it.
870
+ */
871
+ function colorizeBox(box: Box, scope: (text: string) => string): Box {
872
+ return { lines: box.lines.map(scope), baseline: box.baseline, width: box.width };
873
+ }
874
+
875
+ /**
876
+ * Parse a math fragment into a layout box. 2-D constructs — fractions, binomials,
877
+ * radicals over tall content, `\left…\right` and tall bare parens, environments,
878
+ * big-operator limits, block scripts — become stacked boxes; everything between
879
+ * them is gathered into inline runs rendered through `latexToUnicode` under the
880
+ * active scope wrapper (`ctx`), with `\color` state re-applied per run.
881
+ */
882
+ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
883
+ const boxes: Box[] = [];
884
+ let inline = "";
885
+ let color = "";
886
+ let colorScope: ((text: string) => string) | null = null;
887
+ const flush = (): void => {
888
+ if (!inline) return;
889
+ boxes.push(textBox(latexToUnicode(ctx.wrap(color + inline))));
890
+ inline = "";
891
+ };
892
+ /** Child context carrying the enclosing wrapper plus current color state. */
893
+ const inner = (): Ctx => {
894
+ if (!color) return ctx;
895
+ const pre = color;
896
+ return { wrap: run => ctx.wrap(pre + run) };
897
+ };
898
+ /** Apply the active `\color` scope to a structural box's glyphs. */
899
+ const paint = (box: Box): Box => (colorScope === null ? box : colorizeBox(box, colorScope));
900
+ let i = 0;
901
+ while (i < src.length) {
902
+ const c = src[i];
903
+ if (c === "\\") {
904
+ let j = i + 1;
905
+ let name = "";
906
+ while (j < src.length && /[A-Za-z]/.test(src[j])) {
907
+ name += src[j];
908
+ j++;
909
+ }
910
+ if (name && FRAC_COMMANDS[name]) {
911
+ flush();
912
+ const num = readArg(src, j);
913
+ const den = readArg(src, num.end);
914
+ boxes.push(paint(fracBox(parseExpr(num.text, inner()), parseExpr(den.text, inner()))));
915
+ i = den.end;
916
+ continue;
917
+ }
918
+ if (name && BINOM_COMMANDS[name]) {
919
+ flush();
920
+ const top = readArg(src, j);
921
+ const bottom = readArg(src, top.end);
922
+ boxes.push(paint(binomBox(parseExpr(top.text, inner()), parseExpr(bottom.text, inner()))));
923
+ i = bottom.end;
924
+ continue;
925
+ }
926
+ if (name && HBRACE_COMMANDS[name]) {
927
+ flush();
928
+ const spec = HBRACE_COMMANDS[name];
929
+ const arg = readArg(src, j);
930
+ // Limits-style scripts: the brace-side script is the label; an
931
+ // opposite-side script attaches as a regular corner script.
932
+ let subText: string | null = null;
933
+ let supText: string | null = null;
934
+ let m = arg.end;
935
+ for (;;) {
936
+ let n = m;
937
+ while (src[n] === " ") n++;
938
+ if (src[n] === "_" && subText === null) {
939
+ const s = readArg(src, n + 1);
940
+ subText = s.text;
941
+ m = s.end;
942
+ continue;
943
+ }
944
+ if (src[n] === "^" && supText === null) {
945
+ const s = readArg(src, n + 1);
946
+ supText = s.text;
947
+ m = s.end;
948
+ continue;
949
+ }
950
+ break;
951
+ }
952
+ const labelText = spec.over ? supText : subText;
953
+ const otherText = spec.over ? subText : supText;
954
+ let box = hbraceBox(
955
+ parseExpr(arg.text, inner()),
956
+ spec,
957
+ labelText === null ? null : parseExpr(labelText, inner()),
958
+ );
959
+ if (otherText !== null) {
960
+ const other = parseExpr(otherText, inner());
961
+ box = attachScripts(box, spec.over ? other : null, spec.over ? null : other);
962
+ }
963
+ boxes.push(paint(box));
964
+ i = m;
965
+ continue;
966
+ }
967
+ if (name === "overset" || name === "underset" || name === "stackrel") {
968
+ flush();
969
+ const anno = readArg(src, j);
970
+ const base = readArg(src, anno.end);
971
+ const annoBox = parseExpr(anno.text, inner());
972
+ const baseBox = parseExpr(base.text, inner());
973
+ boxes.push(
974
+ paint(limitsBox(baseBox, name === "underset" ? annoBox : null, name === "underset" ? null : annoBox)),
975
+ );
976
+ i = base.end;
977
+ continue;
978
+ }
979
+ if (name === "sqrt") {
980
+ let k = j;
981
+ while (src[k] === " ") k++;
982
+ let degree: string | null = null;
983
+ if (src[k] === "[") {
984
+ const close = src.indexOf("]", k);
985
+ degree = src.slice(k + 1, close === -1 ? src.length : close);
986
+ k = close === -1 ? src.length : close + 1;
987
+ }
988
+ const arg = readArg(src, k);
989
+ // Display style always draws the roof (like LaTeX); inline math
990
+ // keeps the flat `√(…)` form via latexToUnicode.
991
+ flush();
992
+ boxes.push(paint(radicalBox(parseExpr(arg.text, inner()), degree)));
993
+ i = arg.end;
994
+ continue;
995
+ }
996
+ if (name === "left") {
997
+ const lr = readLeftRight(src, i);
998
+ if (lr) {
999
+ const segBoxes = lr.segments.map(segment => parseExpr(segment, inner()));
1000
+ let above = 0;
1001
+ let below = 0;
1002
+ for (const b of segBoxes) {
1003
+ above = Math.max(above, b.baseline);
1004
+ below = Math.max(below, b.lines.length - 1 - b.baseline);
1005
+ }
1006
+ const height = above + below + 1;
1007
+ if (height === 1) {
1008
+ // Single-line: keep the whole span inline so converter
1009
+ // state (fonts, colors, spacing) is preserved.
1010
+ inline += src.slice(i, lr.end);
1011
+ i = lr.end;
1012
+ continue;
1013
+ }
1014
+ flush();
1015
+ const parts: Box[] = [];
1016
+ const push = (col: Box | null): void => {
1017
+ if (col) parts.push(col);
1018
+ };
1019
+ push(delimColumn(delimKey(lr.left), height, above));
1020
+ segBoxes.forEach((segment, s) => {
1021
+ parts.push(segment);
1022
+ if (s < lr.middles.length) push(delimColumn(delimKey(lr.middles[s]), height, above));
1023
+ });
1024
+ push(delimColumn(delimKey(lr.right), height, above));
1025
+ boxes.push(paint(hconcat(parts)));
1026
+ i = lr.end;
1027
+ continue;
1028
+ }
1029
+ }
1030
+ if (name && (LIMIT_OPERATORS[name] || INTEGRAL_OPERATORS[name])) {
1031
+ let k = j;
1032
+ while (src[k] === " ") k++;
1033
+ let stack = LIMIT_OPERATORS[name] === true;
1034
+ let resume = j; // resume point when the operator stays inline
1035
+ if (src.startsWith("\\limits", k) && !/[A-Za-z]/.test(src[k + 7] ?? "")) {
1036
+ stack = true;
1037
+ resume = k = k + 7;
1038
+ } else if (src.startsWith("\\nolimits", k) && !/[A-Za-z]/.test(src[k + 9] ?? "")) {
1039
+ stack = false;
1040
+ resume = k + 9;
1041
+ }
1042
+ if (stack) {
1043
+ let subText: string | null = null;
1044
+ let supText: string | null = null;
1045
+ let m = k;
1046
+ for (;;) {
1047
+ // Peek past spaces without consuming them, so a run
1048
+ // following the operator keeps its leading space.
1049
+ let n = m;
1050
+ while (src[n] === " ") n++;
1051
+ if (src[n] === "_" && subText === null) {
1052
+ const arg = readArg(src, n + 1);
1053
+ subText = arg.text;
1054
+ m = arg.end;
1055
+ continue;
1056
+ }
1057
+ if (src[n] === "^" && supText === null) {
1058
+ const arg = readArg(src, n + 1);
1059
+ supText = arg.text;
1060
+ m = arg.end;
1061
+ continue;
1062
+ }
1063
+ break;
1064
+ }
1065
+ if (subText !== null || supText !== null) {
1066
+ flush();
1067
+ const glyph = textBox(latexToUnicode(ctx.wrap(`${color}\\${name}`)));
1068
+ boxes.push(
1069
+ paint(
1070
+ limitsBox(
1071
+ glyph,
1072
+ subText === null ? null : parseExpr(subText, inner()),
1073
+ supText === null ? null : parseExpr(supText, inner()),
1074
+ ),
1075
+ ),
1076
+ );
1077
+ i = m;
1078
+ continue;
1079
+ }
1080
+ }
1081
+ inline += `\\${name}`;
1082
+ i = resume;
1083
+ continue;
1084
+ }
1085
+ if (name === "color" || name === "normalcolor") {
1086
+ flush(); // preceding run keeps the previous color
1087
+ if (name === "normalcolor") {
1088
+ color = "";
1089
+ colorScope = null;
1090
+ i = j;
1091
+ continue;
1092
+ }
1093
+ let k = j;
1094
+ while (src[k] === " ") k++;
1095
+ let opt = "";
1096
+ if (src[k] === "[") {
1097
+ const close = src.indexOf("]", k);
1098
+ if (close !== -1) {
1099
+ opt = src.slice(k, close + 1);
1100
+ k = close + 1;
1101
+ while (src[k] === " ") k++;
1102
+ }
1103
+ }
1104
+ if (src[k] === "{") {
1105
+ const spec = readBraceGroup(src, k);
1106
+ color = `\\color${opt}{${spec.text}}`;
1107
+ colorScope = latexColorScope(opt ? opt.slice(1, -1).trim() : null, spec.text);
1108
+ i = spec.end;
1109
+ } else {
1110
+ color = "";
1111
+ colorScope = null;
1112
+ i = k;
1113
+ }
1114
+ continue;
1115
+ }
1116
+ if (name === "begin") {
1117
+ const env = parseEnvironment(src, i, inner());
1118
+ if (env) {
1119
+ flush();
1120
+ boxes.push(paint(env.box));
1121
+ i = env.end;
1122
+ continue;
1123
+ }
1124
+ }
1125
+ if (name && (MATH_FONT_COMMANDS.has(name) || name === "textcolor")) {
1126
+ // Scoped wrapper around 2-D content: recurse with the wrapper
1127
+ // re-applied to every inline run, so styling crosses boxes.
1128
+ let k = j;
1129
+ while (src[k] === " ") k++;
1130
+ let prefix = `\\${name}`;
1131
+ let scope: ((text: string) => string) | null = null;
1132
+ if (name === "textcolor") {
1133
+ let model: string | null = null;
1134
+ if (src[k] === "[") {
1135
+ const close = src.indexOf("]", k);
1136
+ if (close !== -1) {
1137
+ model = src.slice(k + 1, close).trim();
1138
+ prefix += src.slice(k, close + 1);
1139
+ k = close + 1;
1140
+ while (src[k] === " ") k++;
1141
+ }
1142
+ }
1143
+ if (src[k] !== "{") {
1144
+ inline += `\\${name}`;
1145
+ i = j;
1146
+ continue;
1147
+ }
1148
+ const spec = readBraceGroup(src, k);
1149
+ prefix += `{${spec.text}}`;
1150
+ scope = latexColorScope(model, spec.text);
1151
+ k = spec.end;
1152
+ while (src[k] === " ") k++;
1153
+ }
1154
+ if (src[k] === "{") {
1155
+ const content = readBraceGroup(src, k);
1156
+ flush();
1157
+ const pre = color;
1158
+ let box = parseExpr(content.text, { wrap: run => ctx.wrap(`${pre}${prefix}{${run}}`) });
1159
+ if (scope !== null) box = colorizeBox(box, scope);
1160
+ boxes.push(paint(box));
1161
+ i = content.end;
1162
+ continue;
1163
+ }
1164
+ }
1165
+ if (!name) {
1166
+ // Non-letter command (`\\`, `\,`, `\{`, …): keep the 2-char token inline.
1167
+ inline += `\\${src[j] ?? ""}`;
1168
+ i = j + 1;
1169
+ continue;
1170
+ }
1171
+ // Other command: keep it and its bracket/brace arguments inline so a
1172
+ // `{…}` argument is never mistaken for a top-level stacking group.
1173
+ inline += `\\${name}`;
1174
+ i = j;
1175
+ while (src[i] === "[" || src[i] === "{") {
1176
+ if (src[i] === "{") {
1177
+ const group = readBraceGroup(src, i);
1178
+ inline += `{${group.text}}`;
1179
+ i = group.end;
1180
+ } else {
1181
+ const close = src.indexOf("]", i);
1182
+ const end = close === -1 ? src.length : close + 1;
1183
+ inline += src.slice(i, end);
1184
+ i = end;
1185
+ }
1186
+ }
1187
+ continue;
1188
+ }
1189
+ if (c === "^" || c === "_") {
1190
+ const first = readScript(src, i);
1191
+ // Consume an immediately following opposite script (`M_i^j`) so both
1192
+ // land in one shared column instead of two successive ones.
1193
+ let second: Span | null = null;
1194
+ let n = first.end;
1195
+ while (src[n] === " ") n++;
1196
+ if (src[n] === (c === "^" ? "_" : "^")) second = readScript(src, n);
1197
+ const end = second === null ? first.end : second.end;
1198
+ const supText = c === "^" ? first.text : second?.text;
1199
+ const subText = c === "_" ? first.text : second?.text;
1200
+ const supBox = supText === undefined ? null : parseExpr(scriptArgOf(supText), inner());
1201
+ const subBox = subText === undefined ? null : parseExpr(scriptArgOf(subText), inner());
1202
+ // The converter falls back to `^(…)`/`_(…)` when any character lacks a
1203
+ // Unicode script form; those scripts get real raised/lowered boxes.
1204
+ const unconvertible = (raw: string | undefined): boolean => {
1205
+ if (raw === undefined) return false;
1206
+ const flat = latexToUnicode(raw);
1207
+ return flat.startsWith("^") || flat.startsWith("_");
1208
+ };
1209
+ // Multi-letter script words (`N_{turns}`) would convert per-char into
1210
+ // Unicode glyphs of uneven height and read ragged; box them too.
1211
+ // Commands are stripped: their output (`\prime` → ′) is not letters.
1212
+ const ragged = (raw: string | undefined): boolean => {
1213
+ if (raw === undefined) return false;
1214
+ const letters = scriptArgOf(raw)
1215
+ .replace(/\\[A-Za-z]+/g, "")
1216
+ .match(/[A-Za-z]/g);
1217
+ return letters !== null && letters.length >= 2;
1218
+ };
1219
+ const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1);
1220
+ if (tall || unconvertible(supText) || unconvertible(subText) || ragged(supText) || ragged(subText)) {
1221
+ // Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes
1222
+ // against the run or box they follow.
1223
+ flush();
1224
+ const base = boxes.pop() ?? textBox("");
1225
+ boxes.push(paint(attachScripts(base, subBox, supBox)));
1226
+ i = end;
1227
+ continue;
1228
+ }
1229
+ const last = boxes[boxes.length - 1];
1230
+ if (inline === "" && last !== undefined && last.lines.length > 1) {
1231
+ // Scripts directly on a tall box (`M^T`, `\right|_{x=a}`): pin
1232
+ // the Unicode script glyphs (guaranteed convertible here after
1233
+ // the gate above) to its corners.
1234
+ const corner = (raw: string | undefined): Box | null =>
1235
+ raw === undefined ? null : textBox(latexToUnicode(ctx.wrap(color + raw)));
1236
+ boxes[boxes.length - 1] = paint(attachScripts(last, corner(subText), corner(supText)));
1237
+ i = end;
1238
+ continue;
1239
+ }
1240
+ inline += src.slice(i, end);
1241
+ i = end;
1242
+ continue;
1243
+ }
1244
+ if (c === "{") {
1245
+ const group = readBraceGroup(src, i);
1246
+ flush();
1247
+ boxes.push(paint(parseExpr(group.text, inner())));
1248
+ i = group.end;
1249
+ continue;
1250
+ }
1251
+ if (c === "(" || c === "[") {
1252
+ // Bare delimiters stretch when their content is tall (common in
1253
+ // model output that omits `\left`/`\right`).
1254
+ const closeCh = c === "(" ? ")" : "]";
1255
+ const close = matchDelim(src, i, c, closeCh);
1256
+ if (close !== -1) {
1257
+ const innerBox = parseExpr(src.slice(i + 1, close), inner());
1258
+ if (innerBox.lines.length > 1) {
1259
+ flush();
1260
+ boxes.push(paint(delimBox(innerBox, c, closeCh)));
1261
+ i = close + 1;
1262
+ continue;
1263
+ }
1264
+ }
1265
+ }
1266
+ inline += c;
1267
+ i++;
1268
+ }
1269
+ flush();
1270
+ if (boxes.length === 0) return textBox("");
1271
+ return hconcat(boxes);
1272
+ }
1273
+
1274
+ /** Split on top-level `\n` and `\\` row separators (outside braces and environments). */
1275
+ function splitLines(src: string): string[] {
1276
+ const lines: string[] = [];
1277
+ let braceDepth = 0;
1278
+ let envDepth = 0;
1279
+ let last = 0;
1280
+ let i = 0;
1281
+ while (i < src.length) {
1282
+ if (src.startsWith("\\begin", i)) {
1283
+ envDepth++;
1284
+ i += 6;
1285
+ continue;
1286
+ }
1287
+ if (src.startsWith("\\end", i)) {
1288
+ envDepth--;
1289
+ i += 4;
1290
+ continue;
1291
+ }
1292
+ const c = src[i];
1293
+ if (c === "\\") {
1294
+ if (src[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) {
1295
+ lines.push(src.slice(last, i));
1296
+ i += 2;
1297
+ while (src[i] === " ") i++;
1298
+ if (src[i] === "[") {
1299
+ const close = src.indexOf("]", i);
1300
+ i = close === -1 ? src.length : close + 1;
1301
+ }
1302
+ last = i;
1303
+ continue;
1304
+ }
1305
+ i += 2; // escaped char — never a logical-line break
1306
+ continue;
1307
+ }
1308
+ if (c === "{") braceDepth++;
1309
+ else if (c === "}") braceDepth--;
1310
+ else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
1311
+ lines.push(src.slice(last, i));
1312
+ last = i + 1;
1313
+ }
1314
+ i++;
1315
+ }
1316
+ lines.push(src.slice(last));
1317
+ return lines;
1318
+ }
1319
+
1320
+ /**
1321
+ * Render a display LaTeX math fragment to lines with full 2-D layout: stacked
1322
+ * fractions, stretchy delimiters, matrix grids, operator limits, drawn
1323
+ * radicals. Top-level source newlines and `\\` become vertical rows (so a
1324
+ * `lhs =` line stays above its block). Inline math should use `latexToUnicode`
1325
+ * instead — fractions there stay single-line.
1326
+ */
1327
+ export function latexToBlock(src: string): string[] {
1328
+ if (typeof src !== "string" || src.trim() === "") return [];
1329
+ const rows = splitLines(src.trim())
1330
+ .map(line => line.trim())
1331
+ .filter(line => line !== "")
1332
+ .map(line => parseExpr(line));
1333
+ if (rows.length === 0) return [];
1334
+ let lines = vconcat(rows).lines;
1335
+ while (lines.length > 1 && lines[lines.length - 1].trim() === "") lines = lines.slice(0, -1);
1336
+ while (lines.length > 1 && lines[0].trim() === "") lines = lines.slice(1);
1337
+ return lines;
1338
+ }