Render LaTeX in NotebookLM

Robust KaTeX rendering for NotebookLM

// ==UserScript==
// @name         Render LaTeX in NotebookLM
// @namespace    http://tampermonkey.net/
// @version      2.3
// @description  Robust KaTeX rendering for NotebookLM
// @author       ergs0204 (with Zolangui + adamnelsonarcher)
// @match        https://notebooklm.google.com/*
// @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
// @resource     katexCSS https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css
// @license      MIT
// ==/UserScript==

// Improvements by adamnelsonarcher (2025)
// - Coalesces multi-node $$ and $ spans
// - Escapes unescaped %
// - Adds table-cell and <br> normalization
// - Fixes inline math and nested quantifiers rendering

(function () {
	'use strict';

	const addKaTeXStyles = () => {
		const link = document.createElement('link');
		link.rel = 'stylesheet';
		link.href = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css';
		document.head.appendChild(link);
	};

	const addCustomStyles = () => {
		const css = `.katex { vertical-align: -0.1em; }`;
		if (typeof GM_addStyle === 'function') {
			GM_addStyle(css);
		} else {
			const style = document.createElement('style');
			style.textContent = css;
			document.head.appendChild(style);
		}
	};

	const candidateSelector = 'p, div, li, section, article, span, td, th';

	const convertBRsInsideMathyBlocks = (root) => {
		const candidates = root.querySelectorAll(candidateSelector);
		candidates.forEach(el => {
			const tc = el.textContent || '';
			// If the text looks like it contains math delimiters, normalize <br> → '\n'
			if ((tc.match(/[\$]{2}/g) || []).length >= 2 || (tc.match(/\$/g) || []).length >= 2 || tc.includes('\\[') || tc.includes('\\(')) {
				el.querySelectorAll('br').forEach(br => br.replaceWith(document.createTextNode('\n')));
				el.normalize();
			}
		});
	};

	// Map a textContent offset to (node,offset) in DOM
	const pointAtOffset = (el, target) => {
		const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
		let node, seen = 0, last = null;
		while ((node = walker.nextNode())) {
			last = node;
			const len = node.nodeValue.length;
			if (seen + len >= target) {
				return { node, offset: target - seen };
			}
			seen += len;
		}
		return last ? { node: last, offset: last.nodeValue.length } : null;
	};

	// Escape stray % in LaTeX math content
	const sanitizeMathContent = (s) => {
		// Replace unescaped % with \%
		return s.replace(/(?<!\\)%/g, '\\%');
	};

	const coalesceForDelims = (root, leftDelim, rightDelim, sanitizeFn) => {
		const containers = root.querySelectorAll(candidateSelector);
		containers.forEach(el => {
			el.normalize();
			let tc = el.textContent || '';
			const L = leftDelim.length, R = rightDelim.length;

			if (tc.indexOf(leftDelim) === -1 || tc.indexOf(rightDelim) === -1) return;

			let startFrom = 0;
			while (true) {
				tc = el.textContent || '';
				let open = tc.indexOf(leftDelim, startFrom);
				if (open === -1) break;
                
				if (leftDelim === '$' && tc.slice(open, open + 2) === '$$') {
					startFrom = open + 2;
					continue;
				}

				let close = tc.indexOf(rightDelim, open + L);
				if (close === -1) break;

				if (leftDelim === '$') {
					const before = tc[open - 1];
					if (before === '\\') { startFrom = open + 1; continue; }
					while (close !== -1) {
						const prev = tc[close - 1];
						const isDouble = tc.slice(close, close + 2) === '$$';
						if (prev !== '\\' && !isDouble) break;
						close = tc.indexOf('$', close + 1);
					}
					if (close === -1) break;
				}

				const p1 = pointAtOffset(el, open);
				const p2 = pointAtOffset(el, close + R);
				if (!p1 || !p2) break;

				const range = document.createRange();
				range.setStart(p1.node, p1.offset);
				range.setEnd(p2.node, p2.offset);

				const exact = tc.slice(open, close + R);
				const inner = exact.slice(L, exact.length - R);
				const innerSan = sanitizeFn ? sanitizeFn(inner) : inner;
				const rebuilt = leftDelim + innerSan + rightDelim;

				range.deleteContents();
				const tn = document.createTextNode(rebuilt);

				const anchor = pointAtOffset(el, open);
				if (anchor) {
					if (anchor.offset > 0) {
						anchor.node.splitText(anchor.offset);
						anchor.node.parentNode.insertBefore(tn, anchor.node.nextSibling);
					} else {
						anchor.node.parentNode.insertBefore(tn, anchor.node);
					}
				} else {
					el.appendChild(tn);
				}

				el.normalize();
				startFrom = open + rebuilt.length;
			}
		});
	};

	const ignoreClass = 'katex-ignore-active-render';
	const katexOptions = {
		delimiters: [
			{ left: "$$", right: "$$", display: true },
			{ left: "$", right: "$", display: false },
			{ left: "\\(", right: "\\)", display: false },
			{ left: "\\[", right: "\\]", display: true }
		],
		ignoredClasses: [ignoreClass],
		ignoredTags: ["script","noscript","style","textarea","pre","code"],
		throwOnError: false
	};

	let renderTimeout;
	const renderPageWithIgnore = () => {
		const activeEl = document.activeElement;
		let hasIgnoreClass = false;
		try {
			if (activeEl && (activeEl.isContentEditable || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {
				activeEl.classList.add(ignoreClass);
				hasIgnoreClass = true;
			}

			convertBRsInsideMathyBlocks(document.body);

			// Coalesce and sanitize in safe order: $$ first, then $; also support \[ \] / \( \)
			coalesceForDelims(document.body, '$$', '$$', sanitizeMathContent);
			coalesceForDelims(document.body, '$', '$', sanitizeMathContent);
			coalesceForDelims(document.body, '\\[', '\\]', sanitizeMathContent);
			coalesceForDelims(document.body, '\\(', '\\)', sanitizeMathContent);

			renderMathInElement(document.body, katexOptions);
		} catch (e) {
			console.error('KaTeX render error:', e);
		} finally {
			if (hasIgnoreClass && activeEl) {
				activeEl.classList.remove(ignoreClass);
			}
		}
	};

	const observer = new MutationObserver(() => {
		clearTimeout(renderTimeout);
		renderTimeout = setTimeout(renderPageWithIgnore, 250);
	});

	window.addEventListener('load', () => {
		addKaTeXStyles();
		addCustomStyles();
		renderPageWithIgnore();
		observer.observe(document.body, { childList: true, subtree: true });
	});
})();