// js/processing/formula_post_processor.js // 后处理:扫描 HTML 中的纯文本公式并渲染 (function(global) { 'use strict'; /** * 修复公式中的常见 LaTeX 错误 * @param {string} formula - 原始公式 * @param {boolean} isDisplay - 是否为块级公式 * @returns {string} - 修复后的公式 */ function fixFormulaErrors(formula, isDisplay) { let fixed = formula; // 修复 1: 移除行内公式中的 \tag{...} // \tag 只能在块级公式 (displayMode) 中使用 if (!isDisplay && /\\tag\{[^}]*\}/.test(fixed)) { console.log('[FormulaPostProcessor] 移除行内公式中的 \\tag'); fixed = fixed.replace(/\\tag\{[^}]*\}/g, ''); } // 修复 2: 修复 \;^\circ 语法错误 // \;^\circ → \,^{\circ} 或 ^{\circ} if (/\\;\s*\^\\circ/.test(fixed)) { console.log('[FormulaPostProcessor] 修复 \\;^\\circ → \\,^{\\circ}'); fixed = fixed.replace(/\\;\s*\^\\circ/g, '\\,^{\\circ}'); } // 修复 2b: 修复其他 \;^ 的情况 if (/\\;\s*\^([^{])/.test(fixed)) { console.log('[FormulaPostProcessor] 修复 \\;^x → \\,^{x}'); fixed = fixed.replace(/\\;\s*\^([^{])/g, (match, char) => `\\,^{${char}}`); } // 修复 3: 修复双花括号 {{...}} → {...} // 双花括号可能导致 "internal group" 错误 if (/\{\{/.test(fixed)) { console.log('[FormulaPostProcessor] 修复双花括号 {{...}} → {...}'); // 递归移除双花括号,直到没有为止 while (/\{\{/.test(fixed)) { fixed = fixed.replace(/\{\{([^}]*)\}\}/g, '{$1}'); } } // 修复 4: 修复 \mathrm{\;^\circ C} 的情况 if (/\\mathrm\{[^}]*\\;[^}]*\^\s*\\circ[^}]*\}/.test(fixed)) { console.log('[FormulaPostProcessor] 修复 \\mathrm{\\;^\\circ C}'); // \mathrm{\;^\circ C} → \,^{\circ}\mathrm{C} fixed = fixed.replace(/\\mathrm\{\s*\\;\s*\^\s*\\circ\s+([^}]+)\}/g, '\\,^{\\circ}\\mathrm{$1}'); } // 修复 5: 确保上标总是用花括号包围(除非是单个字符) // ^xy → ^{xy} (如果 y 不是空格或特殊符号) fixed = fixed.replace(/\^([a-zA-Z]{2,})/g, '^{$1}'); return fixed.trim(); } /** * 扫描元素中的所有文本节点,找到未渲染的公式并渲染 * @param {HTMLElement} rootElement - 要扫描的根元素 */ function processFormulasInElement(rootElement) { if (!rootElement) return; const startTime = performance.now(); let processedCount = 0; let removedCount = 0; // 先清理错误的 katex-fallback 元素(不完整的公式) const fallbackElements = rootElement.querySelectorAll('.katex-fallback'); fallbackElements.forEach(el => { const text = el.textContent.trim(); // 检测不完整的环境标记 if (/^\\begin\{(aligned|array|matrix|cases|split|gather)\}$/.test(text) || /^\\end\{(aligned|array|matrix|cases|split|gather)\}$/.test(text)) { console.log('[FormulaPostProcessor] 删除不完整的公式块:', text); el.remove(); removedCount++; return; } // 检测常见的 LaTeX 错误并尝试修正 const errorTitle = el.getAttribute('title') || ''; const errorMatch = errorTitle.match(/Undefined control sequence: (\\[A-Za-z]+)/); const fontMetricsError = errorTitle.includes('Font metrics not found'); if (errorMatch || fontMetricsError) { const wrongCmd = errorMatch ? errorMatch[1] : ''; let fixedText = text; let fixed = false; // 修复字体嵌套问题(如 \texttt{\textbf{M}} ) if (fontMetricsError) { // 移除嵌套的字体命令,只保留外层 fixedText = text // \texttt{\textbf{X}} → \texttt{X} .replace(/\\texttt\s*\{\s*\\text(bf|it|rm)\s*\{([^}]+)\}\s*\}/g, '\\texttt{$2}') // \textbf{\texttt{X}} → \textbf{X} .replace(/\\text(bf|it|rm)\s*\{\s*\\texttt\s*\{([^}]+)\}\s*\}/g, '\\text$1{$2}') // 任意嵌套字体 → 保留外层 .replace(/\\(text(tt|bf|it|rm|sf)|math(tt|bf|it|rm|sf|cal|bb))\s*\{\s*\\(text(tt|bf|it|rm|sf)|math(tt|bf|it|rm|sf|cal|bb))\s*\{([^}]+)\}\s*\}/g, '\\$1{$6}'); if (fixedText !== text) { fixed = true; console.log('[FormulaPostProcessor] 修正字体嵌套:', text.substring(0, 50), '→', fixedText.substring(0, 50)); } } // 常见错误映射 if (!fixed && errorMatch) { const fixes = { // 向量和重音符号(大写→小写) '\\Vec': '\\vec', '\\Hat': '\\hat', '\\Bar': '\\bar', '\\Tilde': '\\tilde', '\\Dot': '\\dot', '\\Ddot': '\\ddot', '\\Check': '\\check', '\\Acute': '\\acute', '\\Grave': '\\grave', '\\Breve': '\\breve', '\\Overline': '\\overline', '\\Underline': '\\underline', '\\Widehat': '\\widehat', '\\Widetilde': '\\widetilde', '\\Overbrace': '\\overbrace', '\\Underbrace': '\\underbrace', // 字体命令(大写→小写) '\\Mat': '\\mathrm', '\\Bf': '\\mathbf', '\\It': '\\mathit', '\\Cal': '\\mathcal', '\\Scr': '\\mathscr', '\\Frak': '\\mathfrak', '\\Bb': '\\mathbb', // 小写希腊字母(大写→小写) '\\Alpha': '\\alpha', '\\Beta': '\\beta', '\\Epsilon': '\\epsilon', '\\Zeta': '\\zeta', '\\Eta': '\\eta', '\\Iota': '\\iota', '\\Kappa': '\\kappa', '\\Mu': '\\mu', '\\Nu': '\\nu', '\\Omicron': '\\omicron', '\\Rho': '\\rho', '\\Tau': '\\tau', '\\Chi': '\\chi', // 数学运算符(大写→小写) '\\Sum': '\\sum', '\\Prod': '\\prod', '\\Int': '\\int', '\\Lim': '\\lim', '\\Inf': '\\inf', '\\Sup': '\\sup', '\\Max': '\\max', '\\Min': '\\min', '\\Sin': '\\sin', '\\Cos': '\\cos', '\\Tan': '\\tan', '\\Log': '\\log', '\\Ln': '\\ln', '\\Exp': '\\exp', // 其他常见错误 '\\limit': '\\lim', '\\Frac': '\\frac', '\\Sqrt': '\\sqrt', '\\Text': '\\text', '\\Left': '\\left', '\\Right': '\\right', '\\Big': '\\big', '\\Bigg': '\\bigg', }; // 尝试修正 for (const [wrong, correct] of Object.entries(fixes)) { if (text.includes(wrong)) { fixedText = text.replace(new RegExp(wrong.replace('\\', '\\\\'), 'g'), correct); fixed = true; break; } } } // 结束 if (!fixed && errorMatch) if (fixed) { console.log('[FormulaPostProcessor] 修正公式错误:', wrongCmd, '→', fixedText.substring(0, 50)); try { // 尝试重新渲染 const isBlock = el.classList.contains('katex-block'); const span = document.createElement('span'); span.className = isBlock ? 'katex-block' : 'katex-inline'; if (typeof katex !== 'undefined') { katex.render(fixedText, span, { throwOnError: false, displayMode: isBlock }); el.replaceWith(span); processedCount++; } } catch (err) { console.warn('[FormulaPostProcessor] 修正后仍无法渲染:', err.message); } } } }); // 递归处理所有文本节点 function processNode(node) { // 跳过已经渲染过的 KaTeX 元素 if (node.classList && (node.classList.contains('katex') || node.classList.contains('katex-inline') || node.classList.contains('katex-block'))) { return; } // 跳过 script、style、code 等标签 if (node.tagName && /^(SCRIPT|STYLE|CODE|PRE)$/.test(node.tagName)) { return; } // 处理文本节点 if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; // 检查是否包含公式标记 if (text.includes('$')) { processTextNode(node); } } // 递归处理子节点 else if (node.childNodes) { // 转换为数组避免在遍历时修改 DOM 导致问题 Array.from(node.childNodes).forEach(processNode); } } /** * 处理单个文本节点,将其中的公式替换为渲染后的 HTML */ function processTextNode(textNode) { const text = textNode.textContent; // 匹配所有公式:$...$ 和 $$...$$ const formulaRegex = /\$\$([^\$]+?)\$\$|\$([^\$\n]+?)\$/g; if (!formulaRegex.test(text)) return; // 重置正则 formulaRegex.lastIndex = 0; const fragments = []; let lastIndex = 0; let match; while ((match = formulaRegex.exec(text)) !== null) { // 添加公式前的文本 if (match.index > lastIndex) { fragments.push(document.createTextNode(text.substring(lastIndex, match.index))); } // 渲染公式 let formula = match[1] || match[2]; // $$...$$ 或 $...$ const isDisplay = !!match[1]; // 预处理:修复常见的 LaTeX 错误 formula = fixFormulaErrors(formula, isDisplay); try { // 使用 KaTeX 渲染 const span = document.createElement('span'); span.className = isDisplay ? 'katex-block' : 'katex-inline'; if (typeof katex !== 'undefined') { katex.render(formula, span, { throwOnError: false, displayMode: isDisplay }); fragments.push(span); processedCount++; } else { // KaTeX 不可用,保留原文 fragments.push(document.createTextNode(match[0])); } } catch (error) { console.warn('[FormulaPostProcessor] 渲染公式失败:', formula, error); // 渲染失败,保留原文 fragments.push(document.createTextNode(match[0])); } lastIndex = formulaRegex.lastIndex; } // 添加剩余文本 if (lastIndex < text.length) { fragments.push(document.createTextNode(text.substring(lastIndex))); } // 替换原文本节点 if (fragments.length > 0) { const parent = textNode.parentNode; if (parent) { fragments.forEach(fragment => { parent.insertBefore(fragment, textNode); }); parent.removeChild(textNode); } } } // 开始处理 processNode(rootElement); const duration = performance.now() - startTime; // Phase 3.5: 只在有实际处理时才输出日志,避免流式更新时刷屏 if (processedCount > 0 || removedCount > 0) { console.log(`[FormulaPostProcessor] 处理完成: 渲染 ${processedCount} 个公式, 删除 ${removedCount} 个错误块, 耗时 ${duration.toFixed(2)}ms`); } return { processedCount, removedCount }; } // 导出到全局 global.FormulaPostProcessor = { processFormulasInElement: processFormulasInElement, version: '1.0.0' }; console.log('[FormulaPostProcessor] Formula post processor loaded.'); })(window);