jeopi-tui 16.2.13
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 +1861 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +99 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +155 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +23 -0
- package/dist/types/components/loader.d.ts +20 -0
- package/dist/types/components/markdown.d.ts +64 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +68 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +14 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +51 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +32 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +191 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +79 -0
- package/dist/types/latex-block.d.ts +7 -0
- package/dist/types/latex-to-unicode.d.ts +33 -0
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +284 -0
- package/dist/types/terminal.d.ts +107 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +423 -0
- package/dist/types/utils.d.ts +95 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +1026 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +194 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3092 -0
- package/src/components/image.ts +444 -0
- package/src/components/input.ts +474 -0
- package/src/components/loader.ts +103 -0
- package/src/components/markdown.ts +2068 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +531 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +122 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +186 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +356 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +337 -0
- package/src/keys.ts +561 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +461 -0
- package/src/latex-to-unicode.ts +1994 -0
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +669 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1152 -0
- package/src/terminal.ts +1463 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +3901 -0
- package/src/utils.ts +570 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE), with
|
|
3
|
+
* runtime feature state and env overrides.
|
|
4
|
+
*
|
|
5
|
+
* Unicode placeholders let a transmitted image be displayed by writing ordinary
|
|
6
|
+
* text cells — the placeholder char U+10EEEE plus row/column combining
|
|
7
|
+
* diacritics — instead of a cursor-positioned `a=p` direct placement. The image
|
|
8
|
+
* then participates in the normal text grid, so it survives horizontal slicing,
|
|
9
|
+
* reflow and overlapping draws (each cell names its own row+column, so a sliced
|
|
10
|
+
* row still maps to the correct sub-region). See kitty
|
|
11
|
+
* `docs/graphics-protocol.rst` "Unicode placeholders for relative placements".
|
|
12
|
+
*
|
|
13
|
+
* This module is intentionally free of `./terminal-capabilities` imports so the
|
|
14
|
+
* dependency stays one-way (capabilities → kitty-graphics) and no import cycle
|
|
15
|
+
* forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
|
|
19
|
+
export const KITTY_PLACEHOLDER = "\u{10eeee}";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Row/column diacritics (Unicode combining class 230, no decomposition) used to
|
|
23
|
+
* name a placeholder cell's row and column. Index `i` → codepoint. Derived from
|
|
24
|
+
* kitty `gen/rowcolumn-diacritics.txt` (Unicode 6.0.0 NSM set). 297 entries, so
|
|
25
|
+
* a single image can address up to 297 rows/columns without ID-high-byte tricks.
|
|
26
|
+
*/
|
|
27
|
+
const ROWCOLUMN_DIACRITICS: readonly number[] = [
|
|
28
|
+
0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357,
|
|
29
|
+
0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484,
|
|
30
|
+
0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1,
|
|
31
|
+
0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658,
|
|
32
|
+
0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2,
|
|
33
|
+
0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743,
|
|
34
|
+
0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819,
|
|
35
|
+
0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c,
|
|
36
|
+
0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75,
|
|
37
|
+
0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72,
|
|
38
|
+
0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7,
|
|
39
|
+
0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda,
|
|
40
|
+
0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0,
|
|
41
|
+
0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1,
|
|
42
|
+
0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded,
|
|
43
|
+
0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb,
|
|
44
|
+
0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4,
|
|
45
|
+
0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0,
|
|
46
|
+
0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26,
|
|
47
|
+
0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243,
|
|
48
|
+
0x1d244,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Largest row/column index expressible with the diacritic table (one cell each). */
|
|
52
|
+
export const KITTY_PLACEHOLDER_MAX_CELLS = ROWCOLUMN_DIACRITICS.length;
|
|
53
|
+
|
|
54
|
+
export interface KittyGraphicsFeatures {
|
|
55
|
+
/** Display images via Unicode placeholders instead of direct `a=p` placement. */
|
|
56
|
+
unicodePlaceholders: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
61
|
+
* U+10EEEE with row/column diacritics).
|
|
62
|
+
*
|
|
63
|
+
* Only `kitty` (the protocol's origin) and `ghostty` ship a working
|
|
64
|
+
* implementation; WezTerm advertises Kitty graphics but treats placeholder
|
|
65
|
+
* cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
|
|
66
|
+
* still unchecked), and the tmux/screen fallback can land on any outer
|
|
67
|
+
* terminal. Enabling placeholders on those paths emits a `columns × rows`
|
|
68
|
+
* grid of U+10EEEE per image per frame; the cells render as boxed fallback
|
|
69
|
+
* glyphs and re-emit on every repaint, which is exactly the
|
|
70
|
+
* "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
|
|
71
|
+
*
|
|
72
|
+
* `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
|
|
73
|
+
* non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
|
|
74
|
+
* for a wezterm nightly that has merged placeholder support).
|
|
75
|
+
*/
|
|
76
|
+
export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
77
|
+
const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
78
|
+
if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
|
|
79
|
+
const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
80
|
+
if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
|
|
81
|
+
if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
|
|
82
|
+
return terminalId === "kitty" || terminalId === "ghostty";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let features: KittyGraphicsFeatures = {
|
|
86
|
+
// Off until `terminal-capabilities` seeds it from the detected terminal id —
|
|
87
|
+
// the default-on path corrupts wezterm and tmux-passthrough sessions.
|
|
88
|
+
unicodePlaceholders: false,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function getKittyGraphics(): Readonly<KittyGraphicsFeatures> {
|
|
92
|
+
return features;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void {
|
|
96
|
+
features = { ...features, ...partial };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether a `columns`×`rows` placeholder grid fits within the diacritic table. */
|
|
100
|
+
export function kittyPlaceholdersFit(columns: number, rows: number): boolean {
|
|
101
|
+
return columns >= 1 && rows >= 1 && columns <= KITTY_PLACEHOLDER_MAX_CELLS && rows <= KITTY_PLACEHOLDER_MAX_CELLS;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function diacritic(index: number): string {
|
|
105
|
+
const cp = ROWCOLUMN_DIACRITICS[index];
|
|
106
|
+
return cp === undefined ? "" : String.fromCodePoint(cp);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Virtual placement APC (`a=p,U=1`): tells the terminal that placeholder cells
|
|
111
|
+
* carrying image id `i` should display the transmitted image, scaled to fit the
|
|
112
|
+
* `c`×`r` cell box. Re-emitting with a stable `placementId` replaces in place.
|
|
113
|
+
*/
|
|
114
|
+
export function encodeKittyVirtualPlacement(opts: {
|
|
115
|
+
imageId: number;
|
|
116
|
+
placementId?: number;
|
|
117
|
+
columns: number;
|
|
118
|
+
rows: number;
|
|
119
|
+
}): string {
|
|
120
|
+
const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`];
|
|
121
|
+
if (opts.placementId) params.push(`p=${opts.placementId}`);
|
|
122
|
+
params.push(`c=${opts.columns}`, `r=${opts.rows}`);
|
|
123
|
+
return `\x1b_G${params.join(",")}\x1b\\`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the placeholder cell grid as one string per row. The image id is carried
|
|
128
|
+
* in each row's foreground color and the placement id (if any) in its underline
|
|
129
|
+
* color; every cell names its explicit row+column diacritic (robust to slicing,
|
|
130
|
+
* unlike left-inheritance). Returns exactly `rows` strings.
|
|
131
|
+
*/
|
|
132
|
+
export function encodeKittyPlaceholderGrid(opts: {
|
|
133
|
+
imageId: number;
|
|
134
|
+
placementId?: number;
|
|
135
|
+
columns: number;
|
|
136
|
+
rows: number;
|
|
137
|
+
}): string[] {
|
|
138
|
+
const fg = `\x1b[38;2;${(opts.imageId >> 16) & 0xff};${(opts.imageId >> 8) & 0xff};${opts.imageId & 0xff}m`;
|
|
139
|
+
const underline = opts.placementId
|
|
140
|
+
? `\x1b[58:2::${(opts.placementId >> 16) & 0xff}:${(opts.placementId >> 8) & 0xff}:${opts.placementId & 0xff}m`
|
|
141
|
+
: "";
|
|
142
|
+
const reset = "\x1b[39;59m";
|
|
143
|
+
const lead = fg + underline;
|
|
144
|
+
const out: string[] = [];
|
|
145
|
+
for (let r = 0; r < opts.rows; r++) {
|
|
146
|
+
const rowDiacritic = diacritic(r);
|
|
147
|
+
let row = lead;
|
|
148
|
+
for (let c = 0; c < opts.columns; c++) {
|
|
149
|
+
row += KITTY_PLACEHOLDER + rowDiacritic + diacritic(c);
|
|
150
|
+
}
|
|
151
|
+
out.push(row + reset);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Full placeholder render: the virtual-placement APC prefixes line 0, and every
|
|
158
|
+
* line carries placeholder cells. Returns exactly `rows` lines (no cursor moves).
|
|
159
|
+
*/
|
|
160
|
+
export function renderKittyPlaceholderLines(opts: {
|
|
161
|
+
imageId: number;
|
|
162
|
+
placementId?: number;
|
|
163
|
+
columns: number;
|
|
164
|
+
rows: number;
|
|
165
|
+
}): string[] {
|
|
166
|
+
const grid = encodeKittyPlaceholderGrid(opts);
|
|
167
|
+
if (grid.length > 0) {
|
|
168
|
+
grid[0] = encodeKittyVirtualPlacement(opts) + grid[0];
|
|
169
|
+
}
|
|
170
|
+
return grid;
|
|
171
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// Two-dimensional layout for *display* LaTeX math: stacks `\frac` numerator over
|
|
2
|
+
// denominator with a horizontal bar, aligning surrounding text to the bar's row.
|
|
3
|
+
//
|
|
4
|
+
// −b ± √(b² − 4ac)
|
|
5
|
+
// x = ────────────────
|
|
6
|
+
// 2a
|
|
7
|
+
//
|
|
8
|
+
// Only display blocks (`$$…$$`, `\[…\]`) use this; inline `$…$` stays single-line
|
|
9
|
+
// (`½`, `(a+b)/c`). Everything that is not a fraction — symbols, scripts, roots,
|
|
10
|
+
// matrices, environments — is delegated to `latexToUnicode`, so this engine only
|
|
11
|
+
// adds the vertical stacking the flat string form can't express.
|
|
12
|
+
|
|
13
|
+
import { latexToUnicode } from "./latex-to-unicode";
|
|
14
|
+
import { visibleWidth } from "./utils";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A rectangular block of rendered text. Every entry in `lines` is padded to
|
|
18
|
+
* exactly `width` visible columns; `baseline` is the row that aligns with the
|
|
19
|
+
* surrounding text when boxes are placed side by side (e.g. the fraction bar).
|
|
20
|
+
*/
|
|
21
|
+
interface Box {
|
|
22
|
+
lines: string[];
|
|
23
|
+
baseline: number;
|
|
24
|
+
width: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BAR = "─";
|
|
28
|
+
const FRAC_COMMANDS: Record<string, true> = { frac: true, dfrac: true, tfrac: true, cfrac: true };
|
|
29
|
+
|
|
30
|
+
// Display "wrapper" environments whose body is an expression (possibly with `\\`
|
|
31
|
+
// row breaks and `&` alignment). Their bodies are parsed so fractions inside
|
|
32
|
+
// stack; grid/structure environments (matrix/array/cases) stay opaque and are
|
|
33
|
+
// rendered flat by `latexToUnicode`.
|
|
34
|
+
const DISPLAY_ROW_ENVIRONMENTS: Record<string, true> = {
|
|
35
|
+
equation: true,
|
|
36
|
+
eqnarray: true,
|
|
37
|
+
align: true,
|
|
38
|
+
aligned: true,
|
|
39
|
+
alignat: true,
|
|
40
|
+
alignedat: true,
|
|
41
|
+
flalign: true,
|
|
42
|
+
split: true,
|
|
43
|
+
gather: true,
|
|
44
|
+
gathered: true,
|
|
45
|
+
gatheredat: true,
|
|
46
|
+
multline: true,
|
|
47
|
+
displaymath: true,
|
|
48
|
+
math: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function spaces(n: number): string {
|
|
52
|
+
return n > 0 ? " ".repeat(n) : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pad `line` on the right to `width` visible columns. */
|
|
56
|
+
function padRight(line: string, width: number): string {
|
|
57
|
+
return line + spaces(width - visibleWidth(line));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pad `line` symmetrically (left-biased) to `width` visible columns. */
|
|
61
|
+
function center(line: string, width: number): string {
|
|
62
|
+
const extra = width - visibleWidth(line);
|
|
63
|
+
if (extra <= 0) return line;
|
|
64
|
+
const left = extra >> 1;
|
|
65
|
+
return spaces(left) + line + spaces(extra - left);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A single rendered string (possibly multi-line) as a baseline-centered box. */
|
|
69
|
+
function textBox(text: string): Box {
|
|
70
|
+
const raw = text.split("\n");
|
|
71
|
+
let width = 0;
|
|
72
|
+
for (const line of raw) width = Math.max(width, visibleWidth(line));
|
|
73
|
+
return { lines: raw.map(line => padRight(line, width)), baseline: (raw.length - 1) >> 1, width };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Place boxes side by side, aligning their baselines. */
|
|
77
|
+
function hconcat(boxes: Box[]): Box {
|
|
78
|
+
if (boxes.length === 1) return boxes[0];
|
|
79
|
+
let above = 0;
|
|
80
|
+
let below = 0;
|
|
81
|
+
for (const b of boxes) {
|
|
82
|
+
above = Math.max(above, b.baseline);
|
|
83
|
+
below = Math.max(below, b.lines.length - 1 - b.baseline);
|
|
84
|
+
}
|
|
85
|
+
const height = above + below + 1;
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
let width = 0;
|
|
88
|
+
for (const b of boxes) width += b.width;
|
|
89
|
+
for (let row = 0; row < height; row++) {
|
|
90
|
+
let line = "";
|
|
91
|
+
for (const b of boxes) {
|
|
92
|
+
const local = row - (above - b.baseline);
|
|
93
|
+
line += local >= 0 && local < b.lines.length ? b.lines[local] : spaces(b.width);
|
|
94
|
+
}
|
|
95
|
+
lines.push(line);
|
|
96
|
+
}
|
|
97
|
+
return { lines, baseline: above, width };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stack `num` over `den`, separated by a bar; the bar becomes the baseline. */
|
|
101
|
+
function fracBox(num: Box, den: Box): Box {
|
|
102
|
+
const width = Math.max(num.width, den.width) + 2;
|
|
103
|
+
const lines = [
|
|
104
|
+
...num.lines.map(line => center(line, width)),
|
|
105
|
+
BAR.repeat(width),
|
|
106
|
+
...den.lines.map(line => center(line, width)),
|
|
107
|
+
];
|
|
108
|
+
return { lines, baseline: num.lines.length, width };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stack boxes vertically (left-aligned), e.g. the rows of an aligned block. */
|
|
112
|
+
function vconcat(boxes: Box[]): Box {
|
|
113
|
+
if (boxes.length === 1) return boxes[0];
|
|
114
|
+
let width = 0;
|
|
115
|
+
for (const b of boxes) width = Math.max(width, b.width);
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
for (const b of boxes) for (const line of b.lines) lines.push(padRight(line, width));
|
|
118
|
+
return { lines, baseline: (lines.length - 1) >> 1, width };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Span {
|
|
122
|
+
text: string;
|
|
123
|
+
end: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read a balanced `{…}` beginning at `i` (which must point at `{`). */
|
|
127
|
+
function readBraceGroup(src: string, i: number): Span {
|
|
128
|
+
let depth = 0;
|
|
129
|
+
let out = "";
|
|
130
|
+
let j = i;
|
|
131
|
+
for (; j < src.length; j++) {
|
|
132
|
+
const c = src[j];
|
|
133
|
+
if (c === "\\") {
|
|
134
|
+
out += c + (src[j + 1] ?? "");
|
|
135
|
+
j++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === "{") {
|
|
139
|
+
depth++;
|
|
140
|
+
if (depth > 1) out += c;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === "}") {
|
|
144
|
+
depth--;
|
|
145
|
+
if (depth === 0) {
|
|
146
|
+
j++;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
out += c;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
out += c;
|
|
153
|
+
}
|
|
154
|
+
return { text: out, end: j };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read one fraction argument: a `{…}` group, a single char, or a `\command`
|
|
159
|
+
* together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
|
|
160
|
+
* block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
|
|
161
|
+
*/
|
|
162
|
+
function readArg(src: string, i: number): Span {
|
|
163
|
+
while (src[i] === " ") i++;
|
|
164
|
+
if (i >= src.length) return { text: "", end: i };
|
|
165
|
+
if (src[i] === "{") return readBraceGroup(src, i);
|
|
166
|
+
if (src[i] !== "\\") return { text: src[i], end: i + 1 };
|
|
167
|
+
let j = i + 1;
|
|
168
|
+
let name = "";
|
|
169
|
+
while (/[A-Za-z]/.test(src[j] ?? "")) {
|
|
170
|
+
name += src[j];
|
|
171
|
+
j++;
|
|
172
|
+
}
|
|
173
|
+
if (name === "begin") {
|
|
174
|
+
const env = consumeEnvironment(src, i);
|
|
175
|
+
if (env) return env;
|
|
176
|
+
}
|
|
177
|
+
if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
|
|
178
|
+
let end = j;
|
|
179
|
+
while (src[end] === "[" || src[end] === "{") {
|
|
180
|
+
if (src[end] === "{") end = readBraceGroup(src, end).end;
|
|
181
|
+
else {
|
|
182
|
+
const close = src.indexOf("]", end);
|
|
183
|
+
end = close === -1 ? src.length : close + 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { text: src.slice(i, end), end };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface EnvParts {
|
|
190
|
+
env: string;
|
|
191
|
+
bodyStart: number;
|
|
192
|
+
bodyEnd: number;
|
|
193
|
+
end: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Locate a `\begin{env}…\end{env}` block (balanced) starting at the backslash. */
|
|
197
|
+
function readEnvironment(src: string, start: number): EnvParts | null {
|
|
198
|
+
let i = start + 6; // past "\begin"
|
|
199
|
+
while (src[i] === " ") i++;
|
|
200
|
+
if (src[i] !== "{") return null;
|
|
201
|
+
const nameGroup = readBraceGroup(src, i);
|
|
202
|
+
let k = nameGroup.end;
|
|
203
|
+
let depth = 1;
|
|
204
|
+
let bodyEnd = src.length;
|
|
205
|
+
while (k < src.length && depth > 0) {
|
|
206
|
+
if (src.startsWith("\\begin", k)) {
|
|
207
|
+
depth++;
|
|
208
|
+
k += 6;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (src.startsWith("\\end", k)) {
|
|
212
|
+
depth--;
|
|
213
|
+
if (depth === 0) bodyEnd = k;
|
|
214
|
+
k += 4;
|
|
215
|
+
while (src[k] === " ") k++;
|
|
216
|
+
if (src[k] === "{") k = readBraceGroup(src, k).end;
|
|
217
|
+
if (depth === 0) break;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
k++;
|
|
221
|
+
}
|
|
222
|
+
return { env: nameGroup.text.trim(), bodyStart: nameGroup.end, bodyEnd, end: k };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The full `\begin{env}…\end{env}` substring as an inline run. */
|
|
226
|
+
function consumeEnvironment(src: string, start: number): Span | null {
|
|
227
|
+
const env = readEnvironment(src, start);
|
|
228
|
+
return env ? { text: src.slice(start, env.end), end: env.end } : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Split an environment body on top-level `\\` row breaks (depth-aware). */
|
|
232
|
+
function splitRows(body: string): string[] {
|
|
233
|
+
const rows: string[] = [];
|
|
234
|
+
let braceDepth = 0;
|
|
235
|
+
let envDepth = 0;
|
|
236
|
+
let last = 0;
|
|
237
|
+
let i = 0;
|
|
238
|
+
while (i < body.length) {
|
|
239
|
+
if (body.startsWith("\\begin", i)) {
|
|
240
|
+
envDepth++;
|
|
241
|
+
i += 6;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (body.startsWith("\\end", i)) {
|
|
245
|
+
envDepth--;
|
|
246
|
+
i += 4;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const c = body[i];
|
|
250
|
+
if (c === "\\") {
|
|
251
|
+
if (body[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) {
|
|
252
|
+
rows.push(body.slice(last, i));
|
|
253
|
+
i += 2;
|
|
254
|
+
while (body[i] === " ") i++;
|
|
255
|
+
if (body[i] === "[") {
|
|
256
|
+
const close = body.indexOf("]", i);
|
|
257
|
+
i = close === -1 ? body.length : close + 1;
|
|
258
|
+
}
|
|
259
|
+
last = i;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
i += 2; // skip escaped char / second backslash so `\{`/`\\` never skew depth
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (c === "{") braceDepth++;
|
|
266
|
+
else if (c === "}") braceDepth--;
|
|
267
|
+
i++;
|
|
268
|
+
}
|
|
269
|
+
rows.push(body.slice(last));
|
|
270
|
+
return rows;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Render a `\begin{env}…\end{env}` block. Expression "wrapper" environments
|
|
275
|
+
* (`equation`, `align`, `gather`, …) have their rows parsed so fractions stack;
|
|
276
|
+
* grid/structure environments (matrix/array/cases) render flat via
|
|
277
|
+
* `latexToUnicode`.
|
|
278
|
+
*/
|
|
279
|
+
function parseEnvironment(src: string, start: number): { box: Box; end: number } | null {
|
|
280
|
+
const env = readEnvironment(src, start);
|
|
281
|
+
if (env === null) return null;
|
|
282
|
+
const base = env.env.endsWith("*") ? env.env.slice(0, -1) : env.env;
|
|
283
|
+
if (!DISPLAY_ROW_ENVIRONMENTS[base]) {
|
|
284
|
+
return { box: textBox(latexToUnicode(src.slice(start, env.end))), end: env.end };
|
|
285
|
+
}
|
|
286
|
+
let bodyStart = env.bodyStart;
|
|
287
|
+
if (base === "alignat" || base === "alignedat" || base === "gatheredat") {
|
|
288
|
+
// These carry a required column-count argument `{n}` before the body.
|
|
289
|
+
let p = bodyStart;
|
|
290
|
+
while (src[p] === " " || src[p] === "\n") p++;
|
|
291
|
+
if (src[p] === "{") bodyStart = readBraceGroup(src, p).end;
|
|
292
|
+
}
|
|
293
|
+
const rows = splitRows(src.slice(bodyStart, env.bodyEnd))
|
|
294
|
+
.map(row => row.trim())
|
|
295
|
+
.filter(row => row !== "")
|
|
296
|
+
.map(row => parseExpr(row));
|
|
297
|
+
return { box: rows.length > 0 ? vconcat(rows) : textBox(""), end: env.end };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Append a script (`^`/`_`) and its argument to the inline run verbatim. */
|
|
301
|
+
function readScript(src: string, i: number): Span {
|
|
302
|
+
let out = src[i];
|
|
303
|
+
i++;
|
|
304
|
+
while (src[i] === " ") {
|
|
305
|
+
out += src[i];
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
if (src[i] === "{") {
|
|
309
|
+
const group = readBraceGroup(src, i);
|
|
310
|
+
return { text: `${out}{${group.text}}`, end: group.end };
|
|
311
|
+
}
|
|
312
|
+
if (src[i] === "\\") {
|
|
313
|
+
let j = i + 1;
|
|
314
|
+
if (/[A-Za-z]/.test(src[j] ?? "")) while (/[A-Za-z]/.test(src[j] ?? "")) j++;
|
|
315
|
+
else j++;
|
|
316
|
+
return { text: out + src.slice(i, j), end: j };
|
|
317
|
+
}
|
|
318
|
+
if (i < src.length) return { text: out + src[i], end: i + 1 };
|
|
319
|
+
return { text: out, end: i };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse a math fragment into a layout box, stacking top-level fractions (and
|
|
324
|
+
* fractions nested inside other fractions' arguments). Non-fraction runs —
|
|
325
|
+
* including scripts, roots, environments, and command arguments — are gathered
|
|
326
|
+
* into inline strings and rendered through `latexToUnicode`.
|
|
327
|
+
*/
|
|
328
|
+
function parseExpr(src: string): Box {
|
|
329
|
+
const boxes: Box[] = [];
|
|
330
|
+
let inline = "";
|
|
331
|
+
const flush = (): void => {
|
|
332
|
+
if (inline) {
|
|
333
|
+
boxes.push(textBox(latexToUnicode(inline)));
|
|
334
|
+
inline = "";
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
let i = 0;
|
|
338
|
+
while (i < src.length) {
|
|
339
|
+
const c = src[i];
|
|
340
|
+
if (c === "\\") {
|
|
341
|
+
let j = i + 1;
|
|
342
|
+
let name = "";
|
|
343
|
+
while (j < src.length && /[A-Za-z]/.test(src[j])) {
|
|
344
|
+
name += src[j];
|
|
345
|
+
j++;
|
|
346
|
+
}
|
|
347
|
+
if (name && FRAC_COMMANDS[name]) {
|
|
348
|
+
flush();
|
|
349
|
+
const num = readArg(src, j);
|
|
350
|
+
const den = readArg(src, num.end);
|
|
351
|
+
boxes.push(fracBox(parseExpr(num.text), parseExpr(den.text)));
|
|
352
|
+
i = den.end;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (name === "begin") {
|
|
356
|
+
const env = parseEnvironment(src, i);
|
|
357
|
+
if (env) {
|
|
358
|
+
flush();
|
|
359
|
+
boxes.push(env.box);
|
|
360
|
+
i = env.end;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!name) {
|
|
365
|
+
// Non-letter command (`\\`, `\,`, `\{`, …): keep the 2-char token inline.
|
|
366
|
+
inline += `\\${src[j] ?? ""}`;
|
|
367
|
+
i = j + 1;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
// Other command: keep it and its bracket/brace arguments inline so a
|
|
371
|
+
// `{…}` argument is never mistaken for a top-level stacking group.
|
|
372
|
+
inline += `\\${name}`;
|
|
373
|
+
i = j;
|
|
374
|
+
while (src[i] === "[" || src[i] === "{") {
|
|
375
|
+
if (src[i] === "{") {
|
|
376
|
+
const group = readBraceGroup(src, i);
|
|
377
|
+
inline += `{${group.text}}`;
|
|
378
|
+
i = group.end;
|
|
379
|
+
} else {
|
|
380
|
+
const close = src.indexOf("]", i);
|
|
381
|
+
const end = close === -1 ? src.length : close + 1;
|
|
382
|
+
inline += src.slice(i, end);
|
|
383
|
+
i = end;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (c === "^" || c === "_") {
|
|
389
|
+
const script = readScript(src, i);
|
|
390
|
+
inline += script.text;
|
|
391
|
+
i = script.end;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (c === "{") {
|
|
395
|
+
const group = readBraceGroup(src, i);
|
|
396
|
+
flush();
|
|
397
|
+
boxes.push(parseExpr(group.text));
|
|
398
|
+
i = group.end;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
inline += c;
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
flush();
|
|
405
|
+
if (boxes.length === 0) return textBox("");
|
|
406
|
+
return hconcat(boxes);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Split on top-level `\n` row separators (outside braces and environments). */
|
|
410
|
+
function splitLines(src: string): string[] {
|
|
411
|
+
const lines: string[] = [];
|
|
412
|
+
let braceDepth = 0;
|
|
413
|
+
let envDepth = 0;
|
|
414
|
+
let last = 0;
|
|
415
|
+
let i = 0;
|
|
416
|
+
while (i < src.length) {
|
|
417
|
+
if (src.startsWith("\\begin", i)) {
|
|
418
|
+
envDepth++;
|
|
419
|
+
i += 6;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (src.startsWith("\\end", i)) {
|
|
423
|
+
envDepth--;
|
|
424
|
+
i += 4;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const c = src[i];
|
|
428
|
+
if (c === "\\") {
|
|
429
|
+
i += 2; // escaped char / second backslash — never a logical-line break
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (c === "{") braceDepth++;
|
|
433
|
+
else if (c === "}") braceDepth--;
|
|
434
|
+
else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
|
|
435
|
+
lines.push(src.slice(last, i));
|
|
436
|
+
last = i + 1;
|
|
437
|
+
}
|
|
438
|
+
i++;
|
|
439
|
+
}
|
|
440
|
+
lines.push(src.slice(last));
|
|
441
|
+
return lines;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Render a display LaTeX math fragment to lines, stacking `\frac` vertically.
|
|
446
|
+
* Top-level source newlines become vertical rows (so a `lhs =` line stays above
|
|
447
|
+
* its block); each row stacks fractions via `parseExpr`. Inline math should use
|
|
448
|
+
* `latexToUnicode` instead — fractions there stay single-line.
|
|
449
|
+
*/
|
|
450
|
+
export function latexToBlock(src: string): string[] {
|
|
451
|
+
if (typeof src !== "string" || src.trim() === "") return [];
|
|
452
|
+
const rows = splitLines(src.trim())
|
|
453
|
+
.map(line => line.trim())
|
|
454
|
+
.filter(line => line !== "")
|
|
455
|
+
.map(line => parseExpr(line));
|
|
456
|
+
if (rows.length === 0) return [];
|
|
457
|
+
let lines = vconcat(rows).lines;
|
|
458
|
+
while (lines.length > 1 && lines[lines.length - 1].trim() === "") lines = lines.slice(0, -1);
|
|
459
|
+
while (lines.length > 1 && lines[0].trim() === "") lines = lines.slice(1);
|
|
460
|
+
return lines;
|
|
461
|
+
}
|