@oh-my-pi/pi-tui 16.0.2 → 16.0.3

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.
@@ -0,0 +1,1994 @@
1
+ import { TERMINAL } from "./terminal-capabilities";
2
+
3
+ // LaTeX → Unicode/ANSI converter.
4
+ //
5
+ // Terminals cannot lay out real math, but a surprising amount of LaTeX maps
6
+ // cleanly onto Unicode: superscripts/subscripts (x² xᵢ), Greek (α β), big
7
+ // operators (∫ ∑ ∏), relations/arrows (≤ ≠ → ⇒), fonts via the Mathematical
8
+ // Alphanumeric Symbols block (ℝ 𝐱 𝔄 𝒞), accents via combining marks (x̂ x̄ x⃗),
9
+ // fractions (½, (a+b)/c), radicals (√, ∛), and ANSI foreground/background colors
10
+ // (`\textcolor`, `\color`, `\colorbox`, `\fcolorbox`). This module turns a LaTeX math
11
+ //
12
+ // `latexToUnicode(src)` converts a *bare* math fragment (no `$`/`\(` delimiters).
13
+ // `renderMathInText(text)` scans prose for `$$…$$`, `$…$`, `\(…\)`, `\[…\]`
14
+ // spans and converts only those, with anti-currency heuristics so "$5 and $10"
15
+ // is left untouched. The markdown renderer isolates math via a Marked extension
16
+ // and calls `latexToUnicode` directly; `renderMathInText` serves callers that
17
+ // only have raw text.
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Character maps
21
+ // ---------------------------------------------------------------------------
22
+
23
+ // Unicode superscript forms. Letters are incomplete in Unicode (q, and several
24
+ // capitals have no superscript), so the converter falls back to `^(…)` when any
25
+ // character in a script group is unmappable.
26
+ const SUPERSCRIPT: Record<string, string> = {
27
+ "0": "⁰",
28
+ "1": "¹",
29
+ "2": "²",
30
+ "3": "³",
31
+ "4": "⁴",
32
+ "5": "⁵",
33
+ "6": "⁶",
34
+ "7": "⁷",
35
+ "8": "⁸",
36
+ "9": "⁹",
37
+ "+": "⁺",
38
+ "-": "⁻",
39
+ "−": "⁻",
40
+ "=": "⁼",
41
+ "(": "⁽",
42
+ ")": "⁾",
43
+ ".": "·",
44
+ " ": " ",
45
+ a: "ᵃ",
46
+ b: "ᵇ",
47
+ c: "ᶜ",
48
+ d: "ᵈ",
49
+ e: "ᵉ",
50
+ f: "ᶠ",
51
+ g: "ᵍ",
52
+ h: "ʰ",
53
+ i: "ⁱ",
54
+ j: "ʲ",
55
+ k: "ᵏ",
56
+ l: "ˡ",
57
+ m: "ᵐ",
58
+ n: "ⁿ",
59
+ o: "ᵒ",
60
+ p: "ᵖ",
61
+ r: "ʳ",
62
+ s: "ˢ",
63
+ t: "ᵗ",
64
+ u: "ᵘ",
65
+ v: "ᵛ",
66
+ w: "ʷ",
67
+ x: "ˣ",
68
+ y: "ʸ",
69
+ z: "ᶻ",
70
+ A: "ᴬ",
71
+ B: "ᴮ",
72
+ D: "ᴰ",
73
+ E: "ᴱ",
74
+ G: "ᴳ",
75
+ H: "ᴴ",
76
+ I: "ᴵ",
77
+ J: "ᴶ",
78
+ K: "ᴷ",
79
+ L: "ᴸ",
80
+ M: "ᴹ",
81
+ N: "ᴺ",
82
+ O: "ᴼ",
83
+ P: "ᴾ",
84
+ R: "ᴿ",
85
+ T: "ᵀ",
86
+ U: "ᵁ",
87
+ V: "ⱽ",
88
+ W: "ᵂ",
89
+ α: "ᵅ",
90
+ β: "ᵝ",
91
+ γ: "ᵞ",
92
+ δ: "ᵟ",
93
+ ε: "ᵋ",
94
+ θ: "ᶿ",
95
+ ι: "ᶥ",
96
+ φ: "ᵠ",
97
+ χ: "ᵡ",
98
+ };
99
+
100
+ // Unicode subscript forms (even sparser than superscripts).
101
+ const SUBSCRIPT: Record<string, string> = {
102
+ "0": "₀",
103
+ "1": "₁",
104
+ "2": "₂",
105
+ "3": "₃",
106
+ "4": "₄",
107
+ "5": "₅",
108
+ "6": "₆",
109
+ "7": "₇",
110
+ "8": "₈",
111
+ "9": "₉",
112
+ "+": "₊",
113
+ "-": "₋",
114
+ "−": "₋",
115
+ "=": "₌",
116
+ "(": "₍",
117
+ ")": "₎",
118
+ " ": " ",
119
+ a: "ₐ",
120
+ e: "ₑ",
121
+ h: "ₕ",
122
+ i: "ᵢ",
123
+ j: "ⱼ",
124
+ k: "ₖ",
125
+ l: "ₗ",
126
+ m: "ₘ",
127
+ n: "ₙ",
128
+ o: "ₒ",
129
+ p: "ₚ",
130
+ r: "ᵣ",
131
+ s: "ₛ",
132
+ t: "ₜ",
133
+ u: "ᵤ",
134
+ v: "ᵥ",
135
+ x: "ₓ",
136
+ β: "ᵦ",
137
+ γ: "ᵧ",
138
+ ρ: "ᵨ",
139
+ φ: "ᵩ",
140
+ χ: "ᵪ",
141
+ };
142
+
143
+ // Prime runs: f' f'' f''' f''''.
144
+ const PRIMES = ["", "′", "″", "‴", "⁗"] as const;
145
+
146
+ // Common vulgar fractions, keyed by `${num}/${den}` of the rendered parts.
147
+ const VULGAR: Record<string, string> = {
148
+ "1/2": "½",
149
+ "1/3": "⅓",
150
+ "2/3": "⅔",
151
+ "1/4": "¼",
152
+ "3/4": "¾",
153
+ "1/5": "⅕",
154
+ "2/5": "⅖",
155
+ "3/5": "⅗",
156
+ "4/5": "⅘",
157
+ "1/6": "⅙",
158
+ "5/6": "⅚",
159
+ "1/7": "⅐",
160
+ "1/8": "⅛",
161
+ "3/8": "⅜",
162
+ "5/8": "⅝",
163
+ "7/8": "⅞",
164
+ "1/9": "⅑",
165
+ "1/10": "⅒",
166
+ "0/3": "↉",
167
+ };
168
+
169
+ // `\not<rel>` negations that have a dedicated Unicode glyph (cleaner than the
170
+ // combining-solidus fallback).
171
+ const NOT_MAP: Record<string, string> = {
172
+ "=": "≠",
173
+ "<": "≮",
174
+ ">": "≯",
175
+ "∈": "∉",
176
+ "∋": "∌",
177
+ "⊂": "⊄",
178
+ "⊃": "⊅",
179
+ "⊆": "⊈",
180
+ "⊇": "⊉",
181
+ "≡": "≢",
182
+ "∃": "∄",
183
+ "≤": "≰",
184
+ "≥": "≱",
185
+ "≈": "≉",
186
+ "≅": "≇",
187
+ "∼": "≁",
188
+ "≃": "≄",
189
+ "∣": "∤",
190
+ "∥": "∦",
191
+ "≺": "⊀",
192
+ "≻": "⊁",
193
+ "⊑": "⋢",
194
+ "⊒": "⋣",
195
+ };
196
+
197
+ // Combining diacritics for accent commands (applied after each base glyph).
198
+ const ACCENTS: Record<string, string> = {
199
+ hat: "\u0302",
200
+ widehat: "\u0302",
201
+ check: "\u030C",
202
+ widecheck: "\u030C",
203
+ tilde: "\u0303",
204
+ widetilde: "\u0303",
205
+ acute: "\u0301",
206
+ grave: "\u0300",
207
+ dot: "\u0307",
208
+ ddot: "\u0308",
209
+ dddot: "\u20DB",
210
+ ddddot: "\u20DC",
211
+ breve: "\u0306",
212
+ bar: "\u0304",
213
+ vec: "\u20D7",
214
+ overrightarrow: "\u20D7",
215
+ overleftarrow: "\u20D6",
216
+ mathring: "\u030A",
217
+ overline: "\u0305",
218
+ underline: "\u0332",
219
+ underbar: "\u0332",
220
+ };
221
+
222
+ // Math functions rendered as their literal upright name (sin, cos, lim, …).
223
+ const FUNCTIONS: Record<string, true> = {
224
+ sin: true,
225
+ cos: true,
226
+ tan: true,
227
+ cot: true,
228
+ sec: true,
229
+ csc: true,
230
+ sinh: true,
231
+ cosh: true,
232
+ tanh: true,
233
+ coth: true,
234
+ arcsin: true,
235
+ arccos: true,
236
+ arctan: true,
237
+ arccot: true,
238
+ arcsec: true,
239
+ arccsc: true,
240
+ sech: true,
241
+ csch: true,
242
+ ln: true,
243
+ log: true,
244
+ lg: true,
245
+ exp: true,
246
+ lim: true,
247
+ limsup: true,
248
+ liminf: true,
249
+ max: true,
250
+ min: true,
251
+ sup: true,
252
+ inf: true,
253
+ det: true,
254
+ dim: true,
255
+ ker: true,
256
+ hom: true,
257
+ arg: true,
258
+ deg: true,
259
+ gcd: true,
260
+ lcm: true,
261
+ Pr: true,
262
+ argmax: true,
263
+ argmin: true,
264
+ sgn: true,
265
+ tr: true,
266
+ rank: true,
267
+ diag: true,
268
+ var: true,
269
+ cov: true,
270
+ median: true,
271
+ mod: true,
272
+ };
273
+
274
+ // Math-mode font commands → Mathematical Alphanumeric Symbols style.
275
+ type FontStyle =
276
+ | "bold"
277
+ | "italic"
278
+ | "bolditalic"
279
+ | "script"
280
+ | "boldscript"
281
+ | "fraktur"
282
+ | "doublestruck"
283
+ | "boldfraktur"
284
+ | "sans"
285
+ | "sansbold"
286
+ | "sansitalic"
287
+ | "sansbolditalic"
288
+ | "mono";
289
+
290
+ const FONTS: Record<string, FontStyle> = {
291
+ mathbf: "bold",
292
+ boldsymbol: "bolditalic",
293
+ bm: "bolditalic",
294
+ pmb: "bold",
295
+ mathbb: "doublestruck",
296
+ Bbb: "doublestruck",
297
+ mathds: "doublestruck",
298
+ mathbbm: "doublestruck",
299
+ mathcal: "script",
300
+ mathscr: "boldscript",
301
+ mathfrak: "fraktur",
302
+ mathbfscr: "boldscript",
303
+ mathbfcal: "boldscript",
304
+ mathbffrak: "boldfraktur",
305
+ mathfrakbold: "boldfraktur",
306
+ mathsf: "sans",
307
+ mathsfit: "sansitalic",
308
+ mathsfbf: "sansbold",
309
+ mathbfsf: "sansbold",
310
+ mathsfbfit: "sansbolditalic",
311
+ mathbfsfit: "sansbolditalic",
312
+ mathtt: "mono",
313
+ mathit: "italic",
314
+ mathbfit: "bolditalic",
315
+ textbf: "bold",
316
+ textit: "italic",
317
+ texttt: "mono",
318
+ textsf: "sans",
319
+ };
320
+
321
+ // Text-mode commands whose argument is passed through literally (no math).
322
+ const TEXT_COMMANDS: Record<string, true> = {
323
+ text: true,
324
+ textrm: true,
325
+ textnormal: true,
326
+ textup: true,
327
+ textmd: true,
328
+ textsc: true,
329
+ textsl: true,
330
+ emph: true,
331
+ mathrm: true,
332
+ mathnormal: true,
333
+ mbox: true,
334
+ hbox: true,
335
+ };
336
+
337
+ // Base code points for each style's A, a, and (where it exists) 0 in the
338
+ // Mathematical Alphanumeric Symbols block (U+1D400–U+1D7FF).
339
+ interface Plane {
340
+ upper: number;
341
+ lower: number;
342
+ digit?: number;
343
+ }
344
+ const PLANES: Record<FontStyle, Plane> = {
345
+ bold: { upper: 0x1d400, lower: 0x1d41a, digit: 0x1d7ce },
346
+ italic: { upper: 0x1d434, lower: 0x1d44e },
347
+ bolditalic: { upper: 0x1d468, lower: 0x1d482 },
348
+ script: { upper: 0x1d49c, lower: 0x1d4b6 },
349
+ boldscript: { upper: 0x1d4d0, lower: 0x1d4ea },
350
+ fraktur: { upper: 0x1d504, lower: 0x1d51e },
351
+ doublestruck: { upper: 0x1d538, lower: 0x1d552, digit: 0x1d7d8 },
352
+ boldfraktur: { upper: 0x1d56c, lower: 0x1d586 },
353
+ sans: { upper: 0x1d5a0, lower: 0x1d5ba, digit: 0x1d7e2 },
354
+ sansbold: { upper: 0x1d5d4, lower: 0x1d5ee, digit: 0x1d7ec },
355
+ sansitalic: { upper: 0x1d608, lower: 0x1d622 },
356
+ sansbolditalic: { upper: 0x1d63c, lower: 0x1d656 },
357
+ mono: { upper: 0x1d670, lower: 0x1d68a, digit: 0x1d7f6 },
358
+ };
359
+
360
+ // Reserved code points in the math alphabets that Unicode places in the
361
+ // Letterlike Symbols block instead (the famous "holes").
362
+ const ALPHA_HOLES: Record<string, string> = {
363
+ "italic:h": "ℎ",
364
+ "script:B": "ℬ",
365
+ "script:E": "ℰ",
366
+ "script:F": "ℱ",
367
+ "script:H": "ℋ",
368
+ "script:I": "ℐ",
369
+ "script:L": "ℒ",
370
+ "script:M": "ℳ",
371
+ "script:R": "ℛ",
372
+ "script:e": "ℯ",
373
+ "script:g": "ℊ",
374
+ "script:o": "ℴ",
375
+ "fraktur:C": "ℭ",
376
+ "fraktur:H": "ℌ",
377
+ "fraktur:I": "ℑ",
378
+ "fraktur:R": "ℜ",
379
+ "fraktur:Z": "ℨ",
380
+ "doublestruck:C": "ℂ",
381
+ "doublestruck:H": "ℍ",
382
+ "doublestruck:N": "ℕ",
383
+ "doublestruck:P": "ℙ",
384
+ "doublestruck:Q": "ℚ",
385
+ "doublestruck:R": "ℝ",
386
+ "doublestruck:Z": "ℤ",
387
+ };
388
+
389
+ // Matrix/cases environment delimiters: [open, close].
390
+ const ENV_DELIMS: Record<string, readonly [string, string]> = {
391
+ matrix: ["", ""],
392
+ smallmatrix: ["", ""],
393
+ array: ["", ""],
394
+ tabular: ["", ""],
395
+ pmatrix: ["(", ")"],
396
+ bmatrix: ["[", "]"],
397
+ Bmatrix: ["{", "}"],
398
+ vmatrix: ["|", "|"],
399
+ Vmatrix: ["‖", "‖"],
400
+ cases: ["{", ""],
401
+ "cases*": ["{", ""],
402
+ dcases: ["{", ""],
403
+ "dcases*": ["{", ""],
404
+ rcases: ["", "}"],
405
+ drcases: ["", "}"],
406
+ aligned: ["", ""],
407
+ "aligned*": ["", ""],
408
+ alignedat: ["", ""],
409
+ "alignedat*": ["", ""],
410
+ align: ["", ""],
411
+ "align*": ["", ""],
412
+ alignat: ["", ""],
413
+ "alignat*": ["", ""],
414
+ split: ["", ""],
415
+ gathered: ["", ""],
416
+ equation: ["", ""],
417
+ "equation*": ["", ""],
418
+ };
419
+
420
+ // Greek, operators, relations, arrows, delimiters, and assorted symbols.
421
+ const SYMBOLS: Record<string, string> = {
422
+ // Greek lowercase
423
+ alpha: "α",
424
+ beta: "β",
425
+ gamma: "γ",
426
+ delta: "δ",
427
+ epsilon: "ϵ",
428
+ varepsilon: "ε",
429
+ zeta: "ζ",
430
+ eta: "η",
431
+ theta: "θ",
432
+ vartheta: "ϑ",
433
+ iota: "ι",
434
+ kappa: "κ",
435
+ varkappa: "ϰ",
436
+ lambda: "λ",
437
+ mu: "μ",
438
+ nu: "ν",
439
+ xi: "ξ",
440
+ omicron: "ο",
441
+ pi: "π",
442
+ varpi: "ϖ",
443
+ rho: "ρ",
444
+ varrho: "ϱ",
445
+ sigma: "σ",
446
+ varsigma: "ς",
447
+ tau: "τ",
448
+ upsilon: "υ",
449
+ phi: "ϕ",
450
+ varphi: "φ",
451
+ chi: "χ",
452
+ psi: "ψ",
453
+ omega: "ω",
454
+ digamma: "ϝ",
455
+ // Greek uppercase
456
+ Gamma: "Γ",
457
+ Delta: "Δ",
458
+ Theta: "Θ",
459
+ Lambda: "Λ",
460
+ Xi: "Ξ",
461
+ Pi: "Π",
462
+ Sigma: "Σ",
463
+ Upsilon: "Υ",
464
+ Phi: "Φ",
465
+ Psi: "Ψ",
466
+ Omega: "Ω",
467
+ // Big operators
468
+ sum: "∑",
469
+ prod: "∏",
470
+ coprod: "∐",
471
+ int: "∫",
472
+ iint: "∬",
473
+ iiint: "∭",
474
+ iiiint: "⨌",
475
+ oint: "∮",
476
+ oiint: "∯",
477
+ oiiint: "∰",
478
+ bigcap: "⋂",
479
+ bigcup: "⋃",
480
+ bigsqcup: "⨆",
481
+ bigvee: "⋁",
482
+ bigwedge: "⋀",
483
+ bigodot: "⨀",
484
+ bigoplus: "⨁",
485
+ bigotimes: "⨂",
486
+ biguplus: "⨄",
487
+ Cap: "⋒",
488
+ Cup: "⋓",
489
+ bigstar: "★",
490
+ // Binary operators
491
+ pm: "±",
492
+ mp: "∓",
493
+ times: "×",
494
+ div: "÷",
495
+ ast: "∗",
496
+ star: "⋆",
497
+ circ: "∘",
498
+ bullet: "∙",
499
+ cdot: "⋅",
500
+ cdotp: "·",
501
+ centerdot: "·",
502
+ cap: "∩",
503
+ cup: "∪",
504
+ uplus: "⊎",
505
+ sqcap: "⊓",
506
+ sqcup: "⊔",
507
+ vee: "∨",
508
+ wedge: "∧",
509
+ land: "∧",
510
+ lor: "∨",
511
+ setminus: "∖",
512
+ smallsetminus: "∖",
513
+ wr: "≀",
514
+ amalg: "⨿",
515
+ diamond: "⋄",
516
+ Diamond: "◇",
517
+ bigtriangleup: "△",
518
+ bigtriangledown: "▽",
519
+ triangleleft: "◁",
520
+ triangleright: "▷",
521
+ lhd: "⊲",
522
+ rhd: "⊳",
523
+ unlhd: "⊴",
524
+ unrhd: "⊵",
525
+ oplus: "⊕",
526
+ ominus: "⊖",
527
+ otimes: "⊗",
528
+ oslash: "⊘",
529
+ odot: "⊙",
530
+ dagger: "†",
531
+ ddagger: "‡",
532
+ boxplus: "⊞",
533
+ boxtimes: "⊠",
534
+ boxdot: "⊡",
535
+ boxminus: "⊟",
536
+ ltimes: "⋉",
537
+ rtimes: "⋊",
538
+ leftthreetimes: "⋋",
539
+ rightthreetimes: "⋌",
540
+ curlyvee: "⋎",
541
+ curlywedge: "⋏",
542
+ barwedge: "⊼",
543
+ veebar: "⊻",
544
+ doublebarwedge: "⩞",
545
+ circledast: "⊛",
546
+ circledcirc: "⊚",
547
+ circleddash: "⊝",
548
+ divideontimes: "⋇",
549
+ dotplus: "∔",
550
+ // Relations
551
+ leq: "≤",
552
+ le: "≤",
553
+ geq: "≥",
554
+ ge: "≥",
555
+ ll: "≪",
556
+ gg: "≫",
557
+ neq: "≠",
558
+ ne: "≠",
559
+ equiv: "≡",
560
+ doteq: "≐",
561
+ sim: "∼",
562
+ simeq: "≃",
563
+ approx: "≈",
564
+ approxeq: "≊",
565
+ cong: "≅",
566
+ propto: "∝",
567
+ asymp: "≍",
568
+ prec: "≺",
569
+ succ: "≻",
570
+ preceq: "⪯",
571
+ succeq: "⪰",
572
+ subset: "⊂",
573
+ supset: "⊃",
574
+ subseteq: "⊆",
575
+ supseteq: "⊇",
576
+ subsetneq: "⊊",
577
+ supsetneq: "⊋",
578
+ sqsubset: "⊏",
579
+ sqsupset: "⊐",
580
+ sqsubseteq: "⊑",
581
+ sqsupseteq: "⊒",
582
+ in: "∈",
583
+ ni: "∋",
584
+ owns: "∋",
585
+ notin: "∉",
586
+ mid: "∣",
587
+ nmid: "∤",
588
+ parallel: "∥",
589
+ nparallel: "∦",
590
+ perp: "⊥",
591
+ vdash: "⊢",
592
+ dashv: "⊣",
593
+ models: "⊨",
594
+ vDash: "⊨",
595
+ Vdash: "⊩",
596
+ bowtie: "⋈",
597
+ smile: "⌣",
598
+ frown: "⌢",
599
+ between: "≬",
600
+ lessgtr: "≶",
601
+ gtrless: "≷",
602
+ leqslant: "⩽",
603
+ geqslant: "⩾",
604
+ lesssim: "≲",
605
+ gtrsim: "≳",
606
+ lessapprox: "⪅",
607
+ gtrapprox: "⪆",
608
+ leqq: "≦",
609
+ geqq: "≧",
610
+ lneq: "⪇",
611
+ gneq: "⪈",
612
+ lneqq: "≨",
613
+ gneqq: "≩",
614
+ nleq: "≰",
615
+ ngeq: "≱",
616
+ nless: "≮",
617
+ ngtr: "≯",
618
+ nsubseteq: "⊈",
619
+ nsupseteq: "⊉",
620
+ nsim: "≁",
621
+ ncong: "≇",
622
+ triangleq: "≜",
623
+ coloneqq: "≔",
624
+ eqqcolon: "≕",
625
+ risingdotseq: "≓",
626
+ fallingdotseq: "≒",
627
+ circeq: "≗",
628
+ eqcirc: "≖",
629
+ precsim: "≾",
630
+ succsim: "≿",
631
+ precapprox: "⪷",
632
+ succapprox: "⪸",
633
+ curlyeqprec: "⋞",
634
+ curlyeqsucc: "⋟",
635
+ Subset: "⋐",
636
+ Supset: "⋑",
637
+ subseteqq: "⫅",
638
+ supseteqq: "⫆",
639
+ subsetneqq: "⫋",
640
+ supsetneqq: "⫌",
641
+ Vvdash: "⊪",
642
+ shortmid: "∣",
643
+ shortparallel: "∥",
644
+ pitchfork: "⋔",
645
+ // Arrows
646
+ leftarrow: "←",
647
+ gets: "←",
648
+ rightarrow: "→",
649
+ to: "→",
650
+ leftrightarrow: "↔",
651
+ Leftarrow: "⇐",
652
+ Rightarrow: "⇒",
653
+ Leftrightarrow: "⇔",
654
+ uparrow: "↑",
655
+ downarrow: "↓",
656
+ updownarrow: "↕",
657
+ Uparrow: "⇑",
658
+ Downarrow: "⇓",
659
+ Updownarrow: "⇕",
660
+ mapsto: "↦",
661
+ longmapsto: "⟼",
662
+ hookleftarrow: "↩",
663
+ hookrightarrow: "↪",
664
+ leftharpoonup: "↼",
665
+ rightharpoonup: "⇀",
666
+ leftharpoondown: "↽",
667
+ rightharpoondown: "⇁",
668
+ rightleftharpoons: "⇌",
669
+ longleftarrow: "⟵",
670
+ longrightarrow: "⟶",
671
+ longleftrightarrow: "⟷",
672
+ Longleftarrow: "⟸",
673
+ Longrightarrow: "⟹",
674
+ Longleftrightarrow: "⟺",
675
+ implies: "⟹",
676
+ impliedby: "⟸",
677
+ iff: "⟺",
678
+ nearrow: "↗",
679
+ searrow: "↘",
680
+ swarrow: "↙",
681
+ nwarrow: "↖",
682
+ nleftarrow: "↚",
683
+ nrightarrow: "↛",
684
+ leadsto: "⇝",
685
+ rightsquigarrow: "⇝",
686
+ leftrightsquigarrow: "↭",
687
+ twoheadrightarrow: "↠",
688
+ twoheadleftarrow: "↞",
689
+ leftrightharpoons: "⇋",
690
+ rightleftarrows: "⇄",
691
+ leftrightarrows: "⇆",
692
+ leftleftarrows: "⇇",
693
+ rightrightarrows: "⇉",
694
+ upuparrows: "⇈",
695
+ downdownarrows: "⇊",
696
+ circlearrowleft: "↺",
697
+ circlearrowright: "↻",
698
+ curvearrowleft: "↶",
699
+ curvearrowright: "↷",
700
+ dashleftarrow: "⇠",
701
+ dashrightarrow: "⇢",
702
+ Lleftarrow: "⇚",
703
+ Rrightarrow: "⇛",
704
+ leftarrowtail: "↢",
705
+ rightarrowtail: "↣",
706
+ looparrowleft: "↫",
707
+ looparrowright: "↬",
708
+ multimap: "⊸",
709
+ // Miscellaneous
710
+ infty: "∞",
711
+ partial: "∂",
712
+ nabla: "∇",
713
+ forall: "∀",
714
+ exists: "∃",
715
+ nexists: "∄",
716
+ emptyset: "∅",
717
+ varnothing: "∅",
718
+ neg: "¬",
719
+ lnot: "¬",
720
+ top: "⊤",
721
+ bot: "⊥",
722
+ angle: "∠",
723
+ measuredangle: "∡",
724
+ sphericalangle: "∢",
725
+ aleph: "ℵ",
726
+ beth: "ℶ",
727
+ gimel: "ℷ",
728
+ daleth: "ℸ",
729
+ hbar: "ℏ",
730
+ hslash: "ℏ",
731
+ ell: "ℓ",
732
+ imath: "ı",
733
+ jmath: "ȷ",
734
+ wp: "℘",
735
+ Re: "ℜ",
736
+ Im: "ℑ",
737
+ mho: "℧",
738
+ complement: "∁",
739
+ surd: "√",
740
+ flat: "♭",
741
+ natural: "♮",
742
+ sharp: "♯",
743
+ clubsuit: "♣",
744
+ diamondsuit: "♦",
745
+ heartsuit: "♥",
746
+ spadesuit: "♠",
747
+ clubs: "♣",
748
+ diamonds: "♦",
749
+ hearts: "♥",
750
+ spades: "♠",
751
+ therefore: "∴",
752
+ because: "∵",
753
+ checkmark: "✓",
754
+ maltese: "✠",
755
+ dag: "†",
756
+ ddag: "‡",
757
+ S: "§",
758
+ P: "¶",
759
+ copyright: "©",
760
+ circledR: "®",
761
+ pounds: "£",
762
+ yen: "¥",
763
+ euro: "€",
764
+ degree: "°",
765
+ prime: "′",
766
+ backprime: "‵",
767
+ colon: ":",
768
+ semicolon: ";",
769
+ neper: "₪",
770
+ square: "□",
771
+ Box: "□",
772
+ blacksquare: "■",
773
+ lozenge: "◊",
774
+ blacklozenge: "⧫",
775
+ triangle: "△",
776
+ blacktriangle: "▴",
777
+ blacktriangledown: "▾",
778
+ blacktriangleleft: "◂",
779
+ blacktriangleright: "▸",
780
+ diagup: "╱",
781
+ diagdown: "╲",
782
+ backepsilon: "϶",
783
+ Game: "⅁",
784
+ eth: "ð",
785
+ // Dots & ellipses
786
+ ldots: "…",
787
+ dots: "…",
788
+ cdots: "⋯",
789
+ vdots: "⋮",
790
+ ddots: "⋱",
791
+ hdots: "…",
792
+ mathellipsis: "…",
793
+ dotsc: "…",
794
+ dotsb: "⋯",
795
+ dotsm: "⋯",
796
+ dotsi: "⋯",
797
+ // Delimiters
798
+ langle: "⟨",
799
+ rangle: "⟩",
800
+ lceil: "⌈",
801
+ rceil: "⌉",
802
+ lfloor: "⌊",
803
+ rfloor: "⌋",
804
+ lbrace: "{",
805
+ rbrace: "}",
806
+ lbrack: "[",
807
+ rbrack: "]",
808
+ vert: "|",
809
+ Vert: "‖",
810
+ lvert: "|",
811
+ rvert: "|",
812
+ lVert: "‖",
813
+ rVert: "‖",
814
+ backslash: "\\",
815
+ slash: "/",
816
+ ulcorner: "⌜",
817
+ urcorner: "⌝",
818
+ llcorner: "⌞",
819
+ lrcorner: "⌟",
820
+ lmoustache: "⎰",
821
+ rmoustache: "⎱",
822
+ lgroup: "⟮",
823
+ rgroup: "⟯",
824
+ bracevert: "⎪",
825
+ // Blackboard / letterlike shortcuts commonly written bare
826
+ Reals: "ℝ",
827
+ Complex: "ℂ",
828
+ Natural: "ℕ",
829
+ Integer: "ℤ",
830
+ Rational: "ℚ",
831
+ };
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Helpers
835
+ // ---------------------------------------------------------------------------
836
+
837
+ /** Map every code point of `text` through `table`; null if any is unmappable. */
838
+ function mapAll(text: string, table: Record<string, string>): string | null {
839
+ let out = "";
840
+ for (const ch of text) {
841
+ const mapped = table[ch];
842
+ if (mapped === undefined) return null;
843
+ out += mapped;
844
+ }
845
+ return out;
846
+ }
847
+
848
+ /** Number of Unicode code points (not UTF-16 units) in `s`. */
849
+ function codePointLength(s: string): number {
850
+ let n = 0;
851
+ for (const _ of s) n++;
852
+ return n;
853
+ }
854
+
855
+ /** Style a single ASCII letter/digit via the math alphanumeric block. */
856
+ function styleAlnum(ch: string, style: FontStyle): string {
857
+ const hole = ALPHA_HOLES[`${style}:${ch}`];
858
+ if (hole) return hole;
859
+ const plane = PLANES[style];
860
+ const code = ch.charCodeAt(0);
861
+ if (code >= 65 && code <= 90) return String.fromCodePoint(plane.upper + (code - 65));
862
+ if (code >= 97 && code <= 122) return String.fromCodePoint(plane.lower + (code - 97));
863
+ if (code >= 48 && code <= 57 && plane.digit !== undefined) return String.fromCodePoint(plane.digit + (code - 48));
864
+ return ch;
865
+ }
866
+
867
+ /** Identity, or math-alphanumeric styling when a font style is active. */
868
+ function styleChar(ch: string, style: FontStyle | null): string {
869
+ if (style === null) return ch;
870
+ const code = ch.charCodeAt(0);
871
+ const isAlnum = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57);
872
+ return isAlnum ? styleAlnum(ch, style) : ch;
873
+ }
874
+
875
+ /** Append a combining mark after each non-space base glyph (accents/radicals). */
876
+ function applyCombining(text: string, mark: string): string {
877
+ let out = "";
878
+ for (const ch of text) out += ch === " " ? ch : ch + mark;
879
+ return out;
880
+ }
881
+
882
+ /** Light unescape for text-mode content (`\&` → `&`, `~` → space). */
883
+ function unescapeText(s: string): string {
884
+ return s.replace(/\\([&%$#_{}\s])/g, "$1").replace(/~/g, " ");
885
+ }
886
+
887
+ const ANSI_FG_RESET = "\x1b[39m";
888
+ const ANSI_BG_RESET = "\x1b[49m";
889
+
890
+ type AnsiColorFormat = "ansi-16m" | "ansi-256";
891
+
892
+ interface AnsiColor {
893
+ foreground: string;
894
+ background: string;
895
+ }
896
+
897
+ interface Rgb {
898
+ r: number;
899
+ g: number;
900
+ b: number;
901
+ }
902
+
903
+ const LATEX_NAMED_COLORS: Record<string, string> = {
904
+ black: "#000000",
905
+ blue: "#0000ff",
906
+ brown: "#a52a2a",
907
+ cyan: "#00ffff",
908
+ darkgray: "#404040",
909
+ darkgrey: "#404040",
910
+ gray: "#808080",
911
+ green: "#00ff00",
912
+ grey: "#808080",
913
+ lightgray: "#c0c0c0",
914
+ lightgrey: "#c0c0c0",
915
+ lime: "#00ff00",
916
+ magenta: "#ff00ff",
917
+ olive: "#808000",
918
+ orange: "#ffa500",
919
+ pink: "#ffc0cb",
920
+ purple: "#800080",
921
+ red: "#ff0000",
922
+ teal: "#008080",
923
+ violet: "#ee82ee",
924
+ white: "#ffffff",
925
+ yellow: "#ffff00",
926
+ };
927
+
928
+ function colorFormat(): AnsiColorFormat {
929
+ return TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
930
+ }
931
+
932
+ function clamp01(n: number): number {
933
+ if (n <= 0) return 0;
934
+ if (n >= 1) return 1;
935
+ return n;
936
+ }
937
+
938
+ function clampByte(n: number): number {
939
+ if (n <= 0) return 0;
940
+ if (n >= 255) return 255;
941
+ return Math.round(n);
942
+ }
943
+
944
+ function cssRgb(rgb: Rgb): string {
945
+ return `rgb(${clampByte(rgb.r)}, ${clampByte(rgb.g)}, ${clampByte(rgb.b)})`;
946
+ }
947
+
948
+ function parseNumber(raw: string): number | null {
949
+ const trimmed = raw.trim();
950
+ if (trimmed === "") return null;
951
+ const value = Number(trimmed.endsWith("%") ? Number(trimmed.slice(0, -1)) / 100 : trimmed);
952
+ return Number.isFinite(value) ? value : null;
953
+ }
954
+
955
+ function parseColorComponents(spec: string, expected: number): number[] | null {
956
+ const parts = spec
957
+ .split(/[,\s]+/u)
958
+ .map(part => part.trim())
959
+ .filter(Boolean);
960
+ if (parts.length !== expected) return null;
961
+ const values: number[] = [];
962
+ for (const part of parts) {
963
+ const value = parseNumber(part);
964
+ if (value === null) return null;
965
+ values.push(value);
966
+ }
967
+ return values;
968
+ }
969
+
970
+ function rgbFromUnit(values: readonly number[]): string | null {
971
+ if (values.length !== 3) return null;
972
+ return cssRgb({
973
+ r: clamp01(values[0] ?? 0) * 255,
974
+ g: clamp01(values[1] ?? 0) * 255,
975
+ b: clamp01(values[2] ?? 0) * 255,
976
+ });
977
+ }
978
+
979
+ function rgbFromByte(values: readonly number[]): string | null {
980
+ if (values.length !== 3) return null;
981
+ return cssRgb({ r: values[0] ?? 0, g: values[1] ?? 0, b: values[2] ?? 0 });
982
+ }
983
+
984
+ function rgbFromCmyk(values: readonly number[]): string | null {
985
+ if (values.length !== 4) return null;
986
+ const c = clamp01(values[0] ?? 0);
987
+ const m = clamp01(values[1] ?? 0);
988
+ const y = clamp01(values[2] ?? 0);
989
+ const k = clamp01(values[3] ?? 0);
990
+ return cssRgb({ r: 255 * (1 - c) * (1 - k), g: 255 * (1 - m) * (1 - k), b: 255 * (1 - y) * (1 - k) });
991
+ }
992
+
993
+ function rgbFromHsv(values: readonly number[], hueScale: number): string | null {
994
+ if (values.length !== 3) return null;
995
+ const h = (((values[0] ?? 0) * hueScale) % 360) / 60;
996
+ const s = clamp01(values[1] ?? 0);
997
+ const v = clamp01(values[2] ?? 0);
998
+ const c = v * s;
999
+ const x = c * (1 - Math.abs((h % 2) - 1));
1000
+ const m = v - c;
1001
+ let r = 0;
1002
+ let g = 0;
1003
+ let b = 0;
1004
+ if (h < 1) {
1005
+ r = c;
1006
+ g = x;
1007
+ } else if (h < 2) {
1008
+ r = x;
1009
+ g = c;
1010
+ } else if (h < 3) {
1011
+ g = c;
1012
+ b = x;
1013
+ } else if (h < 4) {
1014
+ g = x;
1015
+ b = c;
1016
+ } else if (h < 5) {
1017
+ r = x;
1018
+ b = c;
1019
+ } else {
1020
+ r = c;
1021
+ b = x;
1022
+ }
1023
+ return cssRgb({ r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 });
1024
+ }
1025
+
1026
+ function rgbFromWave(spec: string): string | null {
1027
+ const wavelength = parseNumber(spec);
1028
+ if (wavelength === null || wavelength < 380 || wavelength > 780) return null;
1029
+ let r = 0;
1030
+ let g = 0;
1031
+ let b = 0;
1032
+ if (wavelength < 440) {
1033
+ r = -(wavelength - 440) / 60;
1034
+ b = 1;
1035
+ } else if (wavelength < 490) {
1036
+ g = (wavelength - 440) / 50;
1037
+ b = 1;
1038
+ } else if (wavelength < 510) {
1039
+ g = 1;
1040
+ b = -(wavelength - 510) / 20;
1041
+ } else if (wavelength < 580) {
1042
+ r = (wavelength - 510) / 70;
1043
+ g = 1;
1044
+ } else if (wavelength < 645) {
1045
+ r = 1;
1046
+ g = -(wavelength - 645) / 65;
1047
+ } else {
1048
+ r = 1;
1049
+ }
1050
+ const factor =
1051
+ wavelength < 420
1052
+ ? 0.3 + (0.7 * (wavelength - 380)) / 40
1053
+ : wavelength > 700
1054
+ ? 0.3 + (0.7 * (780 - wavelength)) / 80
1055
+ : 1;
1056
+ return cssRgb({ r: r * factor * 255, g: g * factor * 255, b: b * factor * 255 });
1057
+ }
1058
+
1059
+ function normalizeCssColor(spec: string, allowMix: boolean): string | null {
1060
+ const trimmed = spec.trim();
1061
+ if (trimmed === "") return null;
1062
+ if (allowMix && trimmed.includes("!")) {
1063
+ const mixed = resolveMixedColor(trimmed);
1064
+ if (mixed !== null) return mixed;
1065
+ }
1066
+ const named = LATEX_NAMED_COLORS[trimmed] ?? LATEX_NAMED_COLORS[trimmed.toLowerCase()];
1067
+ if (named !== undefined) return named;
1068
+ if (Bun.color(trimmed, "css") !== null) return trimmed;
1069
+ const lower = trimmed.toLowerCase();
1070
+ return lower !== trimmed && Bun.color(lower, "css") !== null ? lower : null;
1071
+ }
1072
+
1073
+ function resolveModeledColor(model: string, spec: string): string | null {
1074
+ const trimmedModel = model.trim();
1075
+ if (trimmedModel === "" || trimmedModel === "named") return normalizeCssColor(spec, true);
1076
+ if (trimmedModel === "HTML" || trimmedModel === "Html" || trimmedModel === "html") {
1077
+ const hex = spec.trim().replace(/^#/u, "");
1078
+ return /^[0-9A-Fa-f]{3,8}$/u.test(hex) ? `#${hex}` : null;
1079
+ }
1080
+ if (trimmedModel === "wave") return rgbFromWave(spec);
1081
+ const lower = trimmedModel.toLowerCase();
1082
+ if (trimmedModel === "RGB") return rgbFromByte(parseColorComponents(spec, 3) ?? []);
1083
+ if (lower === "rgb") return rgbFromUnit(parseColorComponents(spec, 3) ?? []);
1084
+ if (lower === "cmyk") return rgbFromCmyk(parseColorComponents(spec, 4) ?? []);
1085
+ if (lower === "gray" || lower === "grey") {
1086
+ const value = parseColorComponents(spec, 1)?.[0];
1087
+ if (value === undefined) return null;
1088
+ const unit = trimmedModel === "Gray" || trimmedModel === "Grey" ? value / 15 : value;
1089
+ const byte = clamp01(unit) * 255;
1090
+ return cssRgb({ r: byte, g: byte, b: byte });
1091
+ }
1092
+ if (lower === "hsb" || lower === "hsv") {
1093
+ const values = parseColorComponents(spec, 3);
1094
+ if (values === null) return null;
1095
+ return rgbFromHsv(values, trimmedModel === "Hsb" || trimmedModel === "HSV" ? 1 : 360);
1096
+ }
1097
+ return normalizeCssColor(spec, true);
1098
+ }
1099
+
1100
+ function resolveLatexColor(model: string | null, spec: string): string | null {
1101
+ const unescaped = unescapeText(spec).trim();
1102
+ if (unescaped === "") return null;
1103
+ return model === null ? normalizeCssColor(unescaped, true) : resolveModeledColor(model, unescaped);
1104
+ }
1105
+
1106
+ function resolveMixedColor(spec: string): string | null {
1107
+ const parts = spec.split("!");
1108
+ if (parts.length < 2) return null;
1109
+ const first = normalizeCssColor(parts[0] ?? "", false);
1110
+ if (first === null) return null;
1111
+ let current = Bun.color(first, "{rgb}");
1112
+ if (current === null) return null;
1113
+ for (let i = 1; i < parts.length; i += 2) {
1114
+ const percent = parseNumber(parts[i] ?? "");
1115
+ if (percent === null) return null;
1116
+ const nextSpec = parts[i + 1] ?? "white";
1117
+ const nextColor = normalizeCssColor(nextSpec, false);
1118
+ if (nextColor === null) return null;
1119
+ const next = Bun.color(nextColor, "{rgb}");
1120
+ if (next === null) return null;
1121
+ const t = clamp01(percent / 100);
1122
+ current = {
1123
+ r: current.r * t + next.r * (1 - t),
1124
+ g: current.g * t + next.g * (1 - t),
1125
+ b: current.b * t + next.b * (1 - t),
1126
+ };
1127
+ }
1128
+ return cssRgb(current);
1129
+ }
1130
+
1131
+ function ansiColor(model: string | null, spec: string): AnsiColor | null {
1132
+ const css = resolveLatexColor(model, spec);
1133
+ if (css === null) return null;
1134
+ const foreground = Bun.color(css, colorFormat());
1135
+ if (foreground === null || !foreground.startsWith("\x1b[38;")) return null;
1136
+ return { foreground, background: foreground.replace("\x1b[38;", "\x1b[48;") };
1137
+ }
1138
+
1139
+ function restoreAnsi(
1140
+ text: string,
1141
+ fromForeground: string | null,
1142
+ toForeground: string | null,
1143
+ fromBackground: string | null,
1144
+ toBackground: string | null,
1145
+ ): string {
1146
+ if (fromForeground !== toForeground && fromForeground !== null) text += toForeground ?? ANSI_FG_RESET;
1147
+ if (fromBackground !== toBackground && fromBackground !== null) text += toBackground ?? ANSI_BG_RESET;
1148
+ return text;
1149
+ }
1150
+
1151
+ function toSuperscript(text: string, group: boolean): string {
1152
+ if (text === "") return "";
1153
+ const mapped = mapAll(text, SUPERSCRIPT);
1154
+ if (mapped !== null) return mapped;
1155
+ return group ? `^(${text})` : `^${text}`;
1156
+ }
1157
+
1158
+ function toSubscript(text: string, group: boolean): string {
1159
+ if (text === "") return "";
1160
+ const mapped = mapAll(text, SUBSCRIPT);
1161
+ if (mapped !== null) return mapped;
1162
+ return group ? `_(${text})` : `_${text}`;
1163
+ }
1164
+
1165
+ // ---------------------------------------------------------------------------
1166
+ // Parser
1167
+ // ---------------------------------------------------------------------------
1168
+
1169
+ interface Argument {
1170
+ text: string;
1171
+ /** True when the argument came from a `{…}` group (affects fraction/script parens). */
1172
+ group: boolean;
1173
+ }
1174
+
1175
+ const BIG_DELIM = /^(?:[bB]igg?|[bB]igg?[lrm])$/;
1176
+
1177
+ const EXTENSIBLE_ARROWS: Record<string, string> = {
1178
+ xleftarrow: "←",
1179
+ xrightarrow: "→",
1180
+ xleftrightarrow: "↔",
1181
+ xLeftarrow: "⇐",
1182
+ xRightarrow: "⇒",
1183
+ xLeftrightarrow: "⇔",
1184
+ xhookleftarrow: "↩",
1185
+ xhookrightarrow: "↪",
1186
+ xtwoheadleftarrow: "↞",
1187
+ xtwoheadrightarrow: "↠",
1188
+ xmapsto: "↦",
1189
+ xrightharpoonup: "⇀",
1190
+ xrightharpoondown: "⇁",
1191
+ xleftharpoonup: "↼",
1192
+ xleftharpoondown: "↽",
1193
+ xrightleftharpoons: "⇌",
1194
+ xleftrightharpoons: "⇋",
1195
+ };
1196
+
1197
+ class LatexParser {
1198
+ #s: string;
1199
+ #i = 0;
1200
+ #foreground: string | null = null;
1201
+ #background: string | null = null;
1202
+
1203
+ constructor(src: string) {
1204
+ this.#s = src;
1205
+ }
1206
+
1207
+ render(): string {
1208
+ return restoreAnsi(this.parse(null, false), this.#foreground, null, this.#background, null);
1209
+ }
1210
+
1211
+ /** Parse a run until end-of-input, or until `}` when `stopAtBrace`. */
1212
+ parse(style: FontStyle | null, stopAtBrace: boolean): string {
1213
+ let out = "";
1214
+ while (this.#i < this.#s.length) {
1215
+ const c = this.#s[this.#i];
1216
+ if (c === "}") {
1217
+ if (stopAtBrace) break;
1218
+ this.#i++; // stray close brace
1219
+ continue;
1220
+ }
1221
+ out += this.#node(style);
1222
+ }
1223
+ return out;
1224
+ }
1225
+
1226
+ #node(style: FontStyle | null): string {
1227
+ const c = this.#s[this.#i];
1228
+ switch (c) {
1229
+ case "\\":
1230
+ return this.#command(style);
1231
+ case "{":
1232
+ return this.#group(style);
1233
+ case "^":
1234
+ this.#i++;
1235
+ return this.#script(style, true);
1236
+ case "_":
1237
+ this.#i++;
1238
+ return this.#script(style, false);
1239
+ case "$":
1240
+ this.#i++;
1241
+ return ""; // stray delimiter
1242
+ case "~":
1243
+ this.#i++;
1244
+ return " "; // non-breaking space
1245
+ case "&":
1246
+ this.#i++;
1247
+ return " "; // column separator
1248
+ case "'": {
1249
+ let k = 0;
1250
+ while (this.#s[this.#i] === "'") {
1251
+ k++;
1252
+ this.#i++;
1253
+ }
1254
+ return k <= 4 ? PRIMES[k] : PRIMES[1].repeat(k);
1255
+ }
1256
+ case "%": {
1257
+ const nl = this.#s.indexOf("\n", this.#i);
1258
+ this.#i = nl === -1 ? this.#s.length : nl + 1;
1259
+ return "";
1260
+ }
1261
+ default:
1262
+ this.#i++;
1263
+ return styleChar(c, style);
1264
+ }
1265
+ }
1266
+
1267
+ #command(style: FontStyle | null): string {
1268
+ this.#i++; // past backslash
1269
+ if (this.#i >= this.#s.length) return "";
1270
+ const c = this.#s[this.#i];
1271
+ if (!/[A-Za-z]/.test(c)) {
1272
+ this.#i++;
1273
+ switch (c) {
1274
+ case "\\":
1275
+ return "\n"; // row break
1276
+ case "{":
1277
+ case "}":
1278
+ case "$":
1279
+ case "%":
1280
+ case "&":
1281
+ case "#":
1282
+ case "_":
1283
+ case " ":
1284
+ case ".":
1285
+ return c;
1286
+ case ",":
1287
+ case ":":
1288
+ case ";":
1289
+ case ">":
1290
+ return " "; // spacing
1291
+ case "!":
1292
+ return ""; // negative thin space
1293
+ case "/":
1294
+ return ""; // italic correction
1295
+ case "|":
1296
+ return "‖";
1297
+ case "(":
1298
+ case ")":
1299
+ case "[":
1300
+ case "]":
1301
+ return ""; // bare math delimiters that slipped through
1302
+ default:
1303
+ return c;
1304
+ }
1305
+ }
1306
+ let name = "";
1307
+ while (this.#i < this.#s.length && /[A-Za-z]/.test(this.#s[this.#i])) {
1308
+ name += this.#s[this.#i];
1309
+ this.#i++;
1310
+ }
1311
+ if (this.#s[this.#i] === "*") this.#i++; // starred variants (operatorname*, …)
1312
+ return this.#applyCommand(name, style);
1313
+ }
1314
+
1315
+ #applyCommand(name: string, style: FontStyle | null): string {
1316
+ // Fonts: reparse the argument under the requested style.
1317
+ const font = FONTS[name];
1318
+ if (font) return this.#argument(font).text;
1319
+
1320
+ if (TEXT_COMMANDS[name]) return unescapeText(this.#rawArgument());
1321
+
1322
+ if (name === "operatorname") {
1323
+ const fn = unescapeText(this.#rawArgument());
1324
+ return fn + this.#spaceBeforeArg();
1325
+ }
1326
+
1327
+ // Accents → combining marks over each glyph.
1328
+ const accent = ACCENTS[name];
1329
+ if (accent) return applyCombining(this.#argument(style).text, accent);
1330
+
1331
+ if (name === "frac" || name === "dfrac" || name === "tfrac" || name === "cfrac") {
1332
+ const num = this.#argument(style);
1333
+ const den = this.#argument(style);
1334
+ return this.#fraction(num, den);
1335
+ }
1336
+
1337
+ if (name === "genfrac") {
1338
+ const left = this.#argument(style).text;
1339
+ const right = this.#argument(style).text;
1340
+ this.#rawArgument(); // rule thickness
1341
+ this.#rawArgument(); // math style
1342
+ const num = this.#argument(style);
1343
+ const den = this.#argument(style);
1344
+ return left + this.#fraction(num, den) + right;
1345
+ }
1346
+
1347
+ if (name === "binom" || name === "dbinom" || name === "tbinom") {
1348
+ const n = this.#argument(style);
1349
+ const k = this.#argument(style);
1350
+ return `C(${n.text}, ${k.text})`;
1351
+ }
1352
+
1353
+ if (name === "sqrt") return this.#sqrt(style);
1354
+
1355
+ if (name === "not") {
1356
+ const arg = this.#argument(style);
1357
+ return NOT_MAP[arg.text] ?? applyCombining(arg.text, "\u0338");
1358
+ }
1359
+
1360
+ if (name === "overset" || name === "stackrel") return this.#scriptedAbove(style);
1361
+ if (name === "underset") return this.#scriptedBelow(style);
1362
+ if (name === "prescript") return this.#prescript(style);
1363
+
1364
+ const arrow = EXTENSIBLE_ARROWS[name];
1365
+ if (arrow !== undefined) return this.#extensibleArrow(style, arrow);
1366
+
1367
+ if (name === "boxed" || name === "fbox") return `[${this.#argument(style).text}]`;
1368
+ if (name === "overbrace") return `⏞(${this.#argument(style).text})`;
1369
+ if (name === "underbrace") return `⏟(${this.#argument(style).text})`;
1370
+ if (name === "overbracket") return `⎴(${this.#argument(style).text})`;
1371
+ if (name === "underbracket") return `⎵(${this.#argument(style).text})`;
1372
+ if (name === "overparen") return `⏜(${this.#argument(style).text})`;
1373
+ if (name === "underparen") return `⏝(${this.#argument(style).text})`;
1374
+ if (name === "cancel") return applyCombining(this.#argument(style).text, "\u0338");
1375
+ if (name === "bcancel") return applyCombining(this.#argument(style).text, "\u20E5");
1376
+ if (name === "xcancel") return applyCombining(applyCombining(this.#argument(style).text, "\u0338"), "\u20E5");
1377
+ if (name === "sout") return applyCombining(this.#argument(style).text, "\u0336");
1378
+ if (name === "substack") return this.#argument(style).text.replace(NEWLINES, ",");
1379
+
1380
+ if (name === "left" || name === "right" || name === "middle") return this.#delimiter(style);
1381
+
1382
+ if (BIG_DELIM.test(name)) return this.#delimiter(style); // \big \Bigl \Biggr …
1383
+
1384
+ if (name === "begin") return this.#environment(style);
1385
+ if (name === "end") {
1386
+ this.#rawArgument();
1387
+ return "";
1388
+ }
1389
+
1390
+ if (name === "bmod") return " mod ";
1391
+ if (name === "pmod") return `(mod ${this.#argument(style).text})`;
1392
+ if (name === "pod") return `(${this.#argument(style).text})`;
1393
+ if (name === "tag") return `(${this.#argument(style).text})`;
1394
+ if (name === "label") {
1395
+ this.#rawArgument();
1396
+ return "";
1397
+ }
1398
+ if (name === "ref" || name === "eqref") return `(${unescapeText(this.#rawArgument())})`;
1399
+ if (name === "url") return unescapeText(this.#rawArgument());
1400
+ if (name === "href") {
1401
+ this.#rawArgument();
1402
+ return this.#argument(style).text;
1403
+ }
1404
+ if (name === "textcolor") return this.#scopedForeground(this.#readAnsiColor(), style);
1405
+ if (name === "colorbox") return this.#scopedBackground(this.#readAnsiColor(), style);
1406
+ if (name === "fcolorbox") return this.#fcolorbox(style);
1407
+ if (name === "color") return this.#setForeground();
1408
+ if (name === "normalcolor") {
1409
+ const previous = this.#foreground;
1410
+ this.#foreground = null;
1411
+ return previous === null ? "" : ANSI_FG_RESET;
1412
+ }
1413
+ if (name === "phantom" || name === "hphantom") {
1414
+ return " ".repeat(codePointLength(this.#argument(style).text));
1415
+ }
1416
+ if (name === "vphantom") {
1417
+ this.#argument(style);
1418
+ return "";
1419
+ }
1420
+
1421
+ if (FUNCTIONS[name]) return name + this.#spaceBeforeArg();
1422
+
1423
+ const symbol = SYMBOLS[name];
1424
+ if (symbol !== undefined) return symbol;
1425
+
1426
+ // Layout-only commands that carry no visible glyph.
1427
+ switch (name) {
1428
+ case "displaystyle":
1429
+ case "textstyle":
1430
+ case "scriptstyle":
1431
+ case "scriptscriptstyle":
1432
+ case "limits":
1433
+ case "nolimits":
1434
+ case "nonumber":
1435
+ case "notag":
1436
+ case "quad":
1437
+ return name === "quad" ? " " : "";
1438
+ case "qquad":
1439
+ return " ";
1440
+ case "thinspace":
1441
+ case "enspace":
1442
+ case "medspace":
1443
+ case "thickspace":
1444
+ case "space":
1445
+ return " ";
1446
+ case "negthinspace":
1447
+ case "negmedspace":
1448
+ case "negthickspace":
1449
+ return "";
1450
+ }
1451
+
1452
+ // Unknown command: surface the bare name rather than dropping it silently.
1453
+ return name;
1454
+ }
1455
+
1456
+ #group(style: FontStyle | null): string {
1457
+ this.#i++;
1458
+ const outerForeground = this.#foreground;
1459
+ const outerBackground = this.#background;
1460
+ const inner = this.parse(style, true);
1461
+ const innerForeground = this.#foreground;
1462
+ const innerBackground = this.#background;
1463
+ if (this.#s[this.#i] === "}") this.#i++;
1464
+ this.#foreground = outerForeground;
1465
+ this.#background = outerBackground;
1466
+ return restoreAnsi(inner, innerForeground, outerForeground, innerBackground, outerBackground);
1467
+ }
1468
+
1469
+ #readAnsiColor(): AnsiColor | null {
1470
+ const model = this.#optionalRawArgument();
1471
+ return ansiColor(model, this.#rawArgument());
1472
+ }
1473
+
1474
+ #setForeground(): string {
1475
+ const color = this.#readAnsiColor();
1476
+ if (color === null) return "";
1477
+ this.#foreground = color.foreground;
1478
+ return color.foreground;
1479
+ }
1480
+
1481
+ #scopedForeground(color: AnsiColor | null, style: FontStyle | null): string {
1482
+ const outerForeground = this.#foreground;
1483
+ if (color === null) return this.#argument(style).text;
1484
+ this.#foreground = color.foreground;
1485
+ const arg = this.#argument(style).text;
1486
+ const innerForeground = this.#foreground;
1487
+ this.#foreground = outerForeground;
1488
+ return color.foreground + restoreAnsi(arg, innerForeground, outerForeground, this.#background, this.#background);
1489
+ }
1490
+
1491
+ #scopedBackground(color: AnsiColor | null, style: FontStyle | null): string {
1492
+ const outerBackground = this.#background;
1493
+ if (color === null) return this.#argument(style).text;
1494
+ this.#background = color.background;
1495
+ const arg = this.#argument(style).text;
1496
+ const innerBackground = this.#background;
1497
+ this.#background = outerBackground;
1498
+ return color.background + restoreAnsi(arg, this.#foreground, this.#foreground, innerBackground, outerBackground);
1499
+ }
1500
+
1501
+ #fcolorbox(style: FontStyle | null): string {
1502
+ const frameModel = this.#optionalRawArgument();
1503
+ const frame = ansiColor(frameModel, this.#rawArgument());
1504
+ const backgroundModel = this.#optionalRawArgument() ?? frameModel;
1505
+ const background = ansiColor(backgroundModel, this.#rawArgument());
1506
+ const body = this.#scopedBackground(background, style);
1507
+ if (frame === null) return `[${body}]`;
1508
+ return `${frame.foreground}[${this.#foreground ?? ANSI_FG_RESET}${body}${frame.foreground}]${this.#foreground ?? ANSI_FG_RESET}`;
1509
+ }
1510
+
1511
+ /** Read one argument: a `{…}` group, a single command, or a single char. */
1512
+ #argument(style: FontStyle | null): Argument {
1513
+ while (this.#s[this.#i] === " ") this.#i++;
1514
+ const c = this.#s[this.#i];
1515
+ if (c === undefined) return { text: "", group: false };
1516
+ if (c === "{") {
1517
+ this.#i++;
1518
+ const inner = this.parse(style, true);
1519
+ if (this.#s[this.#i] === "}") this.#i++;
1520
+ return { text: inner, group: true };
1521
+ }
1522
+ if (c === "\\") return { text: this.#command(style), group: false };
1523
+ if (c === "^" || c === "_") {
1524
+ // Bare script with no base (e.g. `{}^{n}`): treat the script as the arg.
1525
+ this.#i++;
1526
+ return { text: this.#script(style, c === "^"), group: false };
1527
+ }
1528
+ this.#i++;
1529
+ return { text: styleChar(c, style), group: false };
1530
+ }
1531
+
1532
+ /** Read a raw (unparsed) argument, returning its literal source text. */
1533
+ #rawArgument(): string {
1534
+ while (this.#s[this.#i] === " ") this.#i++;
1535
+ if (this.#s[this.#i] !== "{") {
1536
+ const c = this.#s[this.#i];
1537
+ if (c === undefined) return "";
1538
+ if (c === "\\") {
1539
+ let t = "\\";
1540
+ this.#i++;
1541
+ if (/[A-Za-z]/.test(this.#s[this.#i] ?? "")) {
1542
+ while (/[A-Za-z]/.test(this.#s[this.#i] ?? "")) {
1543
+ t += this.#s[this.#i];
1544
+ this.#i++;
1545
+ }
1546
+ } else {
1547
+ t += this.#s[this.#i] ?? "";
1548
+ this.#i++;
1549
+ }
1550
+ return t;
1551
+ }
1552
+ this.#i++;
1553
+ return c;
1554
+ }
1555
+ this.#i++; // past {
1556
+ let depth = 1;
1557
+ let out = "";
1558
+ while (this.#i < this.#s.length && depth > 0) {
1559
+ const c = this.#s[this.#i];
1560
+ if (c === "\\") {
1561
+ out += c + (this.#s[this.#i + 1] ?? "");
1562
+ this.#i += 2;
1563
+ continue;
1564
+ }
1565
+ if (c === "{") depth++;
1566
+ else if (c === "}") {
1567
+ depth--;
1568
+ if (depth === 0) {
1569
+ this.#i++;
1570
+ break;
1571
+ }
1572
+ }
1573
+ out += c;
1574
+ this.#i++;
1575
+ }
1576
+ return out;
1577
+ }
1578
+
1579
+ #script(style: FontStyle | null, sup: boolean): string {
1580
+ const arg = this.#argument(style);
1581
+ return sup ? toSuperscript(arg.text, arg.group) : toSubscript(arg.text, arg.group);
1582
+ }
1583
+
1584
+ #wrapFrac(arg: Argument): string {
1585
+ return arg.group && codePointLength(arg.text) > 1 ? `(${arg.text})` : arg.text;
1586
+ }
1587
+
1588
+ #fraction(num: Argument, den: Argument): string {
1589
+ const vulgar = VULGAR[`${num.text}/${den.text}`];
1590
+ if (vulgar) return vulgar;
1591
+ return `${this.#wrapFrac(num)}/${this.#wrapFrac(den)}`;
1592
+ }
1593
+
1594
+ #scriptedAbove(style: FontStyle | null): string {
1595
+ const above = this.#argument(style);
1596
+ const base = this.#argument(style);
1597
+ return base.text + toSuperscript(above.text, true);
1598
+ }
1599
+
1600
+ #scriptedBelow(style: FontStyle | null): string {
1601
+ const below = this.#argument(style);
1602
+ const base = this.#argument(style);
1603
+ return base.text + toSubscript(below.text, true);
1604
+ }
1605
+
1606
+ #prescript(style: FontStyle | null): string {
1607
+ const sup = this.#argument(style);
1608
+ const sub = this.#argument(style);
1609
+ const base = this.#argument(style);
1610
+ return toSuperscript(sup.text, true) + toSubscript(sub.text, true) + base.text;
1611
+ }
1612
+
1613
+ #extensibleArrow(style: FontStyle | null, arrow: string): string {
1614
+ const below = this.#optionalArgument(style);
1615
+ const above = this.#argument(style);
1616
+ return arrow + toSuperscript(above.text, true) + (below ? toSubscript(below.text, true) : "");
1617
+ }
1618
+
1619
+ #delimiter(style: FontStyle | null): string {
1620
+ while (this.#s[this.#i] === " ") this.#i++;
1621
+ const c = this.#s[this.#i];
1622
+ if (c === undefined) return "";
1623
+ if (c === ".") {
1624
+ this.#i++;
1625
+ return "";
1626
+ }
1627
+ if (c !== "\\") {
1628
+ this.#i++;
1629
+ return styleChar(c, style);
1630
+ }
1631
+ this.#i++;
1632
+ if (this.#i >= this.#s.length) return "";
1633
+ const d = this.#s[this.#i];
1634
+ if (!/[A-Za-z]/.test(d)) {
1635
+ this.#i++;
1636
+ switch (d) {
1637
+ case ".":
1638
+ return "";
1639
+ case "{":
1640
+ return "{";
1641
+ case "}":
1642
+ return "}";
1643
+ case "|":
1644
+ return "‖";
1645
+ default:
1646
+ return d;
1647
+ }
1648
+ }
1649
+ let name = "";
1650
+ while (this.#i < this.#s.length && /[A-Za-z]/.test(this.#s[this.#i])) {
1651
+ name += this.#s[this.#i];
1652
+ this.#i++;
1653
+ }
1654
+ return SYMBOLS[name] ?? name;
1655
+ }
1656
+
1657
+ #optionalArgument(style: FontStyle | null): Argument | null {
1658
+ const source = this.#optionalRawArgument();
1659
+ if (source === null) return null;
1660
+ return { text: new LatexParser(source).parse(style, false), group: true };
1661
+ }
1662
+
1663
+ #optionalRawArgument(): string | null {
1664
+ while (this.#s[this.#i] === " ") this.#i++;
1665
+ if (this.#s[this.#i] !== "[") return null;
1666
+ this.#i++;
1667
+ let bracketDepth = 1;
1668
+ let braceDepth = 0;
1669
+ let out = "";
1670
+ while (this.#i < this.#s.length && bracketDepth > 0) {
1671
+ const c = this.#s[this.#i];
1672
+ if (c === "\\") {
1673
+ out += c + (this.#s[this.#i + 1] ?? "");
1674
+ this.#i += 2;
1675
+ continue;
1676
+ }
1677
+ if (c === "{") braceDepth++;
1678
+ else if (c === "}" && braceDepth > 0) braceDepth--;
1679
+ else if (braceDepth === 0 && c === "[") bracketDepth++;
1680
+ else if (braceDepth === 0 && c === "]") {
1681
+ bracketDepth--;
1682
+ if (bracketDepth === 0) {
1683
+ this.#i++;
1684
+ break;
1685
+ }
1686
+ }
1687
+ out += c;
1688
+ this.#i++;
1689
+ }
1690
+ return out;
1691
+ }
1692
+
1693
+ #sqrt(style: FontStyle | null): string {
1694
+ while (this.#s[this.#i] === " ") this.#i++;
1695
+ let radical = "√";
1696
+ const index = this.#optionalArgument(style)?.text;
1697
+ if (index !== undefined) {
1698
+ radical = index === "2" ? "√" : index === "3" ? "∛" : index === "4" ? "∜" : `${toSuperscript(index, true)}√`;
1699
+ }
1700
+ const radicand = this.#argument(style).text;
1701
+ return radical + (codePointLength(radicand) > 1 ? `(${radicand})` : radicand);
1702
+ }
1703
+
1704
+ #environment(style: FontStyle | null): string {
1705
+ const env = this.#rawArgument().trim();
1706
+ if (env === "array" || env === "tabular" || env === "array*" || env === "tabular*") {
1707
+ this.#optionalRawArgument();
1708
+ if (this.#s[this.#i] === "{") this.#rawArgument(); // column spec
1709
+ } else if (
1710
+ env === "alignedat" ||
1711
+ env === "alignedat*" ||
1712
+ env === "alignat" ||
1713
+ env === "alignat*" ||
1714
+ env === "gatheredat"
1715
+ ) {
1716
+ this.#optionalRawArgument();
1717
+ if (this.#s[this.#i] === "{") this.#rawArgument(); // column count
1718
+ }
1719
+ let body = "";
1720
+ while (this.#i < this.#s.length) {
1721
+ if (this.#s.startsWith("\\end", this.#i)) {
1722
+ this.#i += 4;
1723
+ this.#rawArgument();
1724
+ break;
1725
+ }
1726
+ body += this.#node(style);
1727
+ }
1728
+ body = body.trim();
1729
+ if (
1730
+ env === "cases" ||
1731
+ env === "cases*" ||
1732
+ env === "dcases" ||
1733
+ env === "dcases*" ||
1734
+ env === "rcases" ||
1735
+ env === "drcases"
1736
+ ) {
1737
+ body = body.replace(/[ \t]*\n+[ \t]*/g, "; ").replace(/ {3,}/g, " ");
1738
+ }
1739
+ const delims = ENV_DELIMS[env];
1740
+ return delims ? delims[0] + body + delims[1] : body;
1741
+ }
1742
+
1743
+ /** A separator space when the next glyph is alphanumeric or a command. */
1744
+ #spaceBeforeArg(): string {
1745
+ const c = this.#s[this.#i];
1746
+ if (c === undefined) return "";
1747
+ return /[A-Za-z0-9\\]/.test(c) ? " " : "";
1748
+ }
1749
+ }
1750
+
1751
+ // ---------------------------------------------------------------------------
1752
+ // Public API
1753
+ // ---------------------------------------------------------------------------
1754
+
1755
+ /**
1756
+ * Convert a bare LaTeX math fragment (no surrounding `$`/`\(` delimiters) to its
1757
+ * best-effort Unicode rendering. Unknown commands degrade to their bare name;
1758
+ * `\\` becomes a newline. Always returns a string (never throws).
1759
+ */
1760
+ export function latexToUnicode(src: string): string {
1761
+ if (typeof src !== "string" || src.length === 0) return src;
1762
+ return new LatexParser(src).render();
1763
+ }
1764
+
1765
+ const NEWLINES = /\n+/g;
1766
+ const BARE_MATH_LINE_COMMAND =
1767
+ /\\(?: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/;
1768
+
1769
+ // Display-math environments eligible for delimiter-less ("bare") rendering in
1770
+ // prose. Deliberately excludes text-mode table/list/float environments
1771
+ // (`tabular`, `itemize`, `verbatim`, `document`, …) so ordinary LaTeX quoted in
1772
+ // prose or fenced code stays verbatim instead of being mangled. Shared by the
1773
+ // bare-math text scanner here and the markdown bare-env block tokenizer.
1774
+ const BARE_MATH_ENVIRONMENTS = new Set([
1775
+ "matrix",
1776
+ "smallmatrix",
1777
+ "pmatrix",
1778
+ "bmatrix",
1779
+ "Bmatrix",
1780
+ "vmatrix",
1781
+ "Vmatrix",
1782
+ "cases",
1783
+ "dcases",
1784
+ "rcases",
1785
+ "drcases",
1786
+ "aligned",
1787
+ "alignedat",
1788
+ "align",
1789
+ "alignat",
1790
+ "split",
1791
+ "gathered",
1792
+ "gatheredat",
1793
+ "gather",
1794
+ "multline",
1795
+ "equation",
1796
+ "eqnarray",
1797
+ "array",
1798
+ "subarray",
1799
+ ]);
1800
+
1801
+ /**
1802
+ * True when `env` is a math environment safe to auto-render without `$`/`\[`
1803
+ * delimiters. The trailing `*` of starred variants (`align*`, `equation*`) is
1804
+ * ignored; text-mode environments (`tabular`, `itemize`, …) return false.
1805
+ */
1806
+ export function isBareMathEnvironment(env: string): boolean {
1807
+ return BARE_MATH_ENVIRONMENTS.has(env.endsWith("*") ? env.slice(0, -1) : env);
1808
+ }
1809
+
1810
+ // Convert delimiter-less math in prose: whole `\begin{env}…\end{env}` math
1811
+ // blocks (optionally pulling in a preceding `lhs =` line) plus standalone
1812
+ // math-shaped lines. A non-math environment is emitted verbatim — wrappers *and*
1813
+ // body — so a quoted `\begin{verbatim}…\frac…\end{verbatim}` is never touched.
1814
+ function renderBareMathInText(text: string): string {
1815
+ let out = "";
1816
+ let i = 0;
1817
+ for (;;) {
1818
+ const begin = text.indexOf("\\begin{", i);
1819
+ if (begin === -1) return out + renderBareMathLines(text.slice(i));
1820
+ const envStart = begin + "\\begin{".length;
1821
+ const envEnd = text.indexOf("}", envStart);
1822
+ if (envEnd === -1) return out + renderBareMathLines(text.slice(i));
1823
+ const env = text.slice(envStart, envEnd);
1824
+ const closeToken = `\\end{${env}}`;
1825
+ const close = text.indexOf(closeToken, envEnd + 1);
1826
+ if (close === -1) {
1827
+ // Unterminated `\begin`: convert lines up to it, then rescan past it.
1828
+ out += renderBareMathLines(text.slice(i, envEnd + 1));
1829
+ i = envEnd + 1;
1830
+ continue;
1831
+ }
1832
+ const blockEnd = close + closeToken.length;
1833
+ if (!isBareMathEnvironment(env)) {
1834
+ // Non-math env: convert preceding lines, emit the whole block verbatim.
1835
+ out += renderBareMathLines(text.slice(i, begin)) + text.slice(begin, blockEnd);
1836
+ i = blockEnd;
1837
+ continue;
1838
+ }
1839
+ const lineStart = text.lastIndexOf("\n", begin - 1) + 1;
1840
+ const prefix = text.slice(lineStart, begin);
1841
+ let start = prefix.includes("\\") || prefix.includes("=") ? lineStart : begin;
1842
+ if (start === begin && prefix.trim() === "" && lineStart > 0) {
1843
+ const previousLineEnd = lineStart - 1;
1844
+ const previousLineStart = text.lastIndexOf("\n", previousLineEnd - 1) + 1;
1845
+ const previousLine = text.slice(previousLineStart, previousLineEnd);
1846
+ if (/[=([{]\s*$/.test(previousLine)) start = previousLineStart;
1847
+ }
1848
+ out += renderBareMathLines(text.slice(i, start));
1849
+ out += latexToUnicode(text.slice(start, blockEnd)).replace(NEWLINES, " ");
1850
+ i = blockEnd;
1851
+ }
1852
+ }
1853
+
1854
+ function renderBareMathLines(text: string): string {
1855
+ let out = "";
1856
+ let lineStart = 0;
1857
+ for (let i = 0; i <= text.length; i++) {
1858
+ if (i !== text.length && text[i] !== "\n") continue;
1859
+ const line = text.slice(lineStart, i);
1860
+ out += shouldRenderBareMathLine(line) ? latexToUnicode(line).replace(NEWLINES, " ") : line;
1861
+ if (i !== text.length) out += "\n";
1862
+ lineStart = i + 1;
1863
+ }
1864
+ return out;
1865
+ }
1866
+
1867
+ function shouldRenderBareMathLine(line: string): boolean {
1868
+ const trimmed = line.trim();
1869
+ if (trimmed === "" || !trimmed.includes("\\")) return false;
1870
+ // A lone `\begin{X}`/`\end{X}` line for a non-math environment never converts.
1871
+ const env = /\\(?:begin|end)\{([^}]*)\}/.exec(trimmed);
1872
+ if (env && !isBareMathEnvironment(env[1])) return false;
1873
+ if (!BARE_MATH_LINE_COMMAND.test(trimmed)) return false;
1874
+ return trimmed.startsWith("\\") || /[=<>^_{}&]/.test(trimmed);
1875
+ }
1876
+
1877
+ /**
1878
+ * Scan prose for math spans — `$$…$$`, `\[…\]` (display) and `$…$`, `\(…\)`
1879
+ * (inline) — and replace each with its Unicode rendering, leaving everything
1880
+ * else verbatim. Newlines inside a span collapse to spaces so the result stays
1881
+ * single-line-safe.
1882
+ *
1883
+ * Inline `$…$` uses pandoc's anti-currency heuristics: the opener must not be
1884
+ * followed by whitespace, the closer must not be preceded by whitespace nor
1885
+ * followed by a digit, and `\$` is treated as a literal dollar — so "$5 and
1886
+ * $10" is left untouched.
1887
+ */
1888
+ export function renderMathInText(text: string): string {
1889
+ if (typeof text !== "string" || text.length === 0) return text;
1890
+ if (
1891
+ !text.includes("$") &&
1892
+ !text.includes("\\(") &&
1893
+ !text.includes("\\[") &&
1894
+ !text.includes("\\begin") &&
1895
+ !BARE_MATH_LINE_COMMAND.test(text)
1896
+ ) {
1897
+ return text;
1898
+ }
1899
+
1900
+ const conv = (inner: string): string => latexToUnicode(inner).replace(NEWLINES, " ");
1901
+ let out = "";
1902
+ let i = 0;
1903
+ const n = text.length;
1904
+ while (i < n) {
1905
+ const c = text[i];
1906
+ if (c === "\\") {
1907
+ const d = text[i + 1];
1908
+ if (d === "\\") {
1909
+ // Escaped backslash: emit verbatim so a following `(`/`[` is plain text.
1910
+ out += "\\\\";
1911
+ i += 2;
1912
+ continue;
1913
+ }
1914
+ if (d === "(") {
1915
+ const close = text.indexOf("\\)", i + 2);
1916
+ if (close !== -1) {
1917
+ out += conv(text.slice(i + 2, close));
1918
+ i = close + 2;
1919
+ continue;
1920
+ }
1921
+ } else if (d === "[") {
1922
+ const close = text.indexOf("\\]", i + 2);
1923
+ if (close !== -1) {
1924
+ out += conv(text.slice(i + 2, close));
1925
+ i = close + 2;
1926
+ continue;
1927
+ }
1928
+ } else if (d === "$") {
1929
+ out += "$";
1930
+ i += 2;
1931
+ continue;
1932
+ }
1933
+ out += c;
1934
+ i++;
1935
+ continue;
1936
+ }
1937
+ if (c === "$") {
1938
+ if (text[i + 1] === "$") {
1939
+ const close = text.indexOf("$$", i + 2);
1940
+ if (close !== -1 && text.slice(i + 2, close).trim().length > 0) {
1941
+ out += conv(text.slice(i + 2, close));
1942
+ i = close + 2;
1943
+ continue;
1944
+ }
1945
+ out += "$$";
1946
+ i += 2;
1947
+ continue;
1948
+ }
1949
+ const close = inlineMathSpanEnd(text, i);
1950
+ if (close !== -1) {
1951
+ out += conv(text.slice(i + 1, close));
1952
+ i = close + 1;
1953
+ continue;
1954
+ }
1955
+ out += "$";
1956
+ i++;
1957
+ continue;
1958
+ }
1959
+ out += c;
1960
+ i++;
1961
+ }
1962
+ return renderBareMathInText(out);
1963
+ }
1964
+
1965
+ /**
1966
+ * Index of the `$` that closes an inline math span opened at `open` (the index
1967
+ * of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
1968
+ * anti-currency heuristics: the opener must not be followed by whitespace, the
1969
+ * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
1970
+ * literal dollar, and the span may not span a newline. Shared by
1971
+ * `renderMathInText` and the markdown math tokenizer so the rule has one home.
1972
+ */
1973
+ export function inlineMathSpanEnd(text: string, open: number): number {
1974
+ const after = text[open + 1];
1975
+ if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") {
1976
+ return -1;
1977
+ }
1978
+ for (let j = open + 1; j < text.length; j++) {
1979
+ const ch = text[j];
1980
+ if (ch === "\\") {
1981
+ j++;
1982
+ continue;
1983
+ }
1984
+ if (ch === "\n") return -1;
1985
+ if (ch === "$") {
1986
+ const prev = text[j - 1];
1987
+ if (prev === " " || prev === "\t") return -1;
1988
+ const next = text[j + 1];
1989
+ if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
1990
+ return text.slice(open + 1, j).trim().length > 0 ? j : -1;
1991
+ }
1992
+ }
1993
+ return -1;
1994
+ }