export interface MathBlock { raw: string; tex: string; display: boolean; range: [number, number]; } export function detectMathBlocks(text: string): MathBlock[] { const blocks: MathBlock[] = []; const protectedRanges = findMarkdownCodeRanges(text); let protectedIndex = 0; let i = 0; while (i < text.length) { const protectedRange = protectedRanges[protectedIndex]; if (protectedRange && i >= protectedRange[0]) { i = protectedRange[1]; protectedIndex += 1; continue; } if (text.startsWith("$$", i) && !isEscaped(text, i)) { const end = findUnescaped(text, "$$", i + 2); if (end !== -1) { blocks.push(makeBlock(text, i, end + 2, true, 2, 2)); i = end + 2; continue; } } if (text.startsWith("\\[", i) && !isEscaped(text, i)) { const end = findUnescaped(text, "\\]", i + 2); if (end !== -1) { blocks.push(makeBlock(text, i, end + 2, true, 2, 2)); i = end + 2; continue; } } if (text.startsWith("\\(", i) && !isEscaped(text, i)) { const end = findUnescaped(text, "\\)", i + 2); if (end !== -1) { blocks.push(makeBlock(text, i, end + 2, false, 2, 2)); i = end + 2; continue; } } if (text[i] === "$" && isLikelyInlineDollarOpen(text, i)) { const end = findInlineDollarClose(text, i + 1); if (end !== -1) { blocks.push(makeBlock(text, i, end + 1, false, 1, 1)); i = end + 1; continue; } } i += 1; } return blocks; } function findMarkdownCodeRanges(text: string): Array<[number, number]> { const ranges: Array<[number, number]> = []; let fence: { marker: "`" | "~"; length: number; start: number } | undefined; let lineStart = 0; while (lineStart < text.length) { const newline = text.indexOf("\n", lineStart); const lineEnd = newline === -1 ? text.length : newline + 1; const line = text.slice(lineStart, newline === -1 ? text.length : newline); const fenceMatch = /^(?: {0,3})(`{3,}|~{3,})/u.exec(line); if (fence) { if ( fenceMatch && fenceMatch[1]![0] === fence.marker && fenceMatch[1]!.length >= fence.length ) { ranges.push([fence.start, lineEnd]); fence = undefined; } lineStart = lineEnd; continue; } if (fenceMatch) { const run = fenceMatch[1]!; fence = { marker: run[0] as "`" | "~", length: run.length, start: lineStart }; lineStart = lineEnd; continue; } let cursor = lineStart; const contentEnd = newline === -1 ? text.length : newline; while (cursor < contentEnd) { if (text[cursor] !== "`") { cursor += 1; continue; } const openStart = cursor; while (text[cursor] === "`") cursor += 1; const runLength = cursor - openStart; const close = findBacktickRun(text, cursor, contentEnd, runLength); if (close === -1) { ranges.push([openStart, contentEnd]); break; } ranges.push([openStart, close + runLength]); cursor = close + runLength; } lineStart = lineEnd; } if (fence) ranges.push([fence.start, text.length]); return ranges; } function findBacktickRun(text: string, from: number, end: number, length: number): number { for (let index = from; index < end; index += 1) { if (text[index] !== "`") continue; let runEnd = index; while (runEnd < end && text[runEnd] === "`") runEnd += 1; if (runEnd - index === length) return index; index = runEnd - 1; } return -1; } function makeBlock( text: string, start: number, end: number, display: boolean, openLength: number, closeLength: number, ): MathBlock { const raw = text.slice(start, end); return { raw, tex: raw.slice(openLength, raw.length - closeLength).trim(), display, range: [start, end], }; } function findUnescaped(text: string, needle: string, from: number): number { for (let i = from; i < text.length; i += 1) { if (text.startsWith(needle, i) && !isEscaped(text, i)) return i; } return -1; } function findInlineDollarClose(text: string, from: number): number { for (let i = from; i < text.length; i += 1) { if (text[i] !== "$" || isEscaped(text, i)) continue; if (text[i + 1] === "$") return -1; if (/\s/.test(text[i - 1] ?? "")) continue; return i; } return -1; } function isLikelyInlineDollarOpen(text: string, index: number): boolean { if (isEscaped(text, index)) return false; if (text[index + 1] === "$") return false; const next = text[index + 1] ?? ""; const prev = text[index - 1] ?? ""; if (!next || /[\s\d.,]/.test(next)) return false; if (/[A-Za-z0-9]/.test(prev)) return false; return true; } function isEscaped(text: string, index: number): boolean { let slashCount = 0; for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) slashCount += 1; return slashCount % 2 === 1; } const SYMBOLS: Record = { alpha: "α", beta: "β", gamma: "γ", delta: "δ", epsilon: "ε", varepsilon: "ε", zeta: "ζ", eta: "η", theta: "θ", vartheta: "ϑ", iota: "ι", kappa: "κ", lambda: "λ", mu: "μ", nu: "ν", xi: "ξ", pi: "π", rho: "ρ", sigma: "σ", tau: "τ", upsilon: "υ", phi: "φ", varphi: "φ", chi: "χ", psi: "ψ", omega: "ω", Gamma: "Γ", Delta: "Δ", Theta: "Θ", Lambda: "Λ", Xi: "Ξ", Pi: "Π", Sigma: "Σ", Phi: "Φ", Psi: "Ψ", Omega: "Ω", sum: "∑", prod: "∏", infty: "∞", le: "≤", leq: "≤", ge: "≥", geq: "≥", neq: "≠", ne: "≠", approx: "≈", times: "×", cdot: "·", pm: "±", to: "→", rightarrow: "→", }; const SUPERSCRIPTS: Record = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", n: "ⁿ", i: "ⁱ", }; export function latexToTerminalText(input: string): string { let text = stripMathDelimiters(input.trim()); text = replaceTwoArgCommand(text, "frac", (a, b) => `(${latexToTerminalText(a)})/(${latexToTerminalText(b)})`); text = replaceOneArgCommand(text, "sqrt", (x) => `√(${latexToTerminalText(x)})`); text = replaceOneArgCommand(text, "mathrm", (x) => latexToTerminalText(x)); text = replaceOneArgCommand(text, "text", (x) => x); text = replaceAccentCommand(text, "bar", "bar", "\u0304"); text = replaceAccentCommand(text, "hat", "hat", "\u0302"); text = text.replace(/\\(left|right)\s*/g, ""); text = text.replace(/\\([A-Za-z]+)/g, (_match, name: string) => SYMBOLS[name] ?? name); text = text.replace(/\^\{([^{}]+)\}/g, (_match, body: string) => toSuperscript(body)); text = text.replace(/\^([A-Za-z0-9+\-=()])/g, (_match, body: string) => toSuperscript(body)); text = text.replace(/_\{([^{}]+)\}/g, "_$1"); text = text.replace(/_([A-Za-z0-9])/g, "_$1"); text = text.replace(/[{}]/g, ""); text = text.replace(/\s+/g, " ").trim(); return text; } function stripMathDelimiters(text: string): string { const pairs: Array<[string, string]> = [ ["$$", "$$"], ["$", "$"], ["\\(", "\\)"], ["\\[", "\\]"], ]; for (const [open, close] of pairs) { if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) { return text.slice(open.length, text.length - close.length).trim(); } } return text; } function replaceTwoArgCommand(text: string, command: string, render: (first: string, second: string) => string): string { let current = text; while (true) { const next = replaceOneTwoArgCommandPass(current, command, render); if (next === current) return current; current = next; } } function replaceOneTwoArgCommandPass(text: string, command: string, render: (first: string, second: string) => string): string { const needle = `\\${command}`; for (let i = 0; i < text.length; i += 1) { if (!text.startsWith(needle, i)) continue; let cursor = skipSpaces(text, i + needle.length); const first = readBracedGroup(text, cursor); if (!first) continue; cursor = skipSpaces(text, first.end); const second = readBracedGroup(text, cursor); if (!second) continue; return text.slice(0, i) + render(first.body, second.body) + text.slice(second.end); } return text; } function replaceOneArgCommand(text: string, command: string, render: (body: string) => string): string { let current = text; const needle = `\\${command}`; while (true) { let changed = false; let output = ""; let i = 0; while (i < current.length) { if (!current.startsWith(needle, i)) { output += current[i]; i += 1; continue; } const groupStart = skipSpaces(current, i + needle.length); const group = readBracedGroup(current, groupStart); if (!group) { output += current[i]; i += 1; continue; } output += render(group.body); i = group.end; changed = true; } if (!changed) return current; current = output; } } function replaceAccentCommand(text: string, command: string, fallback: string, combining: string): string { const braced = replaceOneArgCommand(text, command, (body) => accentBody(latexToTerminalText(body), fallback, combining)); return braced.replace(new RegExp(`\\\\${command}\\s+([A-Za-z])`, "g"), (_match, body: string) => accentBody(body, fallback, combining), ); } function accentBody(body: string, fallback: string, combining: string): string { if (combining === "\u0302" && body === "y") return "ŷ"; if (body.length === 1) return body + combining; return `${fallback}(${body})`; } function readBracedGroup(text: string, start: number): { body: string; end: number } | null { if (text[start] !== "{") return null; let depth = 0; for (let i = start; i < text.length; i += 1) { if (text[i] === "{" && !isEscaped(text, i)) depth += 1; if (text[i] === "}" && !isEscaped(text, i)) { depth -= 1; if (depth === 0) return { body: text.slice(start + 1, i), end: i + 1 }; } } return null; } function skipSpaces(text: string, start: number): number { let i = start; while (/\s/.test(text[i] ?? "")) i += 1; return i; } function toSuperscript(body: string): string { let out = ""; for (const char of body) { const converted = SUPERSCRIPTS[char]; if (!converted) return `^${body}`; out += converted; } return out; }