Render inline and block math that appears as raw delimiters in Gemini Enterprise.
// ==UserScript==
// @name Gemini Enterprise Inline Math Fix
// @namespace https://github.com/lueluelue2006
// @author schweigen
// @version 1.2.1
// @license MIT
// @description Render inline and block math that appears as raw delimiters in Gemini Enterprise.
// @match https://business.gemini.google/*
// @run-at document-start
// @grant unsafeWindow
// ==/UserScript==
(() => {
'use strict';
try {
if (typeof unsafeWindow !== 'undefined') {
unsafeWindow.__geminiInlineMathFix = { version: '1.2.1' };
}
} catch (e) {
// Ignore if unsafeWindow is blocked.
}
const mathRegex = /\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$\$([\s\S]+?)\$\$|\$([^$\n]+?)\$/g;
const bareMathRegex =
/\\implies\s*(?:\\\{[^}]{1,200}\\\}|\{[^}]{1,200}\})(?:_(?:\{[^}]{1,40}\}|[a-zA-Z0-9]+))?|\\square\b|\{(?=[^}\n]*[\\_^0-9,+=|\\-])[^\n}]{1,200}\}(?:_(?:\{[^}]{1,40}\}|[a-zA-Z0-9]+))?/g;
const PATCH_SKIP_WINDOW_MS = 800;
const getKatex = () => {
if (window.katex) return window.katex;
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.katex) return unsafeWindow.katex;
return null;
};
const isSkippable = (node) => {
const el = node.parentElement;
if (!el) return true;
return !!el.closest('code, pre, textarea, script, style, .katex, .katex-display, .math-block');
};
const isKatexError = (el) => {
if (!el) return true;
if (el.classList && el.classList.contains('katex-error')) return true;
return !!el.querySelector?.('.katex-error');
};
const repairMarkdownBold = (root) => {
const boldRegex = /\*\*([^\n*]{1,200}?)\*\*/g;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue || !node.nodeValue.includes('**')) continue;
if (isSkippable(node)) continue;
const parent = node.parentElement;
if (!parent) continue;
if (parent.closest('strong, b')) continue;
nodes.push(node);
}
for (const n of nodes) {
const text = n.nodeValue;
if (!text) continue;
boldRegex.lastIndex = 0;
let match;
let last = 0;
let changed = false;
const frag = document.createDocumentFragment();
while ((match = boldRegex.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
const before = text.slice(last, start);
if (before) frag.appendChild(document.createTextNode(before));
const content = match[1] || '';
if (content.trim()) {
const strong = document.createElement('strong');
strong.textContent = content;
frag.appendChild(strong);
} else {
frag.appendChild(document.createTextNode(match[0]));
}
last = end;
changed = true;
}
if (!changed) continue;
const after = text.slice(last);
if (after) frag.appendChild(document.createTextNode(after));
const parent = n.parentNode;
if (!parent) continue;
parent.replaceChild(frag, n);
}
};
const isProbablyPlainTextMath = (latex) => {
if (!latex) return false;
const t = latex.trim();
if (t.length < 12) return false;
if (!/\s/.test(t)) return false;
if (/\\[a-zA-Z]+|[0-9]|[_^{}]|[=<>+*/-]/.test(t)) return false;
try {
return /^[\p{L}\s.,;:!?'"()–—-]+$/u.test(t);
} catch (e) {
// Fallback for environments without Unicode property escapes.
return /^[A-Za-z\u00C0-\u024F\u1E00-\u1EFF\s.,;:!?'"()–—-]+$/.test(t);
}
};
const restoreOuterSetBraces = (latex) => {
if (!latex || latex.includes('\\{') || latex.includes('\\}')) return latex;
const start = latex.search(/\S/);
if (start < 0) return latex;
let end = latex.length - 1;
while (end >= 0 && /\s/.test(latex[end])) end -= 1;
if (latex[start] !== '{' || latex[end] !== '}') return latex;
let depth = 0;
for (let i = start; i <= end; i += 1) {
const ch = latex[i];
if (ch === '{' && latex[i - 1] !== '\\') depth += 1;
if (ch === '}' && latex[i - 1] !== '\\') depth -= 1;
if (depth === 0) {
if (i === end) {
return `${latex.slice(0, start)}\\{${latex.slice(start + 1, end)}\\}${latex.slice(end + 1)}`;
}
return latex;
}
}
return latex;
};
const restoreIndexedSetBraces = (latex) => {
if (!latex || !latex.includes('{') || !latex.includes('}')) return latex;
const isEscaped = (s, idx) => idx > 0 && s[idx - 1] === '\\';
const findMatchingBrace = (s, start) => {
let depth = 0;
for (let i = start; i < s.length; i += 1) {
const ch = s[i];
if (ch === '{' && !isEscaped(s, i)) depth += 1;
if (ch === '}' && !isEscaped(s, i)) depth -= 1;
if (depth === 0) return i;
}
return -1;
};
const isInfinity = (s, caretIndex) => {
let i = caretIndex + 1;
while (i < s.length && /\s/.test(s[i])) i += 1;
if (i >= s.length) return null;
if (s[i] === '{' && !isEscaped(s, i)) {
const end = findMatchingBrace(s, i);
if (end > i) {
const inner = s.slice(i + 1, end).trim();
if (inner === '\\infty' || inner === '∞') return end + 1;
}
return null;
}
if (s.slice(i).startsWith('\\infty')) return i + '\\infty'.length;
if (s[i] === '∞') return i + 1;
return null;
};
let out = '';
let cursor = 0;
while (cursor < latex.length) {
const open = latex.indexOf('{', cursor);
if (open < 0) {
out += latex.slice(cursor);
break;
}
out += latex.slice(cursor, open);
if (isEscaped(latex, open)) {
out += '{';
cursor = open + 1;
continue;
}
const groupAEnd = findMatchingBrace(latex, open);
if (groupAEnd < 0) {
out += latex.slice(open);
break;
}
let idx = groupAEnd + 1;
while (idx < latex.length && /\s/.test(latex[idx])) idx += 1;
let groupBStart = -1;
if (idx < latex.length && latex[idx] === '_' && !isEscaped(latex, idx)) {
idx += 1;
while (idx < latex.length && /\s/.test(latex[idx])) idx += 1;
if (idx < latex.length && latex[idx] === '{' && !isEscaped(latex, idx)) {
groupBStart = idx;
}
} else if (idx < latex.length && latex[idx] === '{' && !isEscaped(latex, idx)) {
// Common Gemini markdown escape bug: "\\}_{k=1}" becomes "}{k=1}" (underscore eaten by markdown).
groupBStart = idx;
}
if (groupBStart < 0) {
out += latex.slice(open, groupAEnd + 1);
cursor = groupAEnd + 1;
continue;
}
const groupBEnd = findMatchingBrace(latex, groupBStart);
if (groupBEnd < 0) {
out += latex.slice(open, groupAEnd + 1);
cursor = groupAEnd + 1;
continue;
}
const groupBInner = latex.slice(groupBStart + 1, groupBEnd).trim();
if (!/^[a-zA-Z]\s*=\s*\d+$/.test(groupBInner)) {
out += latex.slice(open, groupAEnd + 1);
cursor = groupAEnd + 1;
continue;
}
idx = groupBEnd + 1;
while (idx < latex.length && /\s/.test(latex[idx])) idx += 1;
if (idx >= latex.length || latex[idx] !== '^' || isEscaped(latex, idx)) {
out += latex.slice(open, groupAEnd + 1);
cursor = groupAEnd + 1;
continue;
}
const afterInfinity = isInfinity(latex, idx);
if (!afterInfinity) {
out += latex.slice(open, groupAEnd + 1);
cursor = groupAEnd + 1;
continue;
}
const groupAInner = latex.slice(open + 1, groupAEnd);
const groupB = latex.slice(groupBStart, groupBEnd + 1);
out += `\\{${groupAInner}\\}_${groupB}${latex.slice(idx, afterInfinity)}`;
cursor = afterInfinity;
}
return out;
};
const restoreSetBracesAfterEquals = (latex) => {
if (!latex || !latex.includes('{') || !latex.includes('}')) return latex;
const findPrevNonSpace = (s, idx) => {
for (let i = idx - 1; i >= 0; i -= 1) {
if (!/\s/.test(s[i])) return i;
}
return -1;
};
const findMatchingBrace = (s, start) => {
let depth = 0;
for (let i = start; i < s.length; i += 1) {
const ch = s[i];
if (ch === '{' && s[i - 1] !== '\\') depth += 1;
if (ch === '}' && s[i - 1] !== '\\') depth -= 1;
if (depth === 0) return i;
}
return -1;
};
let out = '';
let depth = 0;
for (let i = 0; i < latex.length; i += 1) {
const ch = latex[i];
const escaped = i > 0 && latex[i - 1] === '\\';
if (ch === '{' && !escaped) {
if (depth === 0) {
const prevIdx = findPrevNonSpace(latex, i);
const prevCh = prevIdx >= 0 ? latex[prevIdx] : '';
if (prevCh === '=') {
const end = findMatchingBrace(latex, i);
if (end > i) {
out += `\\{${latex.slice(i + 1, end)}\\}`;
i = end;
continue;
}
}
}
depth += 1;
} else if (ch === '}' && !escaped) {
depth = Math.max(0, depth - 1);
}
out += ch;
}
return out;
};
const restoreOperatorSetBraces = (latex) => {
if (!latex) return latex;
if (!latex.includes('\\max') && !latex.includes('\\min')) return latex;
const isEscaped = (s, idx) => idx > 0 && s[idx - 1] === '\\';
const findMatchingBrace = (s, start) => {
let depth = 0;
for (let i = start; i < s.length; i += 1) {
const ch = s[i];
if (ch === '{' && !isEscaped(s, i)) depth += 1;
if (ch === '}' && !isEscaped(s, i)) depth -= 1;
if (depth === 0) return i;
}
return -1;
};
const hasTopLevelComma = (inner) => {
if (!inner || !inner.includes(',')) return false;
let depth = 0;
for (let i = 0; i < inner.length; i += 1) {
const ch = inner[i];
if (ch === '{' && !isEscaped(inner, i)) depth += 1;
if (ch === '}' && !isEscaped(inner, i)) depth = Math.max(0, depth - 1);
if (depth === 0 && ch === ',' && !isEscaped(inner, i)) return true;
}
return false;
};
const ops = ['max', 'min'];
let out = '';
let cursor = 0;
while (cursor < latex.length) {
let bestIdx = -1;
let bestOp = null;
for (const op of ops) {
const idx = latex.indexOf(`\\${op}`, cursor);
if (idx < 0) continue;
if (bestIdx < 0 || idx < bestIdx) {
bestIdx = idx;
bestOp = op;
}
}
if (bestIdx < 0 || !bestOp) {
out += latex.slice(cursor);
break;
}
out += latex.slice(cursor, bestIdx);
out += `\\${bestOp}`;
let i = bestIdx + bestOp.length + 1;
const wsStart = i;
while (i < latex.length && /\s/.test(latex[i])) i += 1;
out += latex.slice(wsStart, i);
if (i >= latex.length || latex[i] !== '{' || isEscaped(latex, i)) {
cursor = i;
continue;
}
const end = findMatchingBrace(latex, i);
if (end <= i) {
out += latex.slice(i);
break;
}
const inner = latex.slice(i + 1, end);
if (!hasTopLevelComma(inner)) {
out += latex.slice(i, end + 1);
cursor = end + 1;
continue;
}
out += `\\{${inner}\\}`;
cursor = end + 1;
}
return out;
};
const repairLatex = (latex) => {
let out = latex;
// Gemini sometimes leaves a dangling underscore that KaTeX treats as a parse error.
// Fix the common pattern seen in linear combinations: "... \\lambda_i v_".
if (/\\lambda_i\s*v_\s*$/.test(out)) {
out = out.replace(/v_\s*$/, 'v_i');
}
// Fix the common truncated fragment: "\\implies (-u_".
if (/\\implies\s*\(-u_\s*$/.test(out)) {
out = out.replace(/\(-u_\s*$/, '(-u_1');
}
// Generic: make trailing sub/sup syntactically valid.
out = out.replace(/_(\s*)$/, '_{}$1');
out = out.replace(/\^(\s*)$/, '^{}$1');
return out;
};
const normalizeBareLatex = (latex) => {
let out = latex;
// If Gemini stripped the surrounding $...$ but kept the TeX command, Markdown may have
// consumed \\{ and \\} into literal braces. Restore set braces for KaTeX.
if (/\\implies/.test(out) && !out.includes('\\{') && out.includes('{') && out.includes('}')) {
out = out.replace(/\{([^}]*)\}/g, '\\{$1\\}');
}
return out;
};
const sanitizeLatexForKatex = (latex) => {
let out = typeof latex === 'string' ? latex : String(latex ?? '');
out = restoreSetBracesAfterEquals(out);
out = restoreOperatorSetBraces(out);
out = restoreOuterSetBraces(out);
out = restoreIndexedSetBraces(out);
out = normalizeBareLatex(out);
out = repairLatex(out);
return out;
};
const renderLatex = (latex, displayMode, katex) => {
const el = document.createElement(displayMode ? 'div' : 'span');
const opts = {
displayMode,
throwOnError: false,
strict: 'ignore'
};
const doRender = (tex) => {
while (el.firstChild) el.removeChild(el.firstChild);
el.className = '';
katex.render(tex, el, opts);
};
try {
doRender(latex);
if (isKatexError(el)) {
const repaired = repairLatex(latex);
if (repaired !== latex) doRender(repaired);
}
if (isKatexError(el)) return null;
el.setAttribute('data-gemini-inline-math-fix', '1');
return el;
} catch (e) {
return null;
}
};
const replacePipesInLatex = (latex) => {
let out = '';
for (let i = 0; i < latex.length; i += 1) {
const ch = latex[i];
if (ch === '|' && latex[i - 1] !== '\\') {
out += '\\vert{}';
} else {
out += ch;
}
}
return out;
};
const patchTableLine = (line) => {
if (!line.includes('|')) return line;
if (!line.includes('$') && !line.includes('\\(') && !line.includes('\\[')) return line;
let out = line;
const wrapInline = (latex) => {
const inner = replacePipesInLatex(latex);
const spaced = inner && inner.startsWith(' ') ? inner : ` ${inner}`;
// Gemini's table renderer sometimes fails when \\( is immediately followed by [ or \\\\.
return `\\(${spaced}\\)`;
};
const wrapDisplay = (latex) => {
const inner = replacePipesInLatex(latex);
const spaced = inner && inner.startsWith(' ') ? inner : ` ${inner}`;
return `\\[${spaced}\\]`;
};
out = out.replace(/\$\$([\s\S]+?)\$\$/g, (m, latex) => wrapDisplay(latex));
out = out.replace(/\\\(([\\s\S]+?)\\\)/g, (m, latex) => wrapInline(latex));
out = out.replace(/\\\[([\\s\S]+?)\\\]/g, (m, latex) => wrapDisplay(latex));
out = out.replace(/\$([^$\n]+?)\$/g, (m, latex) => wrapInline(latex));
return out;
};
const patchMarkdownTables = (markdown) => {
if (!markdown || !markdown.includes('|')) return markdown;
if (!markdown.includes('$') && !markdown.includes('\\(') && !markdown.includes('\\[')) return markdown;
const countPipesOutsideMathAndCode = (line) => {
if (!line || !line.includes('|')) return 0;
let inBackticks = false;
let inMath = false;
let mathMode = null;
let count = 0;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === '`' && line[i - 1] !== '\\') {
inBackticks = !inBackticks;
continue;
}
if (inBackticks) continue;
if (ch === '\\') {
const next = line[i + 1];
if (!inMath && next === '(') {
inMath = true;
mathMode = '\\(';
i += 1;
continue;
}
if (inMath && mathMode === '\\(' && next === ')') {
inMath = false;
mathMode = null;
i += 1;
continue;
}
if (!inMath && next === '[') {
inMath = true;
mathMode = '\\[';
i += 1;
continue;
}
if (inMath && mathMode === '\\[' && next === ']') {
inMath = false;
mathMode = null;
i += 1;
continue;
}
}
if (ch === '$' && line[i - 1] !== '\\') {
if (line[i + 1] === '$') {
if (!inMath) {
inMath = true;
mathMode = '$$';
} else if (mathMode === '$$') {
inMath = false;
mathMode = null;
}
i += 1;
continue;
}
if (!inMath) {
inMath = true;
mathMode = '$';
continue;
}
if (mathMode === '$') {
inMath = false;
mathMode = null;
}
continue;
}
if (ch === '|' && !inMath) count += 1;
}
return count;
};
const lines = markdown.split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^```/.test(line) || /^~~~/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const pipeCountOutside = countPipesOutsideMathAndCode(line);
if (pipeCountOutside >= 2 && (line.includes('$') || line.includes('\\(') || line.includes('\\['))) {
lines[i] = patchTableLine(line);
}
}
return lines.join('\n');
};
const patchMarkdownBold = (markdown) => {
if (!markdown || !markdown.includes('**')) return markdown;
const patchBoldText = (text) => {
if (!text || !text.includes('**')) return text;
let out = text;
out = out.replace(/\*\*“([^”\n*]{1,200}?)”\*\*/g, '“**$1**”');
out = out.replace(/\*\*‘([^’\n*]{1,200}?)’\*\*/g, '‘**$1**’');
out = out.replace(/\*\*《([^》\n*]{1,200}?)》\*\*/g, '《**$1**》');
out = out.replace(/\*\*「([^」\n*]{1,200}?)」\*\*/g, '「**$1**」');
out = out.replace(/\*\*(([^)\n*]{1,200}?))\*\*/g, '(**$1**)');
out = out.replace(/\*\*\(([^)\n*]{1,200}?)\)\*\*/g, '(**$1**)');
const moveTrailingPunctuationOut = (regex) => {
out = out.replace(regex, (m, body, punct) => {
const first = (body || '').trimStart()[0];
if (!first) return m;
if (/^[“‘"'((《「]/.test(first)) return m;
return `**${body}**${punct}`;
});
};
try {
moveTrailingPunctuationOut(/\*\*([^\n*]{1,200}?)([”’"'))\]}】》」])\*\*(?=[\p{L}\p{N}])/gu);
} catch (e) {
moveTrailingPunctuationOut(
/\*\*([^\n*]{1,200}?)([”’"'))\]}】》」])\*\*(?=[A-Za-z0-9\u00C0-\u024F\u1E00-\u1EFF\u4E00-\u9FFF])/g
);
}
return out;
};
const lines = markdown.split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^```/.test(line) || /^~~~/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
if (!line.includes('**')) continue;
const parts = line.split('`');
for (let pi = 0; pi < parts.length; pi += 2) {
parts[pi] = patchBoldText(parts[pi]);
}
lines[i] = parts.join('`');
}
return lines.join('\n');
};
const findRealSegment = (segments, startIndex, direction) => {
for (let i = startIndex; i >= 0 && i < segments.length; i += direction) {
if (segments[i].node) return segments[i];
}
return null;
};
const locate = (segments, index, preferNext) => {
for (let i = 0; i < segments.length; i += 1) {
const seg = segments[i];
const start = seg.start;
const end = seg.start + seg.length;
if (index < start) return null;
if (index === end) {
if (preferNext && i + 1 < segments.length) {
const next = findRealSegment(segments, i + 1, 1);
if (!next) return null;
return { node: next.node, offset: 0 };
}
if (!seg.node) {
const prev = findRealSegment(segments, i - 1, -1);
if (!prev) return null;
return { node: prev.node, offset: prev.length };
}
return { node: seg.node, offset: seg.length };
}
if (index >= start && index < end) {
if (seg.node) {
return { node: seg.node, offset: index - start };
}
const target = preferNext ? findRealSegment(segments, i + 1, 1) : findRealSegment(segments, i - 1, -1);
if (!target) return null;
return { node: target.node, offset: preferNext ? 0 : target.length };
}
}
return null;
};
const collectMatches = (text) => {
const matches = [];
mathRegex.lastIndex = 0;
let match;
while ((match = mathRegex.exec(text)) !== null) {
const latex = match[1] || match[2] || match[3] || match[4];
if (!latex) continue;
matches.push({
kind: 'delimited',
start: match.index,
end: match.index + match[0].length,
latex,
displayMode: !!(match[1] || match[3])
});
}
bareMathRegex.lastIndex = 0;
while ((match = bareMathRegex.exec(text)) !== null) {
matches.push({
kind: 'bare',
start: match.index,
end: match.index + match[0].length,
latex: match[0],
displayMode: false
});
}
if (!matches.length) return matches;
matches.sort((a, b) => a.start - b.start || b.end - a.end);
const filtered = [];
let cursor = -1;
for (const m of matches) {
if (m.start < cursor) continue;
filtered.push(m);
cursor = m.end;
}
return filtered;
};
const safeReplaceRange = (range, node) => {
let extracted;
try {
extracted = range.extractContents();
} catch (e) {
return false;
}
try {
range.insertNode(node);
return true;
} catch (e) {
try {
range.insertNode(extracted);
} catch (restoreErr) {
// Ignore restore failures.
}
return false;
}
};
const processSequence = (text, segments, katex) => {
if (!text || !segments.length) return;
const matches = collectMatches(text);
if (!matches.length) return;
for (let i = matches.length - 1; i >= 0; i -= 1) {
const m = matches[i];
let start = m.start;
let end = m.end;
if (m.kind === 'bare') {
if (start > 0 && text[start - 1] === '$') start -= 1;
if (end < text.length && text[end] === '$') end += 1;
}
const startLoc = locate(segments, start, true);
const endLoc = locate(segments, end, false);
if (!startLoc || !endLoc) continue;
let latex = m.latex;
if (m.kind === 'delimited' && !m.displayMode && isProbablyPlainTextMath(latex)) {
const range = document.createRange();
range.setStart(startLoc.node, startLoc.offset);
range.setEnd(endLoc.node, endLoc.offset);
safeReplaceRange(range, document.createTextNode(latex));
continue;
}
latex = sanitizeLatexForKatex(latex);
const rendered = renderLatex(latex, m.displayMode, katex);
if (!rendered) continue;
const range = document.createRange();
range.setStart(startLoc.node, startLoc.offset);
range.setEnd(endLoc.node, endLoc.offset);
safeReplaceRange(range, rendered);
}
};
const getLeafBlocks = (root) => {
const blockSelector = 'p, li, h1, h2, h3, h4, h5, h6, blockquote, td, th';
const divSelector = 'div:not(.math-block)';
const selector = `${blockSelector}, ${divSelector}`;
const blocks = Array.from(root.querySelectorAll(selector)).filter((el) => {
if (el.closest('code, pre, textarea, script, style, .katex, .katex-display, .math-block')) return false;
const nestedSelector = el.tagName === 'DIV' ? selector : blockSelector;
return !el.querySelector(nestedSelector);
});
if (!blocks.length) return [root];
return blocks;
};
const processBlock = (block, katex) => {
repairMarkdownBold(block);
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let node;
let text = '';
let segments = [];
const flush = () => {
processSequence(text, segments, katex);
text = '';
segments = [];
};
while ((node = walker.nextNode())) {
if (!node.nodeValue) continue;
if (isSkippable(node)) {
flush();
continue;
}
segments.push({ node, start: text.length, length: node.nodeValue.length });
text += node.nodeValue;
}
flush();
};
const processRoot = (root, katex) => {
const blocks = getLeafBlocks(root);
for (const block of blocks) {
processBlock(block, katex);
}
};
const collectRowSegments = (row) => {
const cells = Array.from(row.querySelectorAll('td, th'));
const segments = [];
let text = '';
const addNode = (node) => {
segments.push({ node, start: text.length, length: node.nodeValue.length });
text += node.nodeValue;
};
for (let ci = 0; ci < cells.length; ci += 1) {
const walker = document.createTreeWalker(cells[ci], NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue) continue;
if (isSkippable(node)) continue;
addNode(node);
}
if (ci < cells.length - 1) {
segments.push({ node: null, start: text.length, length: 1 });
text += '|';
}
}
return { text, segments };
};
const countUnescaped = (text, char) => {
let count = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === char && text[i - 1] !== '\\') count += 1;
}
return count;
};
const hasUnbalancedMath = (text) => {
if (!text) return false;
const dollarCount = countUnescaped(text, '$');
if (dollarCount % 2 === 1) return true;
const openParen = (text.match(/\\\(/g) || []).length;
const closeParen = (text.match(/\\\)/g) || []).length;
if (openParen !== closeParen) return true;
const openBracket = (text.match(/\\\[/g) || []).length;
const closeBracket = (text.match(/\\\]/g) || []).length;
if (openBracket !== closeBracket) return true;
return false;
};
const processTableRows = (root, katex) => {
const rows = Array.from(root.querySelectorAll('tr'));
for (const row of rows) {
const getCells = () => Array.from(row.querySelectorAll('td, th'));
const initialCells = getCells();
if (!initialCells.length) continue;
const table = row.closest('table');
const headerRow = table ? table.querySelector('tr') : null;
const headerCells = headerRow ? headerRow.querySelectorAll('td, th') : null;
const desiredCols = headerCells && headerCells.length ? headerCells.length : initialCells.length;
const moveLooseKatexIntoCells = () => {
const cells = getCells();
if (!cells.length) return;
const children = Array.from(row.childNodes);
for (const child of children) {
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const tag = child.tagName;
if (tag === 'TD' || tag === 'TH') continue;
if (!child.classList?.contains('katex') && !child.querySelector?.('.katex, .katex-display')) continue;
let target = child.previousElementSibling;
while (target && target.tagName !== 'TD' && target.tagName !== 'TH') {
target = target.previousElementSibling;
}
if (!target) {
target = child.nextElementSibling;
while (target && target.tagName !== 'TD' && target.tagName !== 'TH') {
target = target.nextElementSibling;
}
}
if (!target) target = cells[0];
target.appendChild(child);
}
};
const cleanupRowMarkers = () => {
const cells = getCells();
for (const cell of cells) {
const walker = document.createTreeWalker(cell, NodeFilter.SHOW_TEXT);
const toRemove = [];
let node;
while ((node = walker.nextNode())) {
const t = node.nodeValue ? node.nodeValue.trim() : '';
if (!t) continue;
if (/^(\*{1,3}|_{1,3})$/.test(t)) toRemove.push(node);
}
for (const n of toRemove) {
n.nodeValue = '';
}
}
};
const splitSpanningCell = () => {
if (desiredCols <= 1) return;
const cells = getCells();
if (cells.length !== 1) return;
const cell = cells[0];
if (cell.colSpan <= 1) return;
const annotation = cell.querySelector('.katex-mathml annotation');
if (!annotation || !annotation.textContent) return;
const latex = annotation.textContent;
const pipeIdx = latex.indexOf('|');
if (pipeIdx < 0) return;
const leftLatex = `${latex.slice(0, pipeIdx)}|`;
const rightLatex = latex.slice(pipeIdx + 1);
if (!leftLatex || !rightLatex) return;
const makeCell = () => {
const c = cell.cloneNode(false);
c.removeAttribute('colspan');
c.textContent = '';
return c;
};
const leftCell = makeCell();
const rightCell = makeCell();
const leftRendered = renderLatex(leftLatex, false, katex);
const rightRendered = renderLatex(rightLatex, false, katex);
if (leftRendered) leftCell.appendChild(leftRendered);
if (rightRendered) rightCell.appendChild(rightRendered);
cell.replaceWith(leftCell, rightCell);
for (let i = 2; i < desiredCols; i += 1) {
row.appendChild(makeCell());
}
};
const mergeIfSingleCell = () => {
if (desiredCols > 1) return;
const cells = getCells();
const meaningful = cells.filter((cell) => {
const text = cell.innerText.replace(/[\s*\u200b_]/g, '').trim();
if (text) return true;
return !!cell.querySelector('.katex, .katex-display');
});
if (meaningful.length !== 1 || cells.length <= 1) return;
const keep = meaningful[0];
keep.colSpan = cells.length;
for (const cell of cells) {
if (cell !== keep) cell.remove();
}
};
const cellsForBalance = getCells();
const needsCrossCell = cellsForBalance.some((cell) => hasUnbalancedMath(cell.textContent || ''));
if (!needsCrossCell) {
cleanupRowMarkers();
moveLooseKatexIntoCells();
splitSpanningCell();
mergeIfSingleCell();
continue;
}
moveLooseKatexIntoCells();
if (!row.textContent || (!row.textContent.includes('$') && !row.textContent.includes('\\(') && !row.textContent.includes('\\['))) {
cleanupRowMarkers();
moveLooseKatexIntoCells();
splitSpanningCell();
mergeIfSingleCell();
continue;
}
const { text, segments } = collectRowSegments(row);
if (!text.includes('$') && !text.includes('\\(') && !text.includes('\\[')) {
cleanupRowMarkers();
moveLooseKatexIntoCells();
splitSpanningCell();
mergeIfSingleCell();
continue;
}
let rowText = text;
const rowSegments = segments.slice();
const dollarCount = (rowText.match(/\$/g) || []).length;
if (dollarCount % 2 === 1) {
rowSegments.push({ node: null, start: rowText.length, length: 1 });
rowText += '$';
}
processSequence(rowText, rowSegments, katex);
cleanupRowMarkers();
moveLooseKatexIntoCells();
splitSpanningCell();
mergeIfSingleCell();
}
};
const patchedMarkdownCache = new WeakMap();
const patchedMarkdownAt = new WeakMap();
const getMarkdownHost = (node) => {
if (!node) return null;
if (node.closest) {
const direct = node.closest('ucs-fast-markdown, ucs-markdown, ucs-response-markdown');
if (direct) return direct;
}
const root = node.getRootNode ? node.getRootNode() : null;
if (root && root.host && root.host.matches && root.host.matches('ucs-fast-markdown, ucs-markdown, ucs-response-markdown')) {
return root.host;
}
return null;
};
const patchMarkdownHosts = (root) => {
const hosts = root.querySelectorAll('ucs-fast-markdown, ucs-markdown, ucs-response-markdown');
for (const host of hosts) {
if (!host || typeof host.markdown !== 'string') continue;
const current = host.markdown;
if (patchedMarkdownCache.get(host) === current) continue;
const patched = patchMarkdownBold(patchMarkdownTables(current));
patchedMarkdownCache.set(host, patched);
if (patched !== current) {
try {
const hostRoot = host.shadowRoot || host;
hostRoot.querySelectorAll('[data-gemini-inline-math-fix]').forEach((el) => el.remove());
host.markdown = patched;
if (typeof host.requestUpdate === 'function') host.requestUpdate();
if (typeof host.scheduleRender === 'function') host.scheduleRender();
patchedMarkdownAt.set(host, Date.now());
setTimeout(schedule, PATCH_SKIP_WINDOW_MS + 50);
} catch (e) {
// Ignore readonly markdown or render errors.
}
}
}
};
const observedRoots = new WeakSet();
const observeRoot = (root) => {
if (!root || observedRoots.has(root)) return;
observedRoots.add(root);
const observer = new MutationObserver(schedule);
observer.observe(root, { subtree: true, childList: true, characterData: true });
};
const ROOT_STYLE_ID = 'gemini-inline-math-fix-style';
const ensureRootStyles = (root) => {
if (!root || typeof root.querySelector !== 'function') return;
if (root.querySelector(`#${ROOT_STYLE_ID}`)) return;
const style = document.createElement('style');
style.id = ROOT_STYLE_ID;
style.textContent = `
.disclaimer { display: none !important; }
.main.chat-mode.omnibar { padding-bottom: 0 !important; }
.main.chat-mode.omnibar form.omnibar { margin-bottom: 0 !important; }
/* KaTeX inside shadow roots may miss base CSS, causing MathML+HTML duplication. */
.katex .katex-mathml {
position: absolute !important;
overflow: hidden !important;
clip: rect(1px, 1px, 1px, 1px) !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
border: 0 !important;
}
.katex .katex-html { position: relative; }
`;
root.appendChild(style);
};
const collectShadowRoots = (root, out) => {
if (!root) return;
out.push(root);
observeRoot(root);
ensureRootStyles(root);
const els = root.querySelectorAll('*');
for (const el of els) {
if (el.shadowRoot) collectShadowRoots(el.shadowRoot, out);
}
};
const REFRESH_BUTTON_ID = 'gemini-inline-math-fix-refresh';
const ensureRefreshButton = () => {
if (document.getElementById(REFRESH_BUTTON_ID)) return;
const btn = document.createElement('button');
btn.id = REFRESH_BUTTON_ID;
btn.type = 'button';
btn.textContent = '↻';
btn.title = '刷新公式渲染';
btn.setAttribute('aria-label', '刷新公式渲染');
btn.style.cssText = [
'position:fixed',
'right:16px',
'bottom:110px',
'z-index:2147483647',
'width:40px',
'height:40px',
'border-radius:20px',
'border:1px solid rgba(255,255,255,0.14)',
'background:rgba(32,33,36,0.86)',
'color:#fff',
'font-size:18px',
'line-height:40px',
'text-align:center',
'cursor:pointer',
'box-shadow:0 6px 18px rgba(0,0,0,0.35)',
'backdrop-filter:blur(6px)'
].join(';');
btn.addEventListener('click', (e) => {
e.preventDefault();
schedule();
});
(document.body || document.documentElement).appendChild(btn);
};
let katexHooked = false;
const hookKatex = (katex) => {
if (!katex || typeof katex.render !== 'function') return false;
if (katex.__geminiInlineMathFixWrapped) return true;
try {
const originalRender = katex.render.bind(katex);
const wrappedRender = (latex, element, options) => {
try {
return originalRender(sanitizeLatexForKatex(latex), element, options);
} catch (e) {
return originalRender(latex, element, options);
}
};
wrappedRender.__geminiInlineMathFixWrapped = true;
wrappedRender.__geminiInlineMathFixOriginal = originalRender;
katex.render = wrappedRender;
if (typeof katex.renderToString === 'function') {
const originalRenderToString = katex.renderToString.bind(katex);
const wrappedRenderToString = (latex, options) => {
try {
return originalRenderToString(sanitizeLatexForKatex(latex), options);
} catch (e) {
return originalRenderToString(latex, options);
}
};
wrappedRenderToString.__geminiInlineMathFixWrapped = true;
wrappedRenderToString.__geminiInlineMathFixOriginal = originalRenderToString;
katex.renderToString = wrappedRenderToString;
}
katex.__geminiInlineMathFixWrapped = true;
return true;
} catch (e) {
return false;
}
};
let katexHookAttempts = 0;
const KATEX_HOOK_WAIT_MAX_ATTEMPTS = 200;
const KATEX_HOOK_WAIT_MS = 50;
const scheduleKatexHook = () => {
if (katexHooked) return;
const katex = getKatex();
if (katex) {
katexHooked = hookKatex(katex);
return;
}
if (katexHookAttempts >= KATEX_HOOK_WAIT_MAX_ATTEMPTS) return;
katexHookAttempts += 1;
setTimeout(scheduleKatexHook, KATEX_HOOK_WAIT_MS);
};
let katexWaitAttempts = 0;
const KATEX_WAIT_MAX_ATTEMPTS = 30;
const KATEX_WAIT_MS = 600;
let followUpAttempts = 0;
const FOLLOW_UP_MAX_ATTEMPTS = 4;
const FOLLOW_UP_DELAY_MS = 650;
const hasUnrenderedMathDelimiters = (text) => {
if (!text) return false;
if (!text.includes('$') && !text.includes('\\(') && !text.includes('\\[')) return false;
const re = /\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$\$([\s\S]+?)\$\$|\$([^$\n]+?)\$/g;
return re.test(text);
};
const EXISTING_KATEX_REPAIRED_ATTR = 'data-gemini-inline-math-fix-existing';
const repairExistingKatexOperators = (root, katex) => {
if (!root || typeof root.querySelectorAll !== 'function') return;
const katexEls = Array.from(root.querySelectorAll('.katex'));
for (const el of katexEls) {
if (!el || typeof el.querySelector !== 'function') continue;
if (el.closest(`[${EXISTING_KATEX_REPAIRED_ATTR}]`)) continue;
if (el.closest('[data-gemini-inline-math-fix]')) continue;
const ann = el.querySelector('.katex-mathml annotation');
const tex = ann && ann.textContent ? ann.textContent : '';
if (!tex) continue;
const repaired = restoreOperatorSetBraces(tex);
if (repaired === tex) continue;
const displayContainer = el.closest('.katex-display');
const container = displayContainer || el;
if (!container || container.closest('[data-gemini-inline-math-fix]')) continue;
if (container.hasAttribute(EXISTING_KATEX_REPAIRED_ATTR)) continue;
const displayMode = !!displayContainer;
try {
katex.render(repaired, container, {
displayMode,
throwOnError: false,
strict: 'ignore'
});
if (isKatexError(container)) {
katex.render(tex, container, {
displayMode,
throwOnError: false,
strict: 'ignore'
});
continue;
}
container.setAttribute(EXISTING_KATEX_REPAIRED_ATTR, '1');
} catch (e) {
// Ignore render failures.
}
}
};
const processAll = () => {
ensureRefreshButton();
const katex = getKatex();
if (!katex) {
if (katexWaitAttempts < KATEX_WAIT_MAX_ATTEMPTS) {
katexWaitAttempts += 1;
setTimeout(schedule, KATEX_WAIT_MS);
}
return;
}
hookKatex(katex);
katexWaitAttempts = 0;
const app = document.querySelector('ucs-standalone-app');
if (!app || !app.shadowRoot) return;
const roots = [];
collectShadowRoots(app.shadowRoot, roots);
const processedDocs = new Set();
for (const r of roots) {
patchMarkdownHosts(r);
const docs = r.querySelectorAll('.markdown-document');
for (const doc of docs) {
const host = getMarkdownHost(doc);
if (host) {
const patchedAt = patchedMarkdownAt.get(host);
if (patchedAt && Date.now() - patchedAt < PATCH_SKIP_WINDOW_MS) continue;
}
processRoot(doc, katex);
processTableRows(doc, katex);
processedDocs.add(doc);
}
const markdowns = r.querySelectorAll('ucs-fast-markdown, ucs-markdown, ucs-response-markdown');
for (const fm of markdowns) {
if (fm.shadowRoot) {
const patchedAt = patchedMarkdownAt.get(fm);
if (patchedAt && Date.now() - patchedAt < PATCH_SKIP_WINDOW_MS) continue;
processRoot(fm.shadowRoot, katex);
processTableRows(fm.shadowRoot, katex);
fm.shadowRoot.querySelectorAll('.markdown-document').forEach((d) => processedDocs.add(d));
}
}
repairExistingKatexOperators(r, katex);
}
if (followUpAttempts < FOLLOW_UP_MAX_ATTEMPTS) {
let needsFollowUp = false;
for (const doc of processedDocs) {
if (hasUnrenderedMathDelimiters(doc.textContent || '')) {
needsFollowUp = true;
break;
}
}
if (needsFollowUp) {
followUpAttempts += 1;
setTimeout(schedule, FOLLOW_UP_DELAY_MS);
} else {
followUpAttempts = 0;
}
}
};
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
setTimeout(() => {
scheduled = false;
processAll();
}, 200);
};
const observer = new MutationObserver(schedule);
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true });
try {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.__geminiInlineMathFix) {
unsafeWindow.__geminiInlineMathFix.refresh = () => schedule();
}
} catch (e) {
// Ignore if unsafeWindow is blocked.
}
scheduleKatexHook();
schedule();
})();