@oh-my-pi/pi-tui 18.0.7 → 18.0.9

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/components/box.d.ts +2 -0
  3. package/dist/types/components/cancellable-loader.d.ts +2 -0
  4. package/dist/types/components/editor.d.ts +4 -0
  5. package/dist/types/components/image.d.ts +2 -0
  6. package/dist/types/components/input.d.ts +2 -0
  7. package/dist/types/components/loader.d.ts +2 -0
  8. package/dist/types/components/markdown.d.ts +2 -0
  9. package/dist/types/components/scroll-view.d.ts +2 -0
  10. package/dist/types/components/select-list.d.ts +2 -0
  11. package/dist/types/components/settings-list.d.ts +4 -0
  12. package/dist/types/components/spacer.d.ts +2 -0
  13. package/dist/types/components/tab-bar.d.ts +2 -0
  14. package/dist/types/components/text.d.ts +2 -0
  15. package/dist/types/components/truncated-text.d.ts +2 -0
  16. package/dist/types/debug-server.d.ts +27 -0
  17. package/dist/types/index.d.ts +1 -0
  18. package/dist/types/latex-to-unicode.d.ts +4 -4
  19. package/dist/types/terminal.d.ts +11 -0
  20. package/dist/types/tui.d.ts +30 -0
  21. package/package.json +3 -3
  22. package/src/components/box.ts +10 -0
  23. package/src/components/cancellable-loader.ts +7 -0
  24. package/src/components/editor.ts +28 -0
  25. package/src/components/image.ts +11 -0
  26. package/src/components/input.ts +13 -0
  27. package/src/components/loader.ts +21 -5
  28. package/src/components/markdown.ts +39 -10
  29. package/src/components/scroll-view.ts +12 -0
  30. package/src/components/select-list.ts +13 -0
  31. package/src/components/settings-list.ts +19 -0
  32. package/src/components/spacer.ts +4 -0
  33. package/src/components/tab-bar.ts +11 -0
  34. package/src/components/text.ts +11 -0
  35. package/src/components/truncated-text.ts +15 -1
  36. package/src/debug-server.ts +422 -0
  37. package/src/index.ts +2 -0
  38. package/src/latex-to-unicode.ts +401 -172
  39. package/src/terminal.ts +22 -2
  40. package/src/tui.ts +115 -3
  41. package/src/utils.ts +10 -1
@@ -313,29 +313,66 @@ const FONTS: Record<string, FontStyle> = {
313
313
  mathtt: "mono",
314
314
  mathit: "italic",
315
315
  mathbfit: "bolditalic",
316
- textbf: "bold",
317
- textit: "italic",
318
- texttt: "mono",
319
- textsf: "sans",
320
316
  };
317
+
318
+ interface ParseStyle {
319
+ font: FontStyle | null;
320
+ bold: boolean;
321
+ italic: boolean;
322
+ }
323
+
324
+ interface RenderStyle {
325
+ bold: boolean;
326
+ italic: boolean;
327
+ foreground: string | null;
328
+ background: string | null;
329
+ }
330
+
331
+ type StyleAttribute = "bold" | "italic";
332
+
333
+ type RenderEvent =
334
+ | { kind: "scope-start"; attribute: StyleAttribute }
335
+ | { kind: "scope-end"; attribute: StyleAttribute; restore: boolean };
336
+
337
+ interface RenderChunk {
338
+ text: string;
339
+ style: RenderStyle;
340
+ before?: RenderEvent[];
341
+ after?: RenderEvent[];
342
+ }
343
+
344
+ type Rendered = RenderChunk[];
345
+
346
+ const ROOT_STYLE: ParseStyle = { font: null, bold: false, italic: false };
347
+
348
+ const TEXT_STYLE_COMMANDS: Readonly<Record<string, Partial<Pick<ParseStyle, "bold" | "italic">>>> = {
349
+ textbf: { bold: true },
350
+ textit: { italic: true },
351
+ textsl: { italic: true },
352
+ emph: { italic: true },
353
+ textmd: { bold: false },
354
+ textup: { italic: false },
355
+ texttt: {},
356
+ textsf: {},
357
+ };
358
+
321
359
  /**
322
- * Math font command names (`\mathbf`, `\mathbb`, …) whose single brace argument
323
- * restyles glyphs. Exported for the display block engine (`latex-block`), which
324
- * re-wraps inline runs inside these commands when their argument contains 2-D
325
- * layout (fractions, matrices) so styling survives box boundaries.
360
+ * Math font and text-style command names whose single brace argument
361
+ * is rendered with the corresponding style. Exported for the display block
362
+ * engine, which re-wraps inline runs inside these commands when their argument
363
+ * contains 2-D layout so styling survives box boundaries.
326
364
  */
327
- export const MATH_FONT_COMMANDS: ReadonlySet<string> = new Set(Object.keys(FONTS));
365
+ export const MATH_FONT_COMMANDS: ReadonlySet<string> = new Set([
366
+ ...Object.keys(FONTS),
367
+ ...Object.keys(TEXT_STYLE_COMMANDS),
368
+ ]);
328
369
 
329
370
  // Text-mode commands whose argument is passed through literally (no math).
