Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Fix Gemini Parser

Chrome extension and Tampermonkey userscript for correcting inline Markdown and
LaTeX while Gemini streams a response. Version 5.0.3 uses the same rendering engine
LaTeX while Gemini streams a response. Version 5.0.4 uses the same rendering engine
for both distributions.

## Install
Expand Down Expand Up @@ -74,7 +74,8 @@ are registered at document scope and its layout styles at shadow scope.

## Supported scope

The engine operates on `p` and `h1`–`h6` in `.chat-container`, excluding known user
The engine operates on `p`, `h1`–`h6`, and existing direct `span` children of
`td`/`th` in `.chat-container`, excluding known user
query/editor/footer/code regions. Simple native inline formatting is retained.
Adjacent text nodes and spans carrying only Gemini path/index/Angular metadata
can form one Markdown token.
Expand All @@ -87,8 +88,14 @@ elements with `data-math` are read as expressions and rendered by our own KaTeX,
including after Gemini finalizes the source. Their descendants are never cloned;
formulas containing interactive descendants still fall back to native display.
Another component's shadow root
is never taken over. Bare `td`/`th` text stays native because those tags cannot
host shadow roots; supported paragraphs/headings inside cells can be rendered.
is never taken over. Gemini's existing table-cell spans host the same streaming
preview as paragraphs, including math, native bold, inline code, and color settings.
The table/cells and their content nodes are never wrapped, moved, or replaced;
column layout, merged cells, scrolling, and native copy data remain owned by Gemini.
Cell hosts with interactive/unknown attributes or unsafe descendants stay native.
Bare `td`/`th` text stays native because those tags cannot host shadow roots;
supported paragraphs/headings inside cells can also be rendered. Delimiters are
not joined across separate cell spans or across cells.
Markdown delimiters crossing styled native elements are processed per text run,
not across those element boundaries. This is an inline repair tool, not a full
replacement for Gemini's block Markdown renderer.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Fix Gemini Parser",
"version": "5.0.3",
"version": "5.0.4",
"description": "Enhance your Google Gemini experience with proper markdown and LaTeX rendering.",
"permissions": ["storage"],
"icons": {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fix-gemini-parser",
"version": "5.0.3",
"version": "5.0.4",
"private": true,
"scripts": {
"build:userscript": "node scripts/build-userscript.cjs",
Expand Down
19 changes: 12 additions & 7 deletions parser-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

const DEFAULTS = { enabled: true, latex: true, bold: true, italic: true, strike: true, underline: true, code: true };
const COLOR_KEYS = ['boldColor', 'italicColor', 'strikeColor', 'underlineColor', 'codeColor', 'mathColor'];
const BLOCKS = 'p,h1,h2,h3,h4,h5,h6';
// TD/TH cannot host a shadow root. Gemini already wraps cell content in a
// SPAN: project on that existing node, leaving table layout/copy DOM intact.
const BLOCKS = 'p,h1,h2,h3,h4,h5,h6,td > span,th > span';
const INLINE_TAGS = new Set(['SPAN', 'B', 'STRONG', 'I', 'EM', 'S', 'DEL', 'U', 'CODE', 'BR']);
const FORMATS = { B: 'bold', STRONG: 'bold', I: 'italic', EM: 'italic', S: 'strike', DEL: 'strike', U: 'underline', CODE: 'code' };
const EXCLUDED = 'pre,code,[contenteditable="true"],[contenteditable=""],user-query,.user-query,.query-text,[data-role="user"],.response-footer';
Expand Down Expand Up @@ -262,19 +264,22 @@
}

function safeBlock(element) {
if (!element.isConnected || !element.closest('.chat-container') || element.closest(EXCLUDED) || element.isContentEditable) return false;
if (!element.matches(BLOCKS) || !element.isConnected || !element.closest('.chat-container') || element.closest(EXCLUDED) || element.isContentEditable) return false;
if (element.querySelector(BLOCKS)) return false;
function safeInline(child) {
if (!INLINE_TAGS.has(child.tagName) || child.shadowRoot || child.isContentEditable) return false;
function safeInline(child, host = false) {
if (!INLINE_TAGS.has(child.tagName) || (!host && child.shadowRoot) || child.isContentEditable) return false;
if ([...child.attributes].some(attribute => !['class', 'style', 'dir', 'lang', 'data-path-to-node', 'data-index-in-node'].includes(attribute.name) &&
!attribute.name.startsWith('_ngcontent-') && !(nativeMath(child) && attribute.name === 'data-math'))) return false;
// Gemini's finished formula is an atomic text presentation. Read its
// expression, never clone its descendants or any interactive widget.
if (nativeMath(child)) return !child.querySelector('a,button,input,textarea,select,[tabindex],[contenteditable],[role]');
if (/katex|mathjax|citation|attachment|source-chip/i.test(child.className)) return false;
return Array.from(child.children).every(safeInline);
return Array.from(child.children).every(child => safeInline(child));
}
return Array.from(element.children).every(safeInline);
// Cell spans may themselves be widgets or native math, unlike paragraph
// hosts. Apply the inline safety gate to the host's own attributes too.
if (element.tagName === 'SPAN') return !nativeMath(element) && safeInline(element, true);
return Array.from(element.children).every(child => safeInline(child));
}

function renderBlock(source, settings, colors, document, katex, markdown) {
Expand Down Expand Up @@ -451,7 +456,7 @@
if (destroyed || !settings.enabled) return;
counters.flushes++;
for (const element of active.keys()) {
if (!element.isConnected || !element.closest('.chat-container')) { restore(element); snapshots.delete(element); }
if (!element.matches(BLOCKS) || !element.isConnected || !element.closest('.chat-container')) { restore(element); snapshots.delete(element); }
}
if (scan) {
containers = new Set(document.querySelectorAll('.chat-container'));
Expand Down
5 changes: 4 additions & 1 deletion tests/extension.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ test('installed MV3 extension renders and toggles through the real popup and sto
await context.route('https://gemini.google.com/**', route => route.fulfill({
contentType: 'text/html',
headers: { 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' chrome-extension:; require-trusted-types-for 'script'; trusted-types 'none'" },
body: '<!doctype html><style>body{font:20px/1.5 Arial;margin:32px}.chat-container{width:420px}</style><div class="chat-container"><p id="answer">**live answer** and $x^2$</p><footer class="response-footer animated"></footer></div>'
body: '<!doctype html><style>body{font:20px/1.5 Arial;margin:32px}.chat-container{width:420px}</style><div class="chat-container"><p id="answer">**live answer** and $x^2$</p><table><tr><td><span data-path-to-node="1,0">**table answer** and $x^2$</span></td></tr></table><footer class="response-footer animated"></footer></div>'
}));
const page = await context.newPage();
await page.goto('https://gemini.google.com/app');
await page.waitForFunction(() => document.querySelector('#answer').shadowRoot?.querySelector('.katex'));
await page.waitForFunction(() => document.querySelector('td > span').shadowRoot?.querySelector('.katex'));
assert.deepEqual(await page.evaluate(() => {
const p = document.querySelector('#answer'); window.nativeText = p.firstChild;
return [p.textContent, p.shadowRoot.querySelector('b').textContent, p.innerText];
Expand All @@ -35,9 +36,11 @@ test('installed MV3 extension renders and toggles through the real popup and sto
await popup.locator('.main-switch').waitFor();
await popup.locator('.main-switch').click();
await page.waitForFunction(() => document.querySelector('#answer').shadowRoot?.firstChild.tagName === 'SLOT');
assert.equal(await page.evaluate(() => document.querySelector('td > span').shadowRoot.firstChild.tagName), 'SLOT');
await page.evaluate(() => { nativeText.data = '**changed while off**'; });
await popup.locator('.main-switch').click();
await page.waitForFunction(() => document.querySelector('#answer').shadowRoot?.querySelector('b')?.textContent === 'changed while off');
await page.waitForFunction(() => document.querySelector('td > span').shadowRoot?.querySelector('b')?.textContent === 'table answer');
assert.equal(await page.evaluate(() => document.querySelector('#answer').firstChild === nativeText), true);

// A newly opened Gemini tab also starts disabled, but its listener is alive.
Expand Down
Loading
Loading