import katex from 'katex'; export type QuizMathTextSegment = | { type: 'text'; value: string; } | { type: 'math'; value: string; html: string; displayMode: boolean; }; interface Delimiter { open: string; close: string; displayMode: boolean; } const DELIMITERS: Delimiter[] = [ { open: '$$', close: '$$', displayMode: true }, { open: '\\[', close: '\\]', displayMode: true }, { open: '\\(', close: '\\)', displayMode: false }, { open: '$', close: '$', displayMode: false }, ]; const LATEX_COMMAND_RE = /\\[a-zA-Z]+/; const LATEX_COMMAND_GLOBAL_RE = /\\[a-zA-Z]+/g; const INLINE_OPERATOR_RE = /[A-Za-z0-9)\]}]\s*[+\-*/]\s*[A-Za-z0-9({\\]/; const FORMULA_CHAR_RE = /^[\s0-9A-Za-z\\{}()[\]^_+\-*/=<>≤≥≈.,:;|!%√πθαβγδελμνρσφωΑΒΓΔΘΛΜΝΠΡΣΦΩ]+$/; const WORD_RE = /[A-Za-z]{3,}/g; const EXPLICIT_DELIMITER_RE = /\\\[|\\\(|(?:^|[^\\])\$\$?/; const LETTER_OR_COMMAND_RE = /[A-Za-z\\πθαβγδελμνρσφωΑΒΓΔΘΛΜΝΠΡΣΦΩ]/; const EQUATION_OR_POWER_RE = /[=<>≤≥≈^_]/; const SINGLE_SYMBOL_RE = /^(?:[A-Za-z]|\\[a-zA-Z]+|\d+(?:\.\d+)?)$/; const CODE_LIKE_BOOLEAN_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:true|false|null|undefined|none)$/i; const CODE_LIKE_INCREMENT_RE = /^([ijk])\s*=\s*\1\s*[+-]\s*1$/i; const SENTENCE_BOUNDARY_CHAR_RE = /^[.,!?;:]$/; const TOKEN_RE = /\S+/g; export function isLikelyStandaloneMathText(value: string): boolean { const text = value.trim(); if (text.length < 3) return false; if (!FORMULA_CHAR_RE.test(text)) return false; if (!LETTER_OR_COMMAND_RE.test(text) && !EQUATION_OR_POWER_RE.test(text)) return false; if (!LATEX_COMMAND_RE.test(text) && !EQUATION_OR_POWER_RE.test(text)) return false; const proseWords = text.replace(LATEX_COMMAND_GLOBAL_RE, ' ').match(WORD_RE) ?? []; if (proseWords.length > 0) return false; if (isCodeLikeAssignment(text)) return false; return true; } export function renderLatexToHtml(value: string, displayMode = false): string | null { try { return katex.renderToString(escapeLiteralPercents(value), { displayMode, output: 'html', strict: false, throwOnError: true, }); } catch { return null; } } function escapeLiteralPercents(value: string): string { let escaped = ''; for (let index = 0; index < value.length; index += 1) { if (value[index] !== '%') { escaped += value[index]; continue; } escaped += isEscaped(value, index) ? '%' : '\\%'; } return escaped; } export function parseQuizMathText(value: string): QuizMathTextSegment[] { const segments: QuizMathTextSegment[] = []; let cursor = 0; while (cursor < value.length) { const opening = findNextDelimiter(value, cursor); if (!opening) break; if (opening.index > cursor) { segments.push({ type: 'text', value: value.slice(cursor, opening.index) }); } const mathStart = opening.index + opening.delimiter.open.length; const closeIndex = findClosingDelimiter(value, opening.delimiter.close, mathStart); if (closeIndex === -1) { segments.push({ type: 'text', value: value.slice(opening.index) }); cursor = value.length; break; } const latex = value.slice(mathStart, closeIndex).trim(); if (latex) { const shouldRender = opening.delimiter.open !== '$' || isLikelyDelimitedMathText(latex); const html = shouldRender ? renderLatexToHtml(latex, opening.delimiter.displayMode) : null; if (html) { segments.push({ type: 'math', value: latex, html, displayMode: opening.delimiter.displayMode, }); } else { segments.push({ type: 'text', value: value.slice(opening.index, closeIndex + opening.delimiter.close.length), }); } } else { segments.push({ type: 'text', value: value.slice(opening.index, closeIndex + opening.delimiter.close.length), }); } cursor = closeIndex + opening.delimiter.close.length; } if (cursor < value.length) { segments.push({ type: 'text', value: value.slice(cursor) }); } return mergeTextSegments(segments); } function parseEmbeddedMathText(value: string): QuizMathTextSegment[] | null { const segments: QuizMathTextSegment[] = []; let cursor = 0; let hasMath = false; TOKEN_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = TOKEN_RE.exec(value)) !== null) { const tokenStart = match.index; const tokenEnd = tokenStart + match[0].length; const candidate = trimEmbeddedMathCandidate(value, tokenStart, tokenEnd); if (!candidate || candidate.start < cursor) continue; if (!isLikelyEmbeddedMathText(candidate.value)) continue; const html = renderLatexToHtml(candidate.value, false); if (!html) continue; if (candidate.start > cursor) { segments.push({ type: 'text', value: value.slice(cursor, candidate.start) }); } segments.push({ type: 'math', value: candidate.value, html, displayMode: false, }); hasMath = true; cursor = candidate.end; } if (!hasMath) return null; if (cursor < value.length) { segments.push({ type: 'text', value: value.slice(cursor) }); } return mergeTextSegments(segments); } function trimEmbeddedMathCandidate( value: string, start: number, end: number, ): { start: number; end: number; value: string } | null { let candidateStart = start; let candidateEnd = end; while (candidateStart < candidateEnd && /["'“”‘’]/.test(value[candidateStart])) { candidateStart += 1; } while (candidateEnd > candidateStart && SENTENCE_BOUNDARY_CHAR_RE.test(value[candidateEnd - 1])) { candidateEnd -= 1; } const candidate = value.slice(candidateStart, candidateEnd); if (!candidate) return null; return { start: candidateStart, end: candidateEnd, value: candidate }; } function isLikelyEmbeddedMathText(value: string): boolean { const text = value.trim(); if (text.length < 6) return false; if (!FORMULA_CHAR_RE.test(text)) return false; if (!LETTER_OR_COMMAND_RE.test(text)) return false; if (isCodeLikeAssignment(text)) return false; const proseWords = text.replace(LATEX_COMMAND_GLOBAL_RE, ' ').match(WORD_RE) ?? []; if (proseWords.length > 0) return false; if (!EQUATION_OR_POWER_RE.test(text)) return false; return INLINE_OPERATOR_RE.test(text) || LATEX_COMMAND_RE.test(text) || text.length >= 12; } function isLikelyDelimitedMathText(value: string): boolean { const text = value.trim(); if (!text) return false; if (!FORMULA_CHAR_RE.test(text)) return false; if (SINGLE_SYMBOL_RE.test(text)) return true; if (LATEX_COMMAND_RE.test(text) || EQUATION_OR_POWER_RE.test(text)) return true; if (INLINE_OPERATOR_RE.test(text) && (text.match(WORD_RE) ?? []).length <= 1) return true; return false; } export function renderQuizMathText(value: string): QuizMathTextSegment[] { const likelyStandalone = isLikelyStandaloneMathText(value); const hasExplicitDelimiter = EXPLICIT_DELIMITER_RE.test(value); if (!hasExplicitDelimiter && !likelyStandalone) { return parseEmbeddedMathText(value) ?? [{ type: 'text', value }]; } const delimited = parseQuizMathText(value); if (delimited.some((segment) => segment.type === 'math')) return delimited; if (!likelyStandalone) return [{ type: 'text', value }]; const latex = value.trim(); const html = renderLatexToHtml(latex, false); if (!html) return [{ type: 'text', value }]; const prefix = value.slice(0, value.indexOf(latex)); const suffix = value.slice(value.indexOf(latex) + latex.length); return mergeTextSegments([ ...(prefix ? ([{ type: 'text', value: prefix }] as QuizMathTextSegment[]) : []), { type: 'math', value: latex, html, displayMode: false }, ...(suffix ? ([{ type: 'text', value: suffix }] as QuizMathTextSegment[]) : []), ]); } function findNextDelimiter( value: string, startIndex: number, ): { delimiter: Delimiter; index: number } | null { let match: { delimiter: Delimiter; index: number } | null = null; for (const delimiter of DELIMITERS) { const index = findUnescaped(value, delimiter.open, startIndex); if (index === -1) continue; if ( !match || index < match.index || (index === match.index && delimiter.open.length > match.delimiter.open.length) ) { match = { delimiter, index }; } } return match; } function findClosingDelimiter(value: string, delimiter: string, startIndex: number): number { return findUnescaped(value, delimiter, startIndex); } function findUnescaped(value: string, search: string, startIndex: number): number { let index = value.indexOf(search, startIndex); while (index !== -1) { if (!isEscaped(value, index)) return index; index = value.indexOf(search, index + search.length); } return -1; } function isEscaped(value: string, index: number): boolean { let slashCount = 0; for (let i = index - 1; i >= 0 && value[i] === '\\'; i -= 1) { slashCount += 1; } return slashCount % 2 === 1; } function isCodeLikeAssignment(value: string): boolean { const text = value.trim(); return CODE_LIKE_BOOLEAN_ASSIGNMENT_RE.test(text) || CODE_LIKE_INCREMENT_RE.test(text); } function mergeTextSegments(segments: QuizMathTextSegment[]): QuizMathTextSegment[] { const merged: QuizMathTextSegment[] = []; for (const segment of segments) { const last = merged[merged.length - 1]; if (segment.type === 'text' && last?.type === 'text') { last.value += segment.value; } else if (segment.type === 'text' && segment.value === '') { continue; } else { merged.push(segment); } } return merged.length > 0 ? merged : [{ type: 'text', value: '' }]; }