Math Render

v10.6:修复 Markdown 渲染器将 LaTeX 下标 _ 误判为斜体导致公式被 HTML 标签切断的问题;innerHTML 级预处理 + 中文断裂修复 + 官方 auto-render。

2026/04/20のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&nbsp;/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);
        });
    });
})();