/** * Math / formula normalisation for student-facing AI answers. * * Pipeline (applied after sanitizeAIResponse): * 1. Convert LaTeX delimiters \( … \) → $ … $ (inline math) * 2. Convert LaTeX delimiters \[ … \] → $$ … $$ (display math) * 3. Collapse excess whitespace produced by stripping reasoning blocks. * * Formulas inside the resulting $ … $ blocks are left untouched — KaTeX * handles them at render time via remark-math + rehype-katex. * * chemicalText() is exported separately as a fallback plain-text formatter * for contexts where ReactMarkdown / KaTeX is not available (e.g. tooltips, * clipboard copy, alt-text). It converts common TeX subscripts/arrows to * Unicode equivalents. */ // --------------------------------------------------------------------------- // 1. LaTeX delimiter normalisation → remark-math compatible $ / $$ delimiters // --------------------------------------------------------------------------- /** * Convert \( … \) and \[ … \] delimiters to $ … $ and $$ … $$. * Also cleans up a handful of stray TeX artefacts that appear outside math * when models hallucinate raw LaTeX into prose. */ export function normalizeMathMarkdown(text: string): string { if (!text) return ""; let t = text; // 1a. Display math \[ … \] → $$ … $$ // Use non-greedy multiline match so one call handles multiple blocks. t = t.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_m, inner: string) => `$$${inner.trim()}$$`); // 1b. Inline math \( … \) → $ … $ t = t.replace(/\\\(\s*([\s\S]*?)\s*\\\)/g, (_m, inner: string) => `$${inner.trim()}$`); // 1c. Strip stray \text{} wrappers that ended up outside any math block // (models sometimes write \text{something} directly in prose). // Inside $…$ they are valid and must be left alone — handled by KaTeX. // This regex only removes them when NOT preceded by $ on the same line. t = t.replace(/\\text\{([^}]*)\}/g, (match, inner: string, offset: number) => { // Check if inside a $…$ block by counting $ signs before this position const before = t.slice(0, offset); const dollarsBefore = (before.match(/\$/g) ?? []).length; // If odd number of $ before → inside inline math → leave it (return original) if (dollarsBefore % 2 === 1) return match; return inner; // outside math — just keep the inner text }); // 1d. Replace \rightarrow and \to outside math blocks with → t = replaceOutsideMath(t, /\\(?:rightarrow|to)\b/g, "→"); // 1e. Replace \Rightarrow outside math blocks with ⟹ t = replaceOutsideMath(t, /\\Rightarrow\b/g, "⟹"); // 1f. Replace \cdot outside math with · t = replaceOutsideMath(t, /\\cdot\b/g, "·"); // 1g. Replace \times outside math with × t = replaceOutsideMath(t, /\\times\b/g, "×"); // 1h. Collapse 3+ blank lines (side-effect of removing reasoning blocks) t = t.replace(/\n{3,}/g, "\n\n").trim(); return t; } /** * Apply a regex replacement only to text segments that are outside $ math $ delimiters. * Walks the string, alternating between "outside math" and "inside math" segments. */ function replaceOutsideMath(text: string, pattern: RegExp, replacement: string): string { // Split on $ boundaries while preserving the delimiters const segments = text.split(/(\$\$[\s\S]*?\$\$|\$[^$\n]*?\$)/); return segments .map((seg, i) => { // Even indices → outside math; odd indices → inside a $…$ or $$…$$ block if (i % 2 === 0) return seg.replace(pattern, replacement); return seg; }) .join(""); } // --------------------------------------------------------------------------- // 2. Plain-text chemical / math formatter (fallback / clipboard) // --------------------------------------------------------------------------- const SUB_MAP: Record = { "0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉", }; const SUP_MAP: Record = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ", }; function toSubscript(digits: string): string { return [...digits].map((d) => SUB_MAP[d] ?? d).join(""); } function toSuperscript(digits: string): string { return [...digits].map((d) => SUP_MAP[d] ?? d).join(""); } /** * Convert chemical/math TeX into readable Unicode plain-text. * Designed for clipboard copy, tooltips, and accessibility alt-text. * * Examples: * C_6H_{12}O_6 → C₆H₁₂O₆ * CO_2 → CO₂ * H_2O → H₂O * x^2 → x² * \rightarrow → → * \text{foo} → foo * \( E = mc^2 \) → E = mc² */ export function chemicalText(raw: string): string { if (!raw) return ""; let t = raw; // Strip LaTeX display/inline delimiters t = t.replace(/\\\[|\\\]/g, "").replace(/\\\(|\\\)/g, ""); // \text{…} → inner text t = t.replace(/\\text\{([^}]*)\}/g, "$1"); // Arrows and operators — keep trailing space (→ B, × 3 etc.) t = t.replace(/\\(?:rightarrow|to)\b/g, "→"); t = t.replace(/\\Rightarrow\b/g, "⟹"); t = t.replace(/\\(?:leftarrow)\b/g, "←"); t = t.replace(/\\cdot\b/g, "·"); t = t.replace(/\\times\b/g, "×"); t = t.replace(/\\pm\b/g, "±"); t = t.replace(/\\infty\b/g, "∞"); // Uppercase Greek used as thermodynamic prefix symbols (ΔH, ΔG, ΔS, ΣF …) // These appear as "\Delta H" in LaTeX — the space is a LaTeX artifact, not prose space. // Consume optional trailing space so output is "ΔH" not "Δ H". t = t.replace(/\\Delta\b\s*/g, "Δ"); t = t.replace(/\\Gamma\b\s*/g, "Γ"); t = t.replace(/\\Sigma\b\s*/g, "Σ"); // Lowercase Greek — keep trailing space (λ = 500, not λ=500) t = t.replace(/\\alpha\b/g, "α"); t = t.replace(/\\beta\b/g, "β"); t = t.replace(/\\gamma\b/g, "γ"); t = t.replace(/\\lambda\b/g, "λ"); t = t.replace(/\\mu\b/g, "μ"); t = t.replace(/\\pi\b/g, "π"); t = t.replace(/\\theta\b/g, "θ"); t = t.replace(/\\omega\b/g, "ω"); t = t.replace(/\\sigma\b/g, "σ"); // Subscript: _{12} or _2 t = t.replace(/\_\{([0-9+\-n]+)\}/g, (_m, d: string) => toSubscript(d)); t = t.replace(/\_([0-9])/g, (_m, d: string) => toSubscript(d)); // Superscript: ^{2} or ^2 t = t.replace(/\^\{([0-9+\-n]+)\}/g, (_m, d: string) => toSuperscript(d)); t = t.replace(/\^([0-9+\-n])/g, (_m, d: string) => toSuperscript(d)); // Strip remaining bare backslash-commands we didn't handle t = t.replace(/\\[a-zA-Z]+\b/g, ""); // Strip leftover braces t = t.replace(/[{}]/g, ""); return t.trim(); }