Spaces:
Running
Running
| import { Fragment, memo, useRef, useEffect } from 'react' | |
| // ── Table detection ────────────────────────────────────────────── | |
| // Require 3+ pipes (2+ columns) so |x| (absolute value) never matches | |
| const TABLE_LINE = /^\|.+\|.+\|$/ | |
| const SEPARATOR_ROW = /^\|[-| :]+\|$/ | |
| // Multiline anchors: only match whole lines starting/ending with |, not mid-line |x| | |
| const BLOCK_SPLIT = /((?:^\|.+\|.+\|[ \t]*$\n?)+)/gm | |
| function isTableBlock(text) { | |
| const lines = text.trim().split('\n').map(l => l.trim()) | |
| // A real table MUST have a separator row (e.g. |---|---|) | |
| const hasSeparator = lines.some(l => SEPARATOR_ROW.test(l)) | |
| if (!hasSeparator) return false | |
| // And at least one data row with 2+ columns | |
| return lines.some(l => TABLE_LINE.test(l) && !SEPARATOR_ROW.test(l)) | |
| } | |
| /** Split a row on unescaped | characters, respecting \| as a literal pipe. */ | |
| function splitTableCells(row, expectedCols) { | |
| const inner = row.slice(1, -1) // strip leading/trailing | | |
| const cells = [] | |
| let current = '' | |
| for (let i = 0; i < inner.length; i++) { | |
| if (inner[i] === '\\' && i + 1 < inner.length && inner[i + 1] === '|') { | |
| current += '|' // escaped pipe → literal | | |
| i++ | |
| } else if (inner[i] === '|') { | |
| cells.push(current.trim()) | |
| current = '' | |
| } else { | |
| current += inner[i] | |
| } | |
| } | |
| cells.push(current.trim()) | |
| // If we got more cells than expected (unescaped | inside content), | |
| // merge the extras back into the cell before the last column. | |
| if (expectedCols && cells.length > expectedCols) { | |
| const merged = [] | |
| const extraCount = cells.length - expectedCols | |
| for (let i = 0; i < cells.length; i++) { | |
| if (i > 0 && i <= extraCount) { | |
| merged[merged.length - 1] += ' | ' + cells[i] | |
| } else { | |
| merged.push(cells[i]) | |
| } | |
| } | |
| return merged | |
| } | |
| return cells | |
| } | |
| function parseTable(text) { | |
| const allRows = text.trim().split('\n').map(l => l.trim()).filter(l => TABLE_LINE.test(l)) | |
| // Detect column count from the separator row (e.g. |---|---|) | |
| const sepRow = allRows.find(r => SEPARATOR_ROW.test(r)) | |
| const expectedCols = sepRow ? sepRow.slice(1, -1).split('|').length : null | |
| const dataRows = allRows.filter(l => !SEPARATOR_ROW.test(l)) | |
| return ( | |
| <table className="math-table"> | |
| <tbody> | |
| {dataRows.map((row, i) => { | |
| const cells = splitTableCells(row, expectedCols) | |
| const Tag = i === 0 ? 'th' : 'td' | |
| return ( | |
| <tr key={i} className={i % 2 === 0 ? 'math-table-row-alt' : ''}> | |
| {cells.map((cell, j) => ( | |
| <Tag key={j} className="math-table-cell"> | |
| {cell} | |
| </Tag> | |
| ))} | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| ) | |
| } | |
| // ── Math patterns ──────────────────────────────────────────────── | |
| const MATH_FUNCTIONS_STR = "sqrt|frac|sum|int|log|ln|sin|cos|tan|sec|csc|cot|arcsin|arccos|arctan|sinh|cosh|tanh|exp|lim|max|min|det"; | |
| const GREEK_LETTERS_STR = "alpha|beta|gamma|theta|Theta|pi|infty|Omega"; | |
| const MATH_PATTERN = /(\$\$[\s\S]+?\$\$|\$[^$]+\$|\\\([\s\S]+?\\\)|\\\[[\s\S]+?\\\])/g | |
| const LEGACY_MATH_PATTERN = new RegExp( | |
| `(\\\\(?:${MATH_FUNCTIONS_STR}|${GREEK_LETTERS_STR})(?:[\\^_]\\s*(?:\\{[^{}]*\\}|[A-Za-z0-9]+))*(?:\\s*(?:\\{[^{}]*\\}|\\([^()]*\\)|[A-Za-z0-9]+))*|` + | |
| `\\\\?(?:Theta|Omega)\\s*\\([^()]+\\)|` + | |
| `\\bO\\s*\\([^()]+\\)|` + | |
| `\\b(?:${MATH_FUNCTIONS_STR})\\s*(?:[\\^_]\\s*(?:\\{[^{}]*\\}|[A-Za-z0-9]+))*\\s*\\([^()]+\\)|` + | |
| `\\b[A-Za-z0-9]+\\s*\\^\\s*(?:\\{[^{}]+\\}|\\([^()]+\\)|[A-Za-z0-9]+)(?:\\s*[-+]\\s*\\d+)?|` + | |
| `\\b(?:[A-Za-z]\\s+)?(?:log|ln)(?:\\s*\\^\\s*(?:\\{[^{}]+\\}|\\([^()]+\\)|[A-Za-z0-9]+))?\\s+[A-Za-z]\\b)`, | |
| "g" | |
| ); | |
| const RAW_LATEX_HINT = new RegExp( | |
| `\\\\(${MATH_FUNCTIONS_STR}|${GREEK_LETTERS_STR})|\\\\?(Theta|Omega)\\s*\\(|\\bO\\s*\\(|[\\^_]\\{?[\\w\\d]+|\\b(?:[A-Za-z]\\s+)?(?:log|ln)(?:\\s*\\^\\s*[\\w\\d]+)?\\s+[A-Za-z]\\b` | |
| ); | |
| const MATH_WORDS = new Set([ | |
| 'O', 'o', 'n', 'h', 'm', 'k', 'x', 'y', 'z', | |
| 'log', 'ln', 'sin', 'cos', 'tan', 'sec', 'csc', 'cot', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh', 'exp', 'lim', 'max', 'min', 'det', | |
| 'sqrt', 'frac', 'sum', 'int', | |
| 'Theta', 'theta', 'Omega', | |
| 'alpha', 'beta', 'gamma', 'pi', 'infty', | |
| ]) | |
| function normalizeMath(math) { | |
| return math | |
| .replace(/^\\\(/, '') | |
| .replace(/\\\)$/, '') | |
| .replace(/^\\\[/, '') | |
| .replace(/\\\]$/, '') | |
| .replace(/^\$\$/, '') | |
| .replace(/\$\$$/, '') | |
| .replace(/^\$/, '') | |
| .replace(/\$$/, '') | |
| .replace(/(^|[^\\])\blog~/g, '$1\\log ') | |
| .replace(/\^\(([^()]+)\)/g, '^{$1}') | |
| .replace(/(^|[^\\])\b(log|ln|sin|cos|tan|sec|csc|cot|arcsin|arccos|arctan|sinh|cosh|tanh|exp|lim|max|min|det)\b/g, '$1\\$2') | |
| .replace(/(^|[^\\])\bTheta\b/g, '$1\\Theta') | |
| .replace(/(^|[^\\])\bOmega\b/g, '$1\\Omega') | |
| } | |
| function mathAriaLabel(math) { | |
| const label = normalizeMath(math) | |
| .replace(/\\frac/g, ' fraction ') | |
| .replace(/\\sqrt/g, ' square root ') | |
| .replace(/\\sum/g, ' summation ') | |
| .replace(/\\int/g, ' integral ') | |
| .replace(/\\log/g, ' log ') | |
| .replace(/\\ln/g, ' natural log ') | |
| .replace(/\\sin/g, ' sine ') | |
| .replace(/\\cos/g, ' cosine ') | |
| .replace(/\\tan/g, ' tangent ') | |
| .replace(/\\Theta/g, ' Theta ') | |
| .replace(/\\Omega/g, ' Omega ') | |
| .replace(/\\alpha/g, ' alpha ') | |
| .replace(/\\beta/g, ' beta ') | |
| .replace(/\\gamma/g, ' gamma ') | |
| .replace(/\\theta/g, ' theta ') | |
| .replace(/\\pi/g, ' pi ') | |
| .replace(/\\infty/g, ' infinity ') | |
| .replace(/\^/g, ' to the power of ') | |
| .replace(/_/g, ' subscript ') | |
| .replace(/\\/g, ' ') | |
| .replace(/[{}]/g, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| return `Math expression: ${label}` | |
| } | |
| function isStandaloneMath(text) { | |
| const trimmed = text.trim() | |
| if (!RAW_LATEX_HINT.test(trimmed)) return false | |
| // Strip LaTeX commands (e.g., \to, \infty, \text) so their names don't trigger the plain-text word check | |
| const textWithoutCommands = trimmed.replace(/\\[A-Za-z]+/g, '') | |
| const words = textWithoutCommands.match(/[A-Za-z]+/g) || [] | |
| if (words.some(word => word.length > 1 && !MATH_WORDS.has(word))) { | |
| return false | |
| } | |
| return true | |
| } | |
| function splitLegacyMath(text) { | |
| const parts = [] | |
| let lastIndex = 0 | |
| for (const match of text.matchAll(LEGACY_MATH_PATTERN)) { | |
| const value = match[0] | |
| const index = match.index ?? 0 | |
| if (index > lastIndex) { | |
| parts.push({ type: 'text', value: text.slice(lastIndex, index) }) | |
| } | |
| parts.push({ type: 'math', value }) | |
| lastIndex = index + value.length | |
| } | |
| if (lastIndex < text.length) { | |
| parts.push({ type: 'text', value: text.slice(lastIndex) }) | |
| } | |
| return parts | |
| } | |
| function SafeInlineMath({ math }) { | |
| const containerRef = useRef(null) | |
| const ariaLabel = mathAriaLabel(math) | |
| useEffect(() => { | |
| if (containerRef.current && window.katex) { | |
| try { | |
| window.katex.render(normalizeMath(math), containerRef.current, { | |
| displayMode: false, | |
| throwOnError: false, | |
| errorColor: '#cc0000', | |
| }) | |
| } catch (err) { | |
| console.error("KaTeX RenderError:", err, "Original:", math) | |
| containerRef.current.innerText = math | |
| } | |
| } | |
| }, [math]) | |
| return <span ref={containerRef} role="math" aria-label={ariaLabel} className="inline-math" /> | |
| } | |
| function SafeBlockMath({ math }) { | |
| const containerRef = useRef(null) | |
| const ariaLabel = mathAriaLabel(math) | |
| useEffect(() => { | |
| if (containerRef.current && window.katex) { | |
| try { | |
| window.katex.render(normalizeMath(math), containerRef.current, { | |
| displayMode: true, | |
| throwOnError: false, | |
| errorColor: '#cc0000', | |
| }) | |
| } catch (err) { | |
| console.error("KaTeX RenderError:", err, "Original:", math) | |
| containerRef.current.innerText = math | |
| } | |
| } | |
| }, [math]) | |
| return <div ref={containerRef} role="math" aria-label={ariaLabel} className="block-math my-4 flex justify-center" /> | |
| } | |
| function renderMathSegment(text, keyPrefix = '') { | |
| const hasExplicitMath = text.search(MATH_PATTERN) !== -1 | |
| if (!hasExplicitMath && isStandaloneMath(text)) { | |
| return <SafeInlineMath math={text} /> | |
| } | |
| if (!hasExplicitMath) { | |
| const legacyParts = splitLegacyMath(text) | |
| const hasLegacyMath = legacyParts.some(part => part.type === 'math') | |
| if (hasLegacyMath) { | |
| return ( | |
| <> | |
| {legacyParts.map((part, index) => { | |
| if (part.type === 'math') { | |
| return <SafeInlineMath key={`${keyPrefix}l${index}`} math={part.value} /> | |
| } | |
| return <Fragment key={`${keyPrefix}l${index}`}>{part.value}</Fragment> | |
| })} | |
| </> | |
| ) | |
| } | |
| return <>{text}</> | |
| } | |
| const parts = text.split(MATH_PATTERN) | |
| return ( | |
| <> | |
| {parts.map((part, index) => { | |
| if (!part) return null | |
| const isBlock = | |
| part.startsWith('$$') || | |
| part.startsWith('\\[') | |
| const isInline = | |
| part.startsWith('$') || | |
| part.startsWith('\\(') | |
| if (isBlock) { | |
| return <SafeBlockMath key={`${keyPrefix}m${index}`} math={part} /> | |
| } | |
| if (isInline) { | |
| return <SafeInlineMath key={`${keyPrefix}m${index}`} math={part} /> | |
| } | |
| return <Fragment key={`${keyPrefix}m${index}`}>{part}</Fragment> | |
| })} | |
| </> | |
| ) | |
| } | |
| const MathText = memo(function MathText({ children }) { | |
| const text = String(children ?? '') | |
| if (!text.trim()) return null | |
| // Split on table blocks first, then parse math in non-table segments | |
| const segments = text.split(BLOCK_SPLIT) | |
| const hasTable = segments.some(seg => isTableBlock(seg)) | |
| if (!hasTable) { | |
| return renderMathSegment(text) | |
| } | |
| return ( | |
| <> | |
| {segments.map((seg, i) => | |
| isTableBlock(seg) | |
| ? <Fragment key={i}>{parseTable(seg)}</Fragment> | |
| : <Fragment key={i}>{renderMathSegment(seg, `s${i}`)}</Fragment> | |
| )} | |
| </> | |
| ) | |
| }) | |
| export default MathText; | |