v10.6:修复 Markdown 渲染器将 LaTeX 下标 _ 误判为斜体导致公式被 HTML 标签切断的问题;innerHTML 级预处理 + 中文断裂修复 + 官方 auto-render。
От
// ==UserScript== // @name Math Render // @namespace http://tampermonkey.net/ // @version 10.6 // @description v10.6:修复 Markdown 渲染器将 LaTeX 下标 _ 误判为斜体导致公式被 HTML 标签切断的问题;innerHTML 级预处理 + 中文断裂修复 + 官方 auto-render。 // @author Monica // @license MIT // @match *://monica.im/* // @grant GM_addStyle // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/auto-render.min.js // ==/UserScript== (function () { 'use strict'; /*********************** * 配置区 ***********************/ const CONFIG = { enableSingleDollar: true, enableMutationObserver: true, debug: false }; function log(...args) { if (CONFIG.debug) console.log('[MathRender v10.6]', ...args); } /*********************** * 资源注入 ***********************/ function loadKatexCSS() { if (document.querySelector('link[href*="katex.min.css"]')) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css'; link.crossOrigin = 'anonymous'; document.head.appendChild(link); } loadKatexCSS(); /*********************** * UI 样式 ***********************/ GM_addStyle(` #katex-ui-container { position: fixed; bottom: 20px; right: 0; display: flex; align-items: center; z-index: 2147483647; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); padding-right: 20px; } #katex-ui-container.is-hidden { transform: translateX(calc(100% - 24px)); } #katex-toggle-btn { width: 24px; height: 48px; background: #fff; color: #666; border: 1px solid #ddd; border-right: none; border-radius: 8px 0 0 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: -2px 2px 6px rgba(0,0,0,0.1); font-size: 12px; margin-right: 10px; user-select: none; transition: background 0.2s; } #katex-toggle-btn:hover { background: #f0f0f0; color: #333; } #katex-trigger-btn { width: 50px; height: 50px; background: #fbc02d; color: #333; border-radius: 50%; text-align: center; line-height: 50px; font-size: 12px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); user-select: none; transition: transform 0.2s, background 0.2s; font-family: sans-serif; } #katex-trigger-btn:hover { transform: scale(1.08); } `); /*********************** * UI 创建 ***********************/ const uiContainer = document.createElement('div'); uiContainer.id = 'katex-ui-container'; const toggleBtn = document.createElement('div'); toggleBtn.id = 'katex-toggle-btn'; toggleBtn.innerHTML = '▶'; toggleBtn.title = '隐藏/显示面板'; const mainBtn = document.createElement('div'); mainBtn.id = 'katex-trigger-btn'; mainBtn.textContent = 'Math Render'; mainBtn.title = '点击渲染公式'; uiContainer.appendChild(toggleBtn); uiContainer.appendChild(mainBtn); document.body.appendChild(uiContainer); let isRendered = false; let isHidden = false; let observer = null; /*********************** * 工具函数 ***********************/ function cleanLatexSafe(latex) { return String(latex || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .trim(); } function getDelimiters() { const ds = [ { left: '$$', right: '$$', display: true }, { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true } ]; if (CONFIG.enableSingleDollar) { ds.splice(1, 0, { left: '$', right: '$', display: false }); } return ds; } function markNativeMath(root) { root.querySelectorAll('.katex, .katex-display').forEach(el => { if (!el.hasAttribute('data-tm-math') && !el.hasAttribute('data-native-math')) { el.setAttribute('data-native-math', 'true'); } }); } function markRenderedByUs(root) { root.querySelectorAll('.katex, .katex-display').forEach(el => { if (!el.hasAttribute('data-native-math') && !el.hasAttribute('data-tm-math')) { el.setAttribute('data-tm-math', 'true'); } }); } /*********************** * ★ 核心预处理:innerHTML 级修复 * * 解决两大问题: * A) Markdown 渲染器把 LaTeX _x_ 变成 <em>x</em>,切断了 $...$ 区间 * B) 两个公式共享一对 $,中间夹着中文 ***********************/ /** * 在公式区间内,还原被 Markdown 破坏的 HTML 标签 * <em>xxx</em> → _xxx_ * <strong>xxx</strong> → **xxx** * 其他标签直接去掉 */ function stripHtmlRestoreMd(htmlFragment) { let s = htmlFragment; s = s.replace(/<em>(.*?)<\/em>/gs, '_$1_'); s = s.replace(/<strong>(.*?)<\/strong>/gs, '**$1**'); s = s.replace(/<\/?[a-zA-Z][^>]*>/g, ''); return s; } /** * 修复 $...中文...$ 的断裂问题 * 将一对 $ 内含中文的情况拆分为多个独立公式 */ function fixChineseSplit(innerLatex) { const chineseRunRegex = /[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef,。、;:!?\u201c\u201d\u2018\u2019()【】]{2,}/; if (!chineseRunRegex.test(innerLatex)) { return '$' + innerLatex + '$'; } // 按中文段拆分 const parts = innerLatex.split(/([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef,。、;:!?\u201c\u201d\u2018\u2019()【】\s]{2,})/); let rebuilt = ''; for (const part of parts) { if (chineseRunRegex.test(part) || !/[\\{}_^]/.test(part.trim())) { // 中文段或非 LaTeX 段,原样输出 rebuilt += part; } else { const trimmed = part.trim(); if (trimmed.length > 0) { rebuilt += '$' + trimmed + '$'; } } } return rebuilt; } /** * 主入口:在 innerHTML 级别修复被破坏的数学公式 * 逐字符扫描,找到 $...$ 和 $$...$$ 区间,在区间内做修复 */ function fixMathInHtml(html) { let result = ''; let i = 0; const length = html.length; while (i < length) { // 检查 $$...$$(display math) if (i + 1 < length && html[i] === '$' && html[i + 1] === '$') { const closeIdx = html.indexOf('$$', i + 2); if (closeIdx !== -1) { let inner = html.substring(i + 2, closeIdx); // display math 内部也可能有被破坏的标签 if (inner.includes('<')) { inner = stripHtmlRestoreMd(inner); } result += '$$' + inner + '$$'; i = closeIdx + 2; continue; } // 没找到配对,原样输出一个 $ result += html[i]; i++; continue; } // 检查 $...$(inline math) if (html[i] === '$') { // 找配对的 $(跳过 $$) let j = i + 1; let found = -1; while (j < length) { if (html[j] === '$') { // 如果是 $$,跳过 if (j + 1 < length && html[j + 1] === '$') { j += 2; continue; } found = j; break; } j++; } if (found !== -1) { let inner = html.substring(i + 1, found); // 步骤 A:还原被 HTML 标签破坏的 LaTeX if (inner.includes('<')) { inner = stripHtmlRestoreMd(inner); } // 步骤 B:修复中文断裂 const fixed = fixChineseSplit(inner); result += fixed; i = found + 1; continue; } // 没有配对的 $,原样输出 result += html[i]; i++; continue; } // 普通字符 result += html[i]; i++; } return result; } /** * 遍历所有叶子级段落元素,在 innerHTML 级别做预处理 */ function preprocessElements(root) { // 选择所有可能包含公式文本的叶子级元素 const candidates = root.querySelectorAll('p, li, dd, td, th, blockquote, h1, h2, h3, h4, h5, h6, div, span'); const processed = new Set(); candidates.forEach(el => { // 跳过已处理的祖先/后代 if (processed.has(el)) return; // 跳过不相关的元素 if (el.closest('.katex, .katex-display')) return; if (el.closest('#katex-ui-container')) return; const tag = el.tagName; if (['SCRIPT', 'STYLE', 'TEXTAREA', 'PRE', 'CODE'].includes(tag)) return; // 只处理"叶子级":不包含其他块级子元素的元素 // 对 div/span 要更谨慎,只处理不含子块元素的 if (['DIV', 'SPAN'].includes(tag)) { if (el.querySelector('p, li, blockquote, h1, h2, h3, h4, h5, h6')) return; } const html = el.innerHTML; // 快速跳过:没有 $ 就不处理 if (!html.includes('$')) return; // 检查是否有需要修复的特征: // 1. $ 区间内有 <em>/<strong> 标签(Markdown 破坏) // 2. $ 区间内有中文(断裂问题) const hasHtmlInMath = /\$[^$]*<(?:em|strong)[^>]*>[^$]*\$/i.test(html); const hasChinese = /\$[^$]*[\u4e00-\u9fff][\u4e00-\u9fff][^$]*\$/.test(html); if (!hasHtmlInMath && !hasChinese) return; const fixed = fixMathInHtml(html); if (fixed !== html) { log('Fixed innerHTML:', html, '→', fixed); el.innerHTML = fixed; processed.add(el); // 标记所有子元素为已处理 el.querySelectorAll('*').forEach(child => processed.add(child)); } }); } /*********************** * 渲染入口 ***********************/ function renderInRoot(root) { if (!root || root.nodeType !== 1) return; if (root.closest && root.closest('.katex, .katex-display')) return; renderMathInElement(root, { delimiters: getDelimiters(), ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'], ignoredClasses: ['katex', 'katex-display'], throwOnError: false, strict: 'ignore', preProcess: cleanLatexSafe }); } /*********************** * 主流程:渲染 ***********************/ function processPage(root) { loadKatexCSS(); // 保护原生公式 markNativeMath(root); // ★ innerHTML 级预处理:修复 HTML 标签破坏 + 中文断裂 preprocessElements(root); // 官方 auto-render renderInRoot(root); // 标记新增 markRenderedByUs(root); log('Render done'); } /*********************** * 主流程:还原 ***********************/ function restorePage(root) { const nodes = root.querySelectorAll( '.katex-display[data-tm-math="true"], .katex[data-tm-math="true"]:not(.katex-display .katex)' ); nodes.forEach(el => { const isDisplay = el.classList.contains('katex-display'); const kContainer = isDisplay ? el.querySelector('.katex') : el; if (!kContainer) return; const annotation = kContainer.querySelector('annotation[encoding="application/x-tex"]'); if (!annotation) return; const tex = annotation.textContent || ''; const restored = isDisplay ? `\\[${tex}\\]` : `$${tex}$`; el.replaceWith(document.createTextNode(restored)); }); log('Restore done'); } /*********************** * 动态内容监听 ***********************/ function startObserver() { if (!CONFIG.enableMutationObserver || observer) return; const pending = new Set(); let timer = null; observer = new MutationObserver(mutations => { if (!isRendered) return; for (const m of mutations) { for (const nd of m.addedNodes) { if (!(nd instanceof HTMLElement)) continue; if (nd.closest && nd.closest('#katex-ui-container')) continue; if (nd.closest && nd.closest('.katex, .katex-display')) continue; pending.add(nd); } } if (timer) clearTimeout(timer); timer = setTimeout(() => { pending.forEach(node => { try { markNativeMath(node); preprocessElements(node); renderInRoot(node); markRenderedByUs(node); } catch (e) { console.error('[MathRender] observer render error:', e); } }); pending.clear(); timer = null; }, 150); }); observer.observe(document.body, { childList: true, subtree: true }); log('Observer started'); } function stopObserver() { if (observer) { observer.disconnect(); observer = null; log('Observer stopped'); } } /*********************** * 事件绑定 ***********************/ toggleBtn.addEventListener('click', () => { isHidden = !isHidden; if (isHidden) { uiContainer.classList.add('is-hidden'); toggleBtn.innerHTML = '◀'; } else { uiContainer.classList.remove('is-hidden'); toggleBtn.innerHTML = '▶'; } }); mainBtn.addEventListener('click', () => { mainBtn.textContent = '...'; requestAnimationFrame(() => { setTimeout(() => { try { if (isRendered) { stopObserver(); restorePage(document.body); isRendered = false; mainBtn.textContent = 'Math Render'; mainBtn.style.backgroundColor = '#fbc02d'; mainBtn.title = '点击渲染公式'; } else { processPage(document.body); isRendered = true; mainBtn.textContent = 'recover'; mainBtn.style.backgroundColor = '#4caf50'; mainBtn.title = '点击还原为未渲染状态'; startObserver(); } } catch (e) { console.error('[MathRender] Error:', e); mainBtn.textContent = 'Error'; mainBtn.style.backgroundColor = '#f44336'; setTimeout(() => { mainBtn.textContent = isRendered ? 'recover' : 'Math Render'; mainBtn.style.backgroundColor = isRendered ? '#4caf50' : '#fbc02d'; }, 1500); } }, 50); }); }); })();