From 0b0fba9a7204157856e21b496b94d841aa1b74d7 Mon Sep 17 00:00:00 2001 From: kangjoseph90 <83045825+kangjoseph90@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:28:31 +0900 Subject: [PATCH] fix: render inline formatting in existing Gemini table cell spans --- README.md | 15 +++-- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- parser-engine.js | 19 ++++-- tests/extension.test.cjs | 5 +- tests/streaming.test.cjs | 141 ++++++++++++++++++++++++++++++++++++++- user-script.js | 21 +++--- 8 files changed, 184 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index c911b6d..4c1facc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/manifest.json b/manifest.json index 1bd792b..f66eca2 100644 --- a/manifest.json +++ b/manifest.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index fde9fe2..e2e45d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fix-gemini-parser", - "version": "5.0.3", + "version": "5.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fix-gemini-parser", - "version": "5.0.3", + "version": "5.0.4", "devDependencies": { "playwright": "1.62.1" } diff --git a/package.json b/package.json index 5c76c6b..ab76548 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/parser-engine.js b/parser-engine.js index fb46f22..e463070 100644 --- a/parser-engine.js +++ b/parser-engine.js @@ -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'; @@ -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) { @@ -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')); diff --git a/tests/extension.test.cjs b/tests/extension.test.cjs index ae11299..5e784e9 100644 --- a/tests/extension.test.cjs +++ b/tests/extension.test.cjs @@ -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: '

**live answer** and $x^2$

' + body: '

**live answer** and $x^2$

**table answer** and $x^2$
' })); 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]; @@ -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. diff --git a/tests/streaming.test.cjs b/tests/streaming.test.cjs index 61539f7..0e4a5b9 100644 --- a/tests/streaming.test.cjs +++ b/tests/streaming.test.cjs @@ -183,7 +183,7 @@ test('interactive content added during streaming restores live original buttons assert.equal(await page.evaluate(() => source.firstChild === originalText), true); }); -test('attachments, native KaTeX, code blocks, user queries, editors, table cells and outside text stay native', async t => { +test('attachments, native KaTeX, code blocks, user queries, editors, bare table cells and outside text stay native', async t => { const page = await fixture(t, { html: '**attachment**' }); await page.evaluate(() => { const container = document.querySelector('.chat-container'); @@ -193,6 +193,145 @@ test('attachments, native KaTeX, code blocks, user queries, editors, table cells assert.deepEqual(await page.evaluate(() => ['answer', 'math', 'pre', 'user', 'editor', 'cell', 'outside'].map(id => !!document.getElementById(id).shadowRoot)), Array(7).fill(false)); }); +async function tableFixture(t, cells, options = {}) { + const page = await fixture(t, { text: 'plain', ...options }); + await page.evaluate(cells => { + document.querySelector('#response').insertAdjacentHTML('afterbegin', + '
' + + '' + cells + '
**수식**설명
' + + ''); + window.table = document.querySelector('table'); + window.tableMarkup = table.outerHTML; + window.tableNodes = [...table.querySelectorAll('*')]; + window.cell = table.querySelector('td > span'); + window.cellText = cell.firstChild; + window.cellView = () => cell.shadowRoot?.querySelector('.gp-view'); + document.querySelector('#table-copy').addEventListener('click', () => { + window.copiedTable = { text: table.innerText, html: table.outerHTML }; + }); + }, cells); + await page.waitForFunction(() => document.querySelector('th > span').shadowRoot?.querySelector('b')); + return page; +} + +test('Gemini cell spans render the reported formulas and native bold without changing table source or copy data', async t => { + const page = await tableFixture(t, String.raw`$\alpha + \beta = -\frac{b}{a}$를 근과 계수의 관계라 부릅니다. + 근과 계수를 활용한 풀이입니다. + $\sum_{k=1}^{n} k = \frac{n(n+1)}{2}$라는 공식입니다. + $literal$ **code**`, + { settings: { boldColor: { color: '#ffcc66' } } }); + assert.deepEqual(await page.evaluate(() => { + const hosts = [...table.querySelectorAll('td > span')]; + return [hosts.slice(0, 3).filter(host => host.shadowRoot?.querySelector('.katex')).length, + getComputedStyle(hosts[1].shadowRoot.querySelector('b')).color, + hosts[3].shadowRoot.querySelector('code').textContent, + table.outerHTML === tableMarkup, tableNodes.every((node, i) => table.querySelectorAll('*')[i] === node), + cell.firstChild === cellText, table.querySelectorAll('td').length]; + }), [2, 'rgb(255, 204, 102)', '$literal$ **code**', true, true, true, 4]); + await page.locator('#table-copy').click(); + assert.deepEqual(await page.evaluate(() => [copiedTable.html === tableMarkup, + copiedTable.text.includes('$\\alpha + \\beta = -\\frac{b}{a}$'), + copiedTable.text.includes('$\\sum_{k=1}^{n} k = \\frac{n(n+1)}{2}$')]), [true, true, true]); + const tree = await page.locator('table').ariaSnapshot(); + assert.match(tree, /columnheader "수식"/); + assert.doesNotMatch(tree, /\$\\alpha|\$\\sum|\*\*수식\*\*/); +}); + +test('table streaming and finalization retain source identity and stable formula nodes before paint', async t => { + const page = await tableFixture(t, 'start **opennative'); + assert.equal(await page.evaluate(() => cell.shadowRoot), null); + await page.evaluate(() => { cellText.data = '**steady** and $x^2$'; }); + await page.waitForFunction(() => cellView()?.querySelector('.katex')); + assert.deepEqual(await page.evaluate(async () => { + const oldView = cellView(), oldBold = oldView.querySelector('b'), oldMath = oldView.querySelector('.gemini-parser-math'); + const bold = document.createElement('b'); bold.setAttribute('data-path-to-node', '1,0'); bold.textContent = 'steady'; + const math = document.createElement('span'); math.className = 'math-inline'; math.setAttribute('data-math', 'x^2'); + katex.render('x^2', math, { output: 'html' }); + cell.replaceChildren(bold, document.createTextNode(' and '), math); + document.querySelector('footer').classList.remove('animated'); + await new Promise(requestAnimationFrame); + return [cellView() === oldView, cellView().querySelector('b') === oldBold, + cellView().querySelector('.gemini-parser-math') === oldMath, cell.firstChild === bold]; + }), [true, true, true, true]); + assert.equal(await page.evaluate(async () => { + const oldCell = cell; + cell = document.createElement('span'); cell.setAttribute('data-path-to-node', '1,0'); cell.textContent = '**replaced**'; + oldCell.replaceWith(cell); + await new Promise(requestAnimationFrame); + return cellView()?.querySelector('b')?.textContent; + }), 'replaced'); +}); + +test('table toggles, selection, off-stream updates and colors use one visible copy', async t => { + const page = await tableFixture(t, '**cell value**native'); + assert.equal(await page.evaluate(() => { + const range = document.createRange(); range.selectNodeContents(cellView()); + getSelection().removeAllRanges(); getSelection().addRange(range); return getSelection().toString(); + }), 'cell value'); + const tree = await page.locator('tbody td').first().ariaSnapshot(); + assert.doesNotMatch(tree, /\*\*/); + assert.equal((tree.match(/cell value/g) || []).length, 1, tree); + await page.evaluate(() => { + engine.updateSettings({ enabled: false }); cellText.data = '**changed while off**'; + }); + assert.deepEqual(await page.evaluate(() => [cell.firstChild === cellText, cell.shadowRoot.firstChild.tagName, + cell.innerText, cell.hasAttribute('style')]), [true, 'SLOT', '**changed while off**', false]); + await page.evaluate(() => { + for (let i = 0; i < 15; i++) { engine.updateSettings({ enabled: true }); engine.updateSettings({ enabled: false }); } + engine.updateSettings({ enabled: true, boldColor: { color: '#ffcc66' } }); + }); + await page.waitForFunction(() => cellView()?.querySelector('b')?.textContent === 'changed while off'); + assert.deepEqual(await page.evaluate(() => [cell.shadowRoot.querySelectorAll('.gp-view').length, + getComputedStyle(cellView().querySelector('b')).color, cell.firstChild === cellText]), [1, 'rgb(255, 204, 102)', true]); +}); + +test('table geometry, spanning cells and horizontal scrolling remain native and respond to width changes', async t => { + const page = await tableFixture(t, '**' + 'long value '.repeat(12) + + '****other**last' + + 'spanning'); + await page.addStyleTag({ content: '.table-content{overflow-x:auto}table{border-collapse:collapse;width:100%}td,th{padding:8px;border:1px solid gray;vertical-align:top}' }); + const height = await page.locator('table').evaluate(table => table.getBoundingClientRect().height); + await page.evaluate(() => { document.querySelector('.chat-container').style.width = '240px'; }); + await page.waitForFunction(height => table.getBoundingClientRect().height > height, height); + assert.deepEqual(await page.evaluate(() => [table.querySelector('td').rowSpan, + table.querySelector('tr:last-child td').colSpan, table.outerHTML === tableMarkup, + document.querySelector('#table-copy').getBoundingClientRect().top >= table.getBoundingClientRect().bottom, + getComputedStyle(table).display, getComputedStyle(table.querySelector('td')).display]), [2, 2, true, true, 'table', 'table-cell']); + await page.evaluate(() => { table.style.minWidth = '600px'; }); + assert.equal(await page.evaluate(() => { + const wrapper = table.parentElement; wrapper.scrollLeft = 50; + return wrapper.scrollWidth > wrapper.clientWidth && wrapper.scrollLeft === 50; + }), true); +}); + +test('cell widgets, native formula hosts and unsupported attributes stay native, including late-arriving controls', async t => { + const page = await tableFixture(t, '**safe**' + + '**widget****focus****tip**' + + 'x**cite**' + + ''); + assert.equal(await page.evaluate(() => [...table.querySelectorAll('td:nth-child(2) > span')].every(span => !span.shadowRoot)), true); + assert.equal(await page.evaluate(async () => { + const button = document.createElement('button'); button.textContent = 'native action'; + window.cellClicks = 0; button.addEventListener('click', () => cellClicks++); cell.append(button); + await new Promise(requestAnimationFrame); return cellView() === null; + }), true); + await page.locator('td button').filter({ hasText: 'native action' }).click(); + assert.equal(await page.evaluate(() => cellClicks), 1); + await page.evaluate(() => { cell.querySelector('button').remove(); }); + await page.waitForFunction(() => cellView()?.querySelector('b')); + await page.evaluate(() => { cell.setAttribute('role', 'button'); }); + await page.waitForFunction(() => !cellView()); +}); + +test('cell hosts moved outside a cell release their projection and regain it when moved back', async t => { + const page = await tableFixture(t, '**moving**native'); + await page.evaluate(() => document.querySelector('#response').append(cell)); + await page.waitForFunction(() => !cellView()); + await page.evaluate(() => table.querySelector('td').append(cell)); + await page.waitForFunction(() => cellView()?.querySelector('b')?.textContent === 'moving'); + assert.equal(await page.evaluate(() => cell.firstChild === cellText), true); +}); + test('another component shadow root is never overwritten', async t => { const page = await fixture(t, { text: 'plain' }); await page.evaluate(() => { diff --git a/user-script.js b/user-script.js index 0a10df5..bf312ac 100644 --- a/user-script.js +++ b/user-script.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Fix Gemini Parser // @namespace http://tampermonkey.net/ -// @version 5.0.3 +// @version 5.0.4 // @description Gemini의 스트리밍 마크다운과 수식을 원본 DOM을 유지하며 렌더링합니다. // @author kangjoseph90 // @match https://gemini.google.com/* @@ -21,7 +21,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'; @@ -279,19 +281,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) { @@ -468,7 +473,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'));