330
371
  const TEXT_COMMANDS: Record<string, true> = {
331
372
  text: true,
332
373
  textrm: true,
333
374
  textnormal: true,
334
- textup: true,
335
- textmd: true,
336
375
  textsc: true,
337
- textsl: true,
338
- emph: true,
339
376
  mathrm: true,
340
377
  mathnormal: true,
341
378
  mbox: true,
@@ -842,22 +879,84 @@ const SYMBOLS: Record<string, string> = {
842
879
  // Helpers
843
880
  // ---------------------------------------------------------------------------
844
881
 
882
+ const ANSI_FG_RESET = "\x1b[39m";
883
+ const ANSI_BG_RESET = "\x1b[49m";
884
+ const ANSI_BOLD_ON = "\x1b[1m";
885
+ const ANSI_BOLD_OFF = "\x1b[22m";
886
+ const ANSI_ITALIC_ON = "\x1b[3m";
887
+ const ANSI_ITALIC_OFF = "\x1b[23m";
888
+
889
+ const DEFAULT_RENDER_STYLE: RenderStyle = {
890
+ bold: false,
891
+ italic: false,
892
+ foreground: null,
893
+ background: null,
894
+ };
895
+
896
+ function renderStyle(style: ParseStyle, foreground: string | null, background: string | null): RenderStyle {
897
+ return { bold: style.bold, italic: style.italic, foreground, background };
898
+ }
899
+
900
+ function sameRenderStyle(a: RenderStyle, b: RenderStyle): boolean {
901
+ return a.bold === b.bold && a.italic === b.italic && a.foreground === b.foreground && a.background === b.background;
902
+ }
903
+
904
+ function appendRendered(target: Rendered, source: Rendered): void {
905
+ for (const chunk of source) {
906
+ if (chunk.text === "") continue;
907
+ const previous = target[target.length - 1];
908
+ if (
909
+ previous !== undefined &&
910
+ previous.after === undefined &&
911
+ chunk.before === undefined &&
912
+ chunk.after === undefined &&
913
+ sameRenderStyle(previous.style, chunk.style)
914
+ ) {
915
+ previous.text += chunk.text;
916
+ } else {
917
+ target.push({ ...chunk });
918
+ }
919
+ }
920
+ }
921
+
922
+ function concatRendered(...parts: Rendered[]): Rendered {
923
+ const out: Rendered = [];
924
+ for (const part of parts) appendRendered(out, part);
925
+ return out;
926
+ }
927
+
928
+ function styledText(text: string, style: RenderStyle): Rendered {
929
+ return text === "" ? [] : [{ text, style }];
930
+ }
931
+
932
+ function plainText(rendered: Rendered): string {
933
+ let text = "";
934
+ for (const chunk of rendered) text += chunk.text;
935
+ return text;
936
+ }
937
+
845
938
  /** Map every code point of `text` through `table`; null if any is unmappable. */
846
- function mapAll(text: string, table: Record<string, string>): string | null {
847
- let out = "";
848
- for (const ch of text) {
849
- const mapped = table[ch];
850
- if (mapped === undefined) return null;
851
- out += mapped;
939
+ function mapAll(text: Rendered, table: Record<string, string>): Rendered | null {
940
+ const out: Rendered = [];
941
+ for (const chunk of text) {
942
+ let mapped = "";
943
+ for (const ch of chunk.text) {
944
+ const replacement = table[ch];
945
+ if (replacement === undefined) return null;
946
+ mapped += replacement;
947
+ }
948
+ appendRendered(out, [{ ...chunk, text: mapped }]);
852
949
  }
853
950
  return out;
854
951
  }
855
952
 
856
- /** Number of Unicode code points (not UTF-16 units) in `s`. */
857
- function codePointLength(s: string): number {
858
- let n = 0;
859
- for (const _ of s) n++;
860
- return n;
953
+ /** Number of Unicode code points (not UTF-16 units) in rendered glyph text. */
954
+ function codePointLength(text: Rendered): number {
955
+ let length = 0;
956
+ for (const chunk of text) {
957
+ for (const _ of chunk.text) length++;
958
+ }
959
+ return length;
861
960
  }
862
961
 
863
962
  /** Style a single ASCII letter/digit via the math alphanumeric block. */
@@ -873,28 +972,121 @@ function styleAlnum(ch: string, style: FontStyle): string {
873
972
  }
874
973
 
875
974
  /** Identity, or math-alphanumeric styling when a font style is active. */
876
- function styleChar(ch: string, style: FontStyle | null): string {
877
- if (style === null) return ch;
975
+ function styleChar(ch: string, style: ParseStyle): string {
976
+ if (style.font === null) return ch;
878
977
  const code = ch.charCodeAt(0);
879
978
  const isAlnum = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57);
880
- return isAlnum ? styleAlnum(ch, style) : ch;
979
+ return isAlnum ? styleAlnum(ch, style.font) : ch;
980
+ }
981
+ function surroundRendered(text: Rendered, left: string, right: string, fallback: RenderStyle): Rendered {
982
+ return concatRendered(styledText(left, fallback), text, styledText(right, fallback));
983
+ }
984
+ function trimRendered(text: Rendered): Rendered {
985
+ const out = text.map(chunk => ({ ...chunk }));
986
+ while (out.length > 0) {
987
+ const first = out[0];
988
+ if (first === undefined) break;
989
+ first.text = first.text.trimStart();
990
+ if (first.text !== "") break;
991
+ out.shift();
992
+ }
993
+ while (out.length > 0) {
994
+ const lastIndex = out.length - 1;
995
+ const last = out[lastIndex];
996
+ if (last === undefined) break;
997
+ last.text = last.text.trimEnd();
998
+ if (last.text !== "") break;
999
+ out.pop();
1000
+ }
1001
+ return out;
1002
+ }
1003
+ function scopeRendered(text: Rendered, attribute: StyleAttribute, parentActive: boolean): Rendered {
1004
+ if (!parentActive || text.length === 0) return text;
1005
+ const out = text.map(chunk => ({ ...chunk }));
1006
+ const first = out[0];
1007
+ const last = out[out.length - 1];
1008
+ if (first?.style[attribute]) {
1009
+ first.before = [...(first.before ?? []), { kind: "scope-start", attribute }];
1010
+ }
1011
+ if (last?.style[attribute]) {
1012
+ last.after = [...(last.after ?? []), { kind: "scope-end", attribute, restore: true }];
1013
+ }
1014
+ return out;
881
1015
  }
882
1016
 
883
- /** Append a combining mark after each non-space base glyph (accents/radicals). */
884
- function applyCombining(text: string, mark: string): string {
885
- let out = "";
886
- for (const ch of text) out += ch === " " ? ch : ch + mark;
1017
+ /** Append a combining mark after each non-space base glyph. */
1018
+ function applyCombining(text: Rendered, mark: string): Rendered {
1019
+ const out: Rendered = [];
1020
+ for (const chunk of text) {
1021
+ let mapped = "";
1022
+ for (const ch of chunk.text) mapped += ch === " " ? ch : ch + mark;
1023
+ appendRendered(out, [{ ...chunk, text: mapped }]);
1024
+ }
887
1025
  return out;
888
1026
  }
889
1027
 
1028
+ function emitStyleTransition(out: string[], from: RenderStyle, to: RenderStyle): void {
1029
+ if (from.bold && !to.bold) out.push(ANSI_BOLD_OFF);
1030
+ if (from.italic && !to.italic) out.push(ANSI_ITALIC_OFF);
1031
+ const foregroundChanged = from.foreground !== to.foreground;
1032
+ const backgroundChanged = from.background !== to.background;
1033
+ if (foregroundChanged && backgroundChanged) {
1034
+ if (to.background === null) {
1035
+ out.push(to.background ?? ANSI_BG_RESET);
1036
+ out.push(to.foreground ?? ANSI_FG_RESET);
1037
+ } else {
1038
+ out.push(to.foreground ?? ANSI_FG_RESET);
1039
+ out.push(to.background ?? ANSI_BG_RESET);
1040
+ }
1041
+ } else {
1042
+ if (foregroundChanged) out.push(to.foreground ?? ANSI_FG_RESET);
1043
+ if (backgroundChanged) out.push(to.background ?? ANSI_BG_RESET);
1044
+ }
1045
+ if (!from.bold && to.bold) out.push(ANSI_BOLD_ON);
1046
+ if (!from.italic && to.italic) out.push(ANSI_ITALIC_ON);
1047
+ }
1048
+
1049
+ function emitStyleEvent(out: string[], event: RenderEvent, active: RenderStyle): RenderStyle {
1050
+ if (event.kind === "scope-start") {
1051
+ out.push(event.attribute === "bold" ? ANSI_BOLD_ON : ANSI_ITALIC_ON);
1052
+ return { ...active, [event.attribute]: true };
1053
+ }
1054
+ if (active[event.attribute]) out.push(event.attribute === "bold" ? ANSI_BOLD_OFF : ANSI_ITALIC_OFF);
1055
+ if (event.restore) out.push(event.attribute === "bold" ? ANSI_BOLD_ON : ANSI_ITALIC_ON);
1056
+ return { ...active, [event.attribute]: event.restore };
1057
+ }
1058
+
1059
+ /** Materialize terminal attributes only after all glyph transforms are complete. */
1060
+ function renderAnsi(text: Rendered): string {
1061
+ let out = "";
1062
+ let active = DEFAULT_RENDER_STYLE;
1063
+ for (const chunk of text) {
1064
+ const transitions: string[] = [];
1065
+ emitStyleTransition(transitions, active, chunk.style);
1066
+ out += transitions.join("");
1067
+ active = chunk.style;
1068
+ for (const event of chunk.before ?? []) {
1069
+ const events: string[] = [];
1070
+ active = emitStyleEvent(events, event, active);
1071
+ out += events.join("");
1072
+ }
1073
+ out += chunk.text;
1074
+ for (const event of chunk.after ?? []) {
1075
+ const events: string[] = [];
1076
+ active = emitStyleEvent(events, event, active);
1077
+ out += events.join("");
1078
+ }
1079
+ }
1080
+ const transitions: string[] = [];
1081
+ emitStyleTransition(transitions, active, DEFAULT_RENDER_STYLE);
1082
+ return out + transitions.join("");
1083
+ }
1084
+
890
1085
  /** Light unescape for text-mode content (`\&` → `&`, `~` → space). */
891
1086
  function unescapeText(s: string): string {
892
1087
  return s.replace(/\\([&%$#_{}\s])/g, "$1").replace(/~/g, " ");
893
1088
  }
894
1089
 
895
- const ANSI_FG_RESET = "\x1b[39m";
896
- const ANSI_BG_RESET = "\x1b[49m";
897
-
898
1090
  type AnsiColorFormat = "ansi-16m" | "ansi-256";
899
1091
 
900
1092
  interface AnsiColor {
@@ -1160,30 +1352,18 @@ export function latexColorScope(model: string | null, spec: string): ((text: str
1160
1352
  return text => foreground + text.replaceAll(ANSI_FG_RESET, foreground) + ANSI_FG_RESET;
1161
1353
  }
1162
1354
 
1163
- function restoreAnsi(
1164
- text: string,
1165
- fromForeground: string | null,
1166
- toForeground: string | null,
1167
- fromBackground: string | null,
1168
- toBackground: string | null,
1169
- ): string {
1170
- if (fromForeground !== toForeground && fromForeground !== null) text += toForeground ?? ANSI_FG_RESET;
1171
- if (fromBackground !== toBackground && fromBackground !== null) text += toBackground ?? ANSI_BG_RESET;
1172
- return text;
1173
- }
1174
-
1175
- function toSuperscript(text: string, group: boolean): string {
1176
- if (text === "") return "";
1355
+ function toSuperscript(text: Rendered, group: boolean, fallback: RenderStyle): Rendered {
1356
+ if (text.length === 0) return [];
1177
1357
  const mapped = mapAll(text, SUPERSCRIPT);
1178
1358
  if (mapped !== null) return mapped;
1179
- return group ? `^(${text})` : `^${text}`;
1359
+ return concatRendered(styledText(group ? "^(" : "^", fallback), text, styledText(group ? ")" : "", fallback));
1180
1360
  }
1181
1361
 
1182
- function toSubscript(text: string, group: boolean): string {
1183
- if (text === "") return "";
1362
+ function toSubscript(text: Rendered, group: boolean, fallback: RenderStyle): Rendered {
1363
+ if (text.length === 0) return [];
1184
1364
  const mapped = mapAll(text, SUBSCRIPT);
1185
1365
  if (mapped !== null) return mapped;
1186
- return group ? `_(${text})` : `_${text}`;
1366
+ return concatRendered(styledText(group ? "_(" : "_", fallback), text, styledText(group ? ")" : "", fallback));
1187
1367
  }
1188
1368
 
1189
1369
  // ---------------------------------------------------------------------------
@@ -1191,7 +1371,7 @@ function toSubscript(text: string, group: boolean): string {
1191
1371
  // ---------------------------------------------------------------------------
1192
1372
 
1193
1373
  interface Argument {
1194
- text: string;
1374
+ text: Rendered;
1195
1375
  /** True when the argument came from a `{…}` group (affects fraction/script parens). */
1196
1376
  group: boolean;
1197
1377
  }
@@ -1229,12 +1409,12 @@ class LatexParser {
1229
1409
  }
1230
1410
 
1231
1411
  render(): string {
1232
- return restoreAnsi(this.parse(null, false), this.#foreground, null, this.#background, null);
1412
+ return renderAnsi(this.parse(ROOT_STYLE, false));
1233
1413
  }
1234
1414
 
1235
1415
  /** Parse a run until end-of-input, or until `}` when `stopAtBrace`. */
1236
- parse(style: FontStyle | null, stopAtBrace: boolean): string {
1237
- let out = "";
1416
+ parse(style: ParseStyle, stopAtBrace: boolean): Rendered {
1417
+ const out: Rendered = [];
1238
1418
  while (this.#i < this.#s.length) {
1239
1419
  const c = this.#s[this.#i];
1240
1420
  if (c === "}") {
@@ -1242,12 +1422,16 @@ class LatexParser {
1242
1422
  this.#i++; // stray close brace
1243
1423
  continue;
1244
1424
  }
1245
- out += this.#node(style);
1425
+ appendRendered(out, this.#node(style));
1246
1426
  }
1247
1427
  return out;
1248
1428
  }
1249
1429
 
1250
- #node(style: FontStyle | null): string {
1430
+ #renderStyle(style: ParseStyle): RenderStyle {
1431
+ return renderStyle(style, this.#foreground, this.#background);
1432
+ }
1433
+
1434
+ #node(style: ParseStyle): Rendered {
1251
1435
  const c = this.#s[this.#i];
1252
1436
  switch (c) {
1253
1437
  case "\\":
@@ -1262,41 +1446,41 @@ class LatexParser {
1262
1446
  return this.#script(style, false);
1263
1447
  case "$":
1264
1448
  this.#i++;
1265
- return ""; // stray delimiter
1449
+ return []; // stray delimiter
1266
1450
  case "~":
1267
1451
  this.#i++;
1268
- return " "; // non-breaking space
1452
+ return styledText(" ", this.#renderStyle(style)); // non-breaking space
1269
1453
  case "&":
1270
1454
  this.#i++;
1271
- return " "; // column separator
1455
+ return styledText(" ", this.#renderStyle(style)); // column separator
1272
1456
  case "'": {
1273
1457
  let k = 0;
1274
1458
  while (this.#s[this.#i] === "'") {
1275
1459
  k++;
1276
1460
  this.#i++;
1277
1461
  }
1278
- return k <= 4 ? PRIMES[k] : PRIMES[1].repeat(k);
1462
+ return styledText(k <= 4 ? PRIMES[k] : PRIMES[1].repeat(k), this.#renderStyle(style));
1279
1463
  }
1280
1464
  case "%": {
1281
1465
  const nl = this.#s.indexOf("\n", this.#i);
1282
1466
  this.#i = nl === -1 ? this.#s.length : nl + 1;
1283
- return "";
1467
+ return [];
1284
1468
  }
1285
1469
  default:
1286
1470
  this.#i++;
1287
- return styleChar(c, style);
1471
+ return styledText(styleChar(c, style), this.#renderStyle(style));
1288
1472
  }
1289
1473
  }
1290
1474
 
1291
- #command(style: FontStyle | null): string {
1475
+ #command(style: ParseStyle): Rendered {
1292
1476
  this.#i++; // past backslash
1293
- if (this.#i >= this.#s.length) return "";
1477
+ if (this.#i >= this.#s.length) return [];
1294
1478
  const c = this.#s[this.#i];
1295
1479
  if (!/[A-Za-z]/.test(c)) {
1296
1480
  this.#i++;
1297
1481
  switch (c) {
1298
1482
  case "\\":
1299
- return "\n"; // row break
1483
+ return styledText("\n", this.#renderStyle(style)); // row break
1300
1484
  case "{":
1301
1485
  case "}":
1302
1486
  case "$":
@@ -1306,25 +1490,25 @@ class LatexParser {
1306
1490
  case "_":
1307
1491
  case " ":
1308
1492
  case ".":
1309
- return c;
1493
+ return styledText(c, this.#renderStyle(style));
1310
1494
  case ",":
1311
1495
  case ":":
1312
1496
  case ";":
1313
1497
  case ">":
1314
- return " "; // spacing
1498
+ return styledText(" ", this.#renderStyle(style)); // spacing
1315
1499
  case "!":
1316
- return ""; // negative thin space
1500
+ return []; // negative thin space
1317
1501
  case "/":
1318
- return ""; // italic correction
1502
+ return []; // italic correction
1319
1503
  case "|":
1320
- return "‖";
1504
+ return styledText("‖", this.#renderStyle(style));
1321
1505
  case "(":
1322
1506
  case ")":
1323
1507
  case "[":
1324
1508
  case "]":
1325
- return ""; // bare math delimiters that slipped through
1509
+ return []; // bare math delimiters that slipped through
1326
1510
  default:
1327
- return c;
1511
+ return styledText(c, this.#renderStyle(style));
1328
1512
  }
1329
1513
  }
1330
1514
  let name = "";
@@ -1336,26 +1520,32 @@ class LatexParser {
1336
1520
  return this.#applyCommand(name, style);
1337
1521
  }
1338
1522
 
1339
- #applyCommand(name: string, style: FontStyle | null): string {
1340
- // Fonts: reparse the argument under the requested style.
1523
+ #applyCommand(name: string, style: ParseStyle): Rendered {
1524
+ const current = this.#renderStyle(style);
1341
1525
  const font = FONTS[name];
1342
- if (font) return this.#argument(font).text;
1526
+ if (font) return this.#argument({ ...style, font }).text;
1343
1527
 
1344
- if (TEXT_COMMANDS[name]) return unescapeText(this.#rawArgument());
1528
+ const textStyle = TEXT_STYLE_COMMANDS[name];
1529
+ if (textStyle !== undefined) {
1530
+ const text = this.#argument({ ...style, ...textStyle }).text;
1531
+ const attribute = textStyle.bold === true ? "bold" : textStyle.italic === true ? "italic" : null;
1532
+ return attribute === null ? text : scopeRendered(text, attribute, style[attribute]);
1533
+ }
1345
1534
 
1535
+ if (TEXT_COMMANDS[name]) return styledText(unescapeText(this.#rawArgument()), current);
1346
1536
  if (name === "operatorname") {
1347
1537
  const fn = unescapeText(this.#rawArgument());
1348
- return fn + this.#spaceBeforeArg();
1538
+ return styledText(fn + this.#spaceBeforeArg(), current);
1349
1539
  }
1350
1540
 
1351
- // Accents → combining marks over each glyph.
1541
+ // Accents operate on glyph chunks, never on terminal escape sequences.
1352
1542
  const accent = ACCENTS[name];
1353
1543
  if (accent) return applyCombining(this.#argument(style).text, accent);
1354
1544
 
1355
1545
  if (name === "frac" || name === "dfrac" || name === "tfrac" || name === "cfrac") {
1356
1546
  const num = this.#argument(style);
1357
1547
  const den = this.#argument(style);
1358
- return this.#fraction(num, den);
1548
+ return this.#fraction(num, den, current);
1359
1549
  }
1360
1550
 
1361
1551
  if (name === "genfrac") {
@@ -1365,20 +1555,29 @@ class LatexParser {
1365
1555
  this.#rawArgument(); // math style
1366
1556
  const num = this.#argument(style);
1367
1557
  const den = this.#argument(style);
1368
- return left + this.#fraction(num, den) + right;
1558
+ return concatRendered(left, this.#fraction(num, den, current), right);
1369
1559
  }
1370
1560
 
1371
1561
  if (name === "binom" || name === "dbinom" || name === "tbinom") {
1372
1562
  const n = this.#argument(style);
1373
1563
  const k = this.#argument(style);
1374
- return `C(${n.text}, ${k.text})`;
1564
+ return concatRendered(
1565
+ styledText("C(", current),
1566
+ n.text,
1567
+ styledText(", ", current),
1568
+ k.text,
1569
+ styledText(")", current),
1570
+ );
1375
1571
  }
1376
1572
 
1377
1573
  if (name === "sqrt") return this.#sqrt(style);
1378
1574
 
1379
1575
  if (name === "not") {
1380
1576
  const arg = this.#argument(style);
1381
- return NOT_MAP[arg.text] ?? applyCombining(arg.text, "\u0338");
1577
+ const mapped = NOT_MAP[plainText(arg.text)];
1578
+ return mapped === undefined
1579
+ ? applyCombining(arg.text, "\u0338")
1580
+ : styledText(mapped, arg.text[0]?.style ?? current);
1382
1581
  }
1383
1582
 
1384
1583
  if (name === "overset" || name === "stackrel") return this.#scriptedAbove(style);
@@ -1388,18 +1587,21 @@ class LatexParser {
1388
1587
  const arrow = EXTENSIBLE_ARROWS[name];
1389
1588
  if (arrow !== undefined) return this.#extensibleArrow(style, arrow);
1390
1589
 
1391
- if (name === "boxed" || name === "fbox") return `[${this.#argument(style).text}]`;
1392
- if (name === "overbrace") return `⏞(${this.#argument(style).text})`;
1393
- if (name === "underbrace") return `⏟(${this.#argument(style).text})`;
1394
- if (name === "overbracket") return `⎴(${this.#argument(style).text})`;
1395
- if (name === "underbracket") return `⎵(${this.#argument(style).text})`;
1396
- if (name === "overparen") return `⏜(${this.#argument(style).text})`;
1397
- if (name === "underparen") return `⏝(${this.#argument(style).text})`;
1590
+ if (name === "boxed" || name === "fbox") return surroundRendered(this.#argument(style).text, "[", "]", current);
1591
+ if (name === "overbrace") return surroundRendered(this.#argument(style).text, "⏞(", ")", current);
1592
+ if (name === "underbrace") return surroundRendered(this.#argument(style).text, "⏟(", ")", current);
1593
+ if (name === "overbracket") return surroundRendered(this.#argument(style).text, "⎴(", ")", current);
1594
+ if (name === "underbracket") return surroundRendered(this.#argument(style).text, "⎵(", ")", current);
1595
+ if (name === "overparen") return surroundRendered(this.#argument(style).text, "⏜(", ")", current);
1596
+ if (name === "underparen") return surroundRendered(this.#argument(style).text, "⏝(", ")", current);
1398
1597
  if (name === "cancel") return applyCombining(this.#argument(style).text, "\u0338");
1399
1598
  if (name === "bcancel") return applyCombining(this.#argument(style).text, "\u20E5");
1400
1599
  if (name === "xcancel") return applyCombining(applyCombining(this.#argument(style).text, "\u0338"), "\u20E5");
1401
1600
  if (name === "sout") return applyCombining(this.#argument(style).text, "\u0336");
1402
- if (name === "substack") return this.#argument(style).text.replace(NEWLINES, ",");
1601
+ if (name === "substack") {
1602
+ const arg = this.#argument(style).text;
1603
+ return arg.map(chunk => ({ ...chunk, text: chunk.text.replace(NEWLINES, ",") }));
1604
+ }
1403
1605
 
1404
1606
  if (name === "left" || name === "right" || name === "middle") return this.#delimiter(style);
1405
1607
 
@@ -1408,19 +1610,19 @@ class LatexParser {
1408
1610
  if (name === "begin") return this.#environment(style);
1409
1611
  if (name === "end") {
1410
1612
  this.#rawArgument();
1411
- return "";
1613
+ return [];
1412
1614
  }
1413
1615
 
1414
- if (name === "bmod") return " mod ";
1415
- if (name === "pmod") return `(mod ${this.#argument(style).text})`;
1416
- if (name === "pod") return `(${this.#argument(style).text})`;
1417
- if (name === "tag") return `(${this.#argument(style).text})`;
1616
+ if (name === "bmod") return styledText(" mod ", current);
1617
+ if (name === "pmod") return surroundRendered(this.#argument(style).text, "(mod ", ")", current);
1618
+ if (name === "pod") return surroundRendered(this.#argument(style).text, "(", ")", current);
1619
+ if (name === "tag") return surroundRendered(this.#argument(style).text, "(", ")", current);
1418
1620
  if (name === "label") {
1419
1621
  this.#rawArgument();
1420
- return "";
1622
+ return [];
1421
1623
  }
1422
- if (name === "ref" || name === "eqref") return `(${unescapeText(this.#rawArgument())})`;
1423
- if (name === "url") return unescapeText(this.#rawArgument());
1624
+ if (name === "ref" || name === "eqref") return styledText(`(${unescapeText(this.#rawArgument())})`, current);
1625
+ if (name === "url") return styledText(unescapeText(this.#rawArgument()), current);
1424
1626
  if (name === "href") {
1425
1627
  this.#rawArgument();
1426
1628
  return this.#argument(style).text;
@@ -1430,22 +1632,22 @@ class LatexParser {
1430
1632
  if (name === "fcolorbox") return this.#fcolorbox(style);
1431
1633
  if (name === "color") return this.#setForeground();
1432
1634
  if (name === "normalcolor") {
1433
- const previous = this.#foreground;
1434
1635
  this.#foreground = null;
1435
- return previous === null ? "" : ANSI_FG_RESET;
1636
+ return [];
1436
1637
  }
1437
1638
  if (name === "phantom" || name === "hphantom") {
1438
- return " ".repeat(codePointLength(this.#argument(style).text));
1639
+ const arg = this.#argument(style).text;
1640
+ return styledText(" ".repeat(codePointLength(arg)), current);
1439
1641
  }
1440
1642
  if (name === "vphantom") {
1441
1643
  this.#argument(style);
1442
- return "";
1644
+ return [];
1443
1645
  }
1444
1646
 
1445
- if (FUNCTIONS[name]) return name + this.#spaceBeforeArg();
1647
+ if (FUNCTIONS[name]) return styledText(name + this.#spaceBeforeArg(), current);
1446
1648
 
1447
1649
  const symbol = SYMBOLS[name];
1448
- if (symbol !== undefined) return symbol;
1650
+ if (symbol !== undefined) return styledText(symbol, current);
1449
1651
 
1450
1652
  // Layout-only commands that carry no visible glyph.
1451
1653
  switch (name) {
@@ -1458,36 +1660,34 @@ class LatexParser {
1458
1660
  case "nonumber":
1459
1661
  case "notag":
1460
1662
  case "quad":
1461
- return name === "quad" ? " " : "";
1663
+ return styledText(name === "quad" ? " " : "", current);
1462
1664
  case "qquad":
1463
- return " ";
1665
+ return styledText(" ", current);
1464
1666
  case "thinspace":
1465
1667
  case "enspace":
1466
1668
  case "medspace":
1467
1669
  case "thickspace":
1468
1670
  case "space":
1469
- return " ";
1671
+ return styledText(" ", current);
1470
1672
  case "negthinspace":
1471
1673
  case "negmedspace":
1472
1674
  case "negthickspace":
1473
- return "";
1675
+ return [];
1474
1676
  }
1475
1677
 
1476
1678
  // Unknown command: surface the bare name rather than dropping it silently.
1477
- return name;
1679
+ return styledText(name, current);
1478
1680
  }
1479
1681
 
1480
- #group(style: FontStyle | null): string {
1682
+ #group(style: ParseStyle): Rendered {
1481
1683
  this.#i++;
1482
1684
  const outerForeground = this.#foreground;
1483
1685
  const outerBackground = this.#background;
1484
1686
  const inner = this.parse(style, true);
1485
- const innerForeground = this.#foreground;
1486
- const innerBackground = this.#background;
1487
1687
  if (this.#s[this.#i] === "}") this.#i++;
1488
1688
  this.#foreground = outerForeground;
1489
1689
  this.#background = outerBackground;
1490
- return restoreAnsi(inner, innerForeground, outerForeground, innerBackground, outerBackground);
1690
+ return inner;
1491
1691
  }
1492
1692
 
1493
1693
  #readAnsiColor(): AnsiColor | null {
@@ -1495,48 +1695,45 @@ class LatexParser {
1495
1695
  return ansiColor(model, this.#rawArgument());
1496
1696
  }
1497
1697
 
1498
- #setForeground(): string {
1698
+ #setForeground(): Rendered {
1499
1699
  const color = this.#readAnsiColor();
1500
- if (color === null) return "";
1501
- this.#foreground = color.foreground;
1502
- return color.foreground;
1700
+ if (color !== null) this.#foreground = color.foreground;
1701
+ return [];
1503
1702
  }
1504
1703
 
1505
- #scopedForeground(color: AnsiColor | null, style: FontStyle | null): string {
1704
+ #scopedForeground(color: AnsiColor | null, style: ParseStyle): Rendered {
1506
1705
  const outerForeground = this.#foreground;
1507
1706
  if (color === null) return this.#argument(style).text;
1508
1707
  this.#foreground = color.foreground;
1509
1708
  const arg = this.#argument(style).text;
1510
- const innerForeground = this.#foreground;
1511
1709
  this.#foreground = outerForeground;
1512
- return color.foreground + restoreAnsi(arg, innerForeground, outerForeground, this.#background, this.#background);
1710
+ return arg;
1513
1711
  }
1514
1712
 
1515
- #scopedBackground(color: AnsiColor | null, style: FontStyle | null): string {
1713
+ #scopedBackground(color: AnsiColor | null, style: ParseStyle): Rendered {
1516
1714
  const outerBackground = this.#background;
1517
1715
  if (color === null) return this.#argument(style).text;
1518
1716
  this.#background = color.background;
1519
1717
  const arg = this.#argument(style).text;
1520
- const innerBackground = this.#background;
1521
1718
  this.#background = outerBackground;
1522
- return color.background + restoreAnsi(arg, this.#foreground, this.#foreground, innerBackground, outerBackground);
1719
+ return arg;
1523
1720
  }
1524
1721
 
1525
- #fcolorbox(style: FontStyle | null): string {
1722
+ #fcolorbox(style: ParseStyle): Rendered {
1526
1723
  const frameModel = this.#optionalRawArgument();
1527
1724
  const frame = ansiColor(frameModel, this.#rawArgument());
1528
1725
  const backgroundModel = this.#optionalRawArgument() ?? frameModel;
1529
1726
  const background = ansiColor(backgroundModel, this.#rawArgument());
1530
1727
  const body = this.#scopedBackground(background, style);
1531
- if (frame === null) return `[${body}]`;
1532
- return `${frame.foreground}[${this.#foreground ?? ANSI_FG_RESET}${body}${frame.foreground}]${this.#foreground ?? ANSI_FG_RESET}`;
1728
+ const frameStyle = renderStyle(style, frame?.foreground ?? this.#foreground, this.#background);
1729
+ return concatRendered(styledText("[", frameStyle), body, styledText("]", frameStyle));
1533
1730
  }
1534
1731
 
1535
1732
  /** Read one argument: a `{…}` group, a single command, or a single char. */
1536
- #argument(style: FontStyle | null): Argument {
1733
+ #argument(style: ParseStyle): Argument {
1537
1734
  while (this.#s[this.#i] === " ") this.#i++;
1538
1735
  const c = this.#s[this.#i];
1539
- if (c === undefined) return { text: "", group: false };
1736
+ if (c === undefined) return { text: [], group: false };
1540
1737
  if (c === "{") {
1541
1738
  this.#i++;
1542
1739
  const inner = this.parse(style, true);
@@ -1550,7 +1747,7 @@ class LatexParser {
1550
1747
  return { text: this.#script(style, c === "^"), group: false };
1551
1748
  }
1552
1749
  this.#i++;
1553
- return { text: styleChar(c, style), group: false };
1750
+ return { text: styledText(styleChar(c, style), this.#renderStyle(style)), group: false };
1554
1751
  }
1555
1752
 
1556
1753
  /** Read a raw (unparsed) argument, returning its literal source text. */
@@ -1600,74 +1797,89 @@ class LatexParser {
1600
1797
  return out;
1601
1798
  }
1602
1799
 
1603
- #script(style: FontStyle | null, sup: boolean): string {
1800
+ #script(style: ParseStyle, sup: boolean): Rendered {
1801
+ const current = this.#renderStyle(style);
1604
1802
  const arg = this.#argument(style);
1605
- return sup ? toSuperscript(arg.text, arg.group) : toSubscript(arg.text, arg.group);
1803
+ return sup ? toSuperscript(arg.text, arg.group, current) : toSubscript(arg.text, arg.group, current);
1606
1804
  }
1607
1805
 
1608
- #wrapFrac(arg: Argument): string {
1609
- return arg.group && codePointLength(arg.text) > 1 ? `(${arg.text})` : arg.text;
1806
+ #wrapFrac(arg: Argument, fallback: RenderStyle): Rendered {
1807
+ if (!arg.group || codePointLength(arg.text) <= 1) return arg.text;
1808
+ return surroundRendered(arg.text, "(", ")", fallback);
1610
1809
  }
1611
1810
 
1612
- #fraction(num: Argument, den: Argument): string {
1613
- const vulgar = VULGAR[`${num.text}/${den.text}`];
1614
- if (vulgar) return vulgar;
1615
- return `${this.#wrapFrac(num)}/${this.#wrapFrac(den)}`;
1811
+ #fraction(num: Argument, den: Argument, fallback: RenderStyle): Rendered {
1812
+ const numText = plainText(num.text);
1813
+ const denText = plainText(den.text);
1814
+ const first = num.text[0]?.style ?? fallback;
1815
+ const same = [...num.text, ...den.text].every(chunk => sameRenderStyle(chunk.style, first));
1816
+ const vulgar = VULGAR[`${numText}/${denText}`];
1817
+ if (vulgar && same) return styledText(vulgar, first);
1818
+ return concatRendered(this.#wrapFrac(num, fallback), styledText("/", fallback), this.#wrapFrac(den, fallback));
1616
1819
  }
1617
1820
 
1618
- #scriptedAbove(style: FontStyle | null): string {
1821
+ #scriptedAbove(style: ParseStyle): Rendered {
1822
+ const current = this.#renderStyle(style);
1619
1823
  const above = this.#argument(style);
1620
1824
  const base = this.#argument(style);
1621
- return base.text + toSuperscript(above.text, true);
1825
+ return concatRendered(base.text, toSuperscript(above.text, true, current));
1622
1826
  }
1623
1827
 
1624
- #scriptedBelow(style: FontStyle | null): string {
1828
+ #scriptedBelow(style: ParseStyle): Rendered {
1829
+ const current = this.#renderStyle(style);
1625
1830
  const below = this.#argument(style);
1626
1831
  const base = this.#argument(style);
1627
- return base.text + toSubscript(below.text, true);
1832
+ return concatRendered(base.text, toSubscript(below.text, true, current));
1628
1833
  }
1629
1834
 
1630
- #prescript(style: FontStyle | null): string {
1835
+ #prescript(style: ParseStyle): Rendered {
1836
+ const current = this.#renderStyle(style);
1631
1837
  const sup = this.#argument(style);
1632
1838
  const sub = this.#argument(style);
1633
1839
  const base = this.#argument(style);
1634
- return toSuperscript(sup.text, true) + toSubscript(sub.text, true) + base.text;
1840
+ return concatRendered(toSuperscript(sup.text, true, current), toSubscript(sub.text, true, current), base.text);
1635
1841
  }
1636
1842
 
1637
- #extensibleArrow(style: FontStyle | null, arrow: string): string {
1843
+ #extensibleArrow(style: ParseStyle, arrow: string): Rendered {
1844
+ const current = this.#renderStyle(style);
1638
1845
  const below = this.#optionalArgument(style);
1639
1846
  const above = this.#argument(style);
1640
- return arrow + toSuperscript(above.text, true) + (below ? toSubscript(below.text, true) : "");
1847
+ return concatRendered(
1848
+ styledText(arrow, current),
1849
+ toSuperscript(above.text, true, current),
1850
+ below === null ? [] : toSubscript(below.text, true, current),
1851
+ );
1641
1852
  }
1642
1853
 
1643
- #delimiter(style: FontStyle | null): string {
1854
+ #delimiter(style: ParseStyle): Rendered {
1644
1855
  while (this.#s[this.#i] === " ") this.#i++;
1645
1856
  const c = this.#s[this.#i];
1646
- if (c === undefined) return "";
1857
+ const current = this.#renderStyle(style);
1858
+ if (c === undefined) return [];
1647
1859
  if (c === ".") {
1648
1860
  this.#i++;
1649
- return "";
1861
+ return [];
1650
1862
  }
1651
1863
  if (c !== "\\") {
1652
1864
  this.#i++;
1653
- return styleChar(c, style);
1865
+ return styledText(styleChar(c, style), current);
1654
1866
  }
1655
1867
  this.#i++;
1656
- if (this.#i >= this.#s.length) return "";
1868
+ if (this.#i >= this.#s.length) return [];
1657
1869
  const d = this.#s[this.#i];
1658
1870
  if (!/[A-Za-z]/.test(d)) {
1659
1871
  this.#i++;
1660
1872
  switch (d) {
1661
1873
  case ".":
1662
- return "";
1874
+ return [];
1663
1875
  case "{":
1664
- return "{";
1876
+ return styledText("{", current);
1665
1877
  case "}":
1666
- return "}";
1878
+ return styledText("}", current);
1667
1879
  case "|":
1668
- return "‖";
1880
+ return styledText("‖", current);
1669
1881
  default:
1670
- return d;
1882
+ return styledText(d, current);
1671
1883
  }
1672
1884
  }
1673
1885
  let name = "";
@@ -1675,10 +1887,10 @@ class LatexParser {
1675
1887
  name += this.#s[this.#i];
1676
1888
  this.#i++;
1677
1889
  }
1678
- return SYMBOLS[name] ?? name;
1890
+ return styledText(SYMBOLS[name] ?? name, current);
1679
1891
  }
1680
1892
 
1681
- #optionalArgument(style: FontStyle | null): Argument | null {
1893
+ #optionalArgument(style: ParseStyle): Argument | null {
1682
1894
  const source = this.#optionalRawArgument();
1683
1895
  if (source === null) return null;
1684
1896
  return { text: new LatexParser(source).parse(style, false), group: true };
@@ -1714,18 +1926,30 @@ class LatexParser {
1714
1926
  return out;
1715
1927
  }
1716
1928
 
1717
- #sqrt(style: FontStyle | null): string {
1929
+ #sqrt(style: ParseStyle): Rendered {
1718
1930
  while (this.#s[this.#i] === " ") this.#i++;
1719
- let radical = "√";
1720
- const index = this.#optionalArgument(style)?.text;
1721
- if (index !== undefined) {
1722
- radical = index === "2" ? "√" : index === "3" ? "∛" : index === "4" ? "∜" : `${toSuperscript(index, true)}√`;
1931
+ const current = this.#renderStyle(style);
1932
+ let radical = styledText("√", current);
1933
+ const index = this.#optionalArgument(style);
1934
+ if (index !== null) {
1935
+ const indexText = plainText(index.text);
1936
+ radical =
1937
+ indexText === "2"
1938
+ ? styledText("√", current)
1939
+ : indexText === "3"
1940
+ ? styledText("∛", current)
1941
+ : indexText === "4"
1942
+ ? styledText("∜", current)
1943
+ : concatRendered(toSuperscript(index.text, true, current), styledText("√", current));
1723
1944
  }
1724
1945
  const radicand = this.#argument(style).text;
1725
- return radical + (codePointLength(radicand) > 1 ? `(${radicand})` : radicand);
1946
+ return concatRendered(
1947
+ radical,
1948
+ codePointLength(radicand) > 1 ? surroundRendered(radicand, "(", ")", current) : radicand,
1949
+ );
1726
1950
  }
1727
1951
 
1728
- #environment(style: FontStyle | null): string {
1952
+ #environment(style: ParseStyle): Rendered {
1729
1953
  const env = this.#rawArgument().trim();
1730
1954
  if (env === "array" || env === "tabular" || env === "array*" || env === "tabular*") {
1731
1955
  this.#optionalRawArgument();
@@ -1740,16 +1964,16 @@ class LatexParser {
1740
1964
  this.#optionalRawArgument();
1741
1965
  if (this.#s[this.#i] === "{") this.#rawArgument(); // column count
1742
1966
  }
1743
- let body = "";
1967
+ const body: Rendered = [];
1744
1968
  while (this.#i < this.#s.length) {
1745
1969
  if (this.#s.startsWith("\\end", this.#i)) {
1746
1970
  this.#i += 4;
1747
1971
  this.#rawArgument();
1748
1972
  break;
1749
1973
  }
1750
- body += this.#node(style);
1974
+ appendRendered(body, this.#node(style));
1751
1975
  }
1752
- body = body.trim();
1976
+ let renderedBody = trimRendered(body);
1753
1977
  if (
1754
1978
  env === "cases" ||
1755
1979
  env === "cases*" ||
@@ -1758,10 +1982,15 @@ class LatexParser {
1758
1982
  env === "rcases" ||
1759
1983
  env === "drcases"
1760
1984
  ) {
1761
- body = body.replace(/[ \t]*\n+[ \t]*/g, "; ").replace(/ {3,}/g, " ");
1985
+ renderedBody = renderedBody.map(chunk => ({
1986
+ ...chunk,
1987
+ text: chunk.text.replace(/[ \t]*\n+[ \t]*/g, "; ").replace(/ {3,}/g, " "),
1988
+ }));
1762
1989
  }
1763
1990
  const delims = ENV_DELIMS[env];
1764
- return delims ? delims[0] + body + delims[1] : body;
1991
+ if (!delims) return renderedBody;
1992
+ const current = this.#renderStyle(style);
1993
+ return concatRendered(styledText(delims[0], current), renderedBody, styledText(delims[1], current));
1765
1994
  }
1766
1995
 
1767
1996
  /** A separator space when the next glyph is alphanumeric or a command. */
@@ -1788,7 +2017,7 @@ export function latexToUnicode(src: string): string {
1788
2017
 
1789
2018
  const NEWLINES = /\n+/g;
1790
2019
  const BARE_MATH_LINE_COMMAND =
1791
- /\\(?:operatorname|frac|dfrac|tfrac|cfrac|genfrac|sqrt|sum|prod|coprod|int|iint|iiint|lim|alpha|beta|gamma|delta|epsilon|varepsilon|theta|lambda|mu|sigma|phi|varphi|pi|omega|infty|partial|nabla|forall|exists|mathbb|mathcal|mathscr|mathbf|mathrm|left|right|begin|phantom|hphantom|vphantom|cdots|ldots|dots|to|rightarrow|leftarrow|leq|geq|neq|times|cdot|overline|underline|vec|hat|bar|textcolor|color|normalcolor|colorbox|fcolorbox)\b/;
2020
+ /\\(?:operatorname|frac|dfrac|tfrac|cfrac|genfrac|sqrt|sum|prod|coprod|int|iint|iiint|lim|alpha|beta|gamma|delta|epsilon|varepsilon|theta|lambda|mu|sigma|phi|varphi|pi|omega|infty|partial|nabla|forall|exists|mathbb|mathcal|mathscr|mathbf|mathrm|left|right|begin|phantom|hphantom|vphantom|cdots|ldots|dots|to|rightarrow|leftarrow|leq|geq|neq|times|cdot|overline|underline|vec|hat|bar|textbf|textit|textsl|emph|textmd|textup|texttt|textsf|textcolor|color|normalcolor|colorbox|fcolorbox)\b/;
1792
2021
 
1793
2022
  // Display-math environments eligible for delimiter-less ("bare") rendering in
1794
2023
  // prose. Deliberately excludes text-mode table/list/float environments