@oh-my-pi/pi-tui 16.4.0 → 16.4.2
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 +15 -0
- package/dist/types/latex-block.d.ts +5 -4
- package/dist/types/latex-to-unicode.d.ts +17 -0
- package/package.json +3 -3
- package/src/latex-block.ts +835 -66
- package/src/latex-to-unicode.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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
|
+
|
|
5
20
|
## [16.4.0] - 2026-07-10
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Render a display LaTeX math fragment to lines
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
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.4.
|
|
4
|
+
"version": "16.4.2",
|
|
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.4.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.4.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.4.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.4.2",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/latex-block.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
// Two-dimensional layout for *display* LaTeX math
|
|
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
|
-
//
|
|
5
|
-
// x
|
|
6
|
-
//
|
|
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`).
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
32
|
-
//
|
|
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
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* into inline
|
|
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
|
-
|
|
334
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* `
|
|
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 [];
|
package/src/latex-to-unicode.ts
CHANGED
|
@@ -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,
|