// ==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 });
});
})();