Repository navigation
fix(gemini): preserve linked text and images in html and pdf exports (#110) #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c84bf44
fix(gemini): preserve linked text and images in html and pdf exports …
deadrat-in 4df1a9d
fix(gemini): isolate draft citations, protect code blocks, and harden…
deadrat-in 502637d
fix(gemini): consume paired image tags and escape brackets in labels
deadrat-in da3e10b
fix(gemini): escape backslashes before brackets and use exact asserti…
deadrat-in File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -460,7 +460,12 @@ export class GeminiParser extends ChatParser { | |
| }); | ||
| } | ||
|
|
||
| const modelText = this.findModelTextInApiItem(item, options); | ||
| const modelCand = this.findModelCandidateInApiItem(item); | ||
| let modelText = this.findModelTextInApiItem(item, options); | ||
| if (modelCand) { | ||
| modelText = this.resolveImageTags(modelText, modelCand); | ||
| modelText = this.resolveMediaPlaceholders(modelText, modelCand); | ||
| } | ||
| const researchExtras = this.extractDeepResearchExtras(item); | ||
| const combined = [modelText, researchExtras] | ||
| .map((t) => this.stripChipPlaceholders(t)) | ||
|
|
@@ -478,6 +483,109 @@ export class GeminiParser extends ChatParser { | |
| return messages; | ||
| } | ||
|
|
||
| buildImageTagMap(cand) { | ||
| const map = new Map(); | ||
| if (!cand || !Array.isArray(cand[12])) return map; | ||
| const imageEntries = cand[12][1]; | ||
| if (!Array.isArray(imageEntries)) return map; | ||
|
|
||
| for (const entry of imageEntries) { | ||
| if (!Array.isArray(entry)) continue; | ||
| const url = entry[0]?.[0]?.[0] || entry[3]?.[0]?.[0]; | ||
| if (!url || typeof url !== "string" || !url.startsWith("http")) continue; | ||
|
|
||
| const tagInfo = entry[7]; | ||
| if (Array.isArray(tagInfo) && typeof tagInfo[0] === "string") { | ||
| const fullTag = tagInfo[0]; | ||
| map.set(fullTag, url); | ||
| const tagIdMatch = fullTag.match( | ||
| /(?:image_agent_tag_\w+|lmdx_image\/\w+)/, | ||
| ); | ||
| if (tagIdMatch) { | ||
| map.set(tagIdMatch[0], url); | ||
| } | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
|
|
||
| resolveImageTags(text, cand) { | ||
| if (typeof text !== "string" || !text) return text; | ||
| const imageMap = this.buildImageTagMap(cand); | ||
|
|
||
| // Protect fenced and inline code blocks before tag replacement | ||
| const codePlaceholders = []; | ||
| let counter = 0; | ||
| const protectedText = text.replace( | ||
| /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+?`)/g, | ||
| (match) => { | ||
| const id = `@@ACE_CODE_${counter++}@@`; | ||
| codePlaceholders.push({ id, content: match }); | ||
| return id; | ||
| }, | ||
| ); | ||
|
|
||
| let replaced = protectedText.replace( | ||
| /<Image\s+([^>]*?)(?:\/>|<\/Image>|>)/gi, | ||
|
gitar-bot[bot] marked this conversation as resolved.
Outdated
|
||
| (match, attrs) => { | ||
| const altMatch = | ||
| attrs.match(/alt="([^"]*)"/i) || attrs.match(/alt='([^']*)'/i); | ||
| const capMatch = | ||
| attrs.match(/caption="([^"]*)"/i) || | ||
| attrs.match(/caption='([^']*)'/i); | ||
| const srcMatch = | ||
| attrs.match(/src="([^"]*)"/i) || attrs.match(/src='([^']*)'/i); | ||
|
|
||
| const src = srcMatch ? srcMatch[1] : ""; | ||
| const alt = altMatch | ||
| ? altMatch[1] | ||
| : capMatch | ||
| ? capMatch[1] | ||
| : "Image"; | ||
|
|
||
| const resolvedUrl = | ||
| imageMap.get(src) || (src.startsWith("http") ? src : null); | ||
| if (resolvedUrl) { | ||
| return ``; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+529
to
+553
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Medium 6. Released extension misses image fixes The new resolveImageTags behavior exists only in the workspace parser, while the standalone decant package still has the old candidate-processing path. When a release build runs before decant-core is synchronized and published, the extension installs the old package and ships without the Gemini image-resolution changes. |
||
| } | ||
| return match; | ||
| }, | ||
| ); | ||
|
|
||
| for (let i = codePlaceholders.length - 1; i >= 0; i--) { | ||
| const { id, content } = codePlaceholders[i]; | ||
| replaced = replaced.replace(id, () => content); | ||
| } | ||
| return replaced; | ||
| } | ||
|
|
||
| resolveMediaPlaceholders(text, cand) { | ||
| if (typeof text !== "string" || !text) return text; | ||
| if (!cand || !Array.isArray(cand[12])) return text; | ||
| const mediaEntries = cand[12][4]; | ||
| if (!Array.isArray(mediaEntries)) return text; | ||
|
|
||
| let resolved = text; | ||
| for (const entry of mediaEntries) { | ||
| if (!Array.isArray(entry)) continue; | ||
| const tagUrl = entry[0]?.[0]; | ||
| const videoDetail = entry[4]?.[0]?.[0]; | ||
| if (typeof tagUrl === "string" && Array.isArray(videoDetail)) { | ||
| const title = videoDetail[0] || "Video"; | ||
| const url = videoDetail[2]; | ||
| if (url && typeof url === "string" && url.startsWith("http")) { | ||
| const escaped = tagUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| const replacement = `[${title}](${url})`; | ||
| resolved = resolved.replace( | ||
| new RegExp(`${escaped}(?![\\w/])`, "g"), | ||
| () => replacement, | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
gitar-bot[bot] marked this conversation as resolved.
|
||
| return resolved; | ||
| } | ||
|
|
||
| // Deep-research turns render a short summary plus placeholder chip links | ||
| // (e.g. http://googleusercontent.com/immersive_entry_chip/0) whose real | ||
| // content — research plan, full report, citation map — lives in adjacent | ||
|
|
@@ -632,15 +740,42 @@ export class GeminiParser extends ChatParser { | |
| const seen = new Map(); | ||
| try { | ||
| const trail = item[3]?.[4]; | ||
| if (!Array.isArray(trail)) return []; | ||
| for (const entry of trail) { | ||
| const detail = entry?.[4]?.[2]; | ||
| const url = Array.isArray(detail) ? detail[1] : null; | ||
| if (typeof url !== "string" || !url.startsWith("http")) continue; | ||
| if (seen.has(url)) continue; | ||
| const title = | ||
| typeof detail[2] === "string" && detail[2] ? detail[2] : url; | ||
| seen.set(url, title); | ||
| if (Array.isArray(trail)) { | ||
| for (const entry of trail) { | ||
| const detail = entry?.[4]?.[2]; | ||
| const url = Array.isArray(detail) ? detail[1] : null; | ||
| if (typeof url !== "string" || !url.startsWith("http")) continue; | ||
| if (seen.has(url)) continue; | ||
| const title = | ||
| typeof detail[2] === "string" && detail[2] ? detail[2] : url; | ||
| seen.set(url, title); | ||
| } | ||
| } | ||
|
|
||
| // Also check candidate citations (cand[2]) of the active candidate only | ||
| const visibleCand = this.findModelCandidateInApiItem(item); | ||
| if (visibleCand && Array.isArray(visibleCand[2])) { | ||
| for (const citeGroup of visibleCand[2]) { | ||
| if (!Array.isArray(citeGroup)) continue; | ||
| for (const cite of citeGroup) { | ||
| if (!Array.isArray(cite)) continue; | ||
| const meta = cite[2]; | ||
| const url = | ||
| Array.isArray(meta) && | ||
| typeof meta[0] === "string" && | ||
| meta[0].startsWith("http") | ||
| ? meta[0] | ||
| : null; | ||
| if (url && !seen.has(url)) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| seen.set(url, parsed.hostname.replace(/^www\./, "") || url); | ||
| } catch { | ||
| seen.set(url, url); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore malformed activity trails | ||
|
|
@@ -733,6 +868,33 @@ export class GeminiParser extends ChatParser { | |
| return ""; | ||
| } | ||
|
|
||
| findModelCandidateInApiItem(item) { | ||
| try { | ||
| if (Array.isArray(item[3])) { | ||
| const candidates = Array.isArray(item[3][0]) ? item[3][0] : item[3]; | ||
| for (const cand of candidates) { | ||
| if (!Array.isArray(cand)) continue; | ||
| if (Array.isArray(cand[1]) && typeof cand[1][0] === "string") { | ||
| return cand; | ||
| } | ||
| if (typeof cand[1] === "string") { | ||
| return cand; | ||
| } | ||
| if (typeof cand[0] === "string" && cand[0].length > 50) { | ||
| return cand; | ||
| } | ||
| } | ||
| } | ||
| if (Array.isArray(item[1])) { | ||
| const candidate = item[1][0]; | ||
| if (Array.isArray(candidate)) return candidate; | ||
| } | ||
| } catch { | ||
| // Fall through | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| extractTitleFromPage() { | ||
| if (typeof document !== "undefined" && document.title) { | ||
| const cleanedDocTitle = document.title | ||
|
|
@@ -905,6 +1067,66 @@ export class GeminiParser extends ChatParser { | |
| if (markdownDiv) { | ||
| const clone = markdownDiv.cloneNode(true); | ||
|
|
||
| // Extract sources from .sources-list before removal. | ||
| // Check both modelResponse and clone so sources in footers outside markdownDiv are not missed. | ||
| const domSources = []; | ||
| const sourceLists = [ | ||
| ...modelResponse.querySelectorAll(".sources-list"), | ||
| ...clone.querySelectorAll(".sources-list"), | ||
| ]; | ||
| sourceLists.forEach((sl) => { | ||
| sl.querySelectorAll("a[href]").forEach((a) => { | ||
| const href = a.getAttribute("href")?.trim(); | ||
| const title = a.textContent?.trim() || href; | ||
| if ( | ||
| href && | ||
| href.startsWith("http") && | ||
| !domSources.some((s) => s.href === href) | ||
| ) { | ||
| domSources.push({ href, title }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| // Convert citation chips and source buttons to links or text before generic button removal | ||
| clone | ||
| .querySelectorAll( | ||
| 'button[aria-label*="View source"], button:has([class*="citation-chip"]), [class*="citation-chip"], citation-chip', | ||
| ) | ||
| .forEach((btn) => { | ||
| const titleEl = btn.querySelector( | ||
| '[class*="citation-chip__title"], [class*="citation_title"]', | ||
| ); | ||
| const ariaLabel = btn.getAttribute("aria-label") || ""; | ||
| const label = titleEl | ||
| ? titleEl.textContent.trim() | ||
| : ariaLabel.replace(/^View source:\s*/i, "").trim(); | ||
| const anchor = | ||
| btn.querySelector("a[href]") || btn.closest("a[href]"); | ||
| const href = anchor ? anchor.getAttribute("href") : null; | ||
|
|
||
| if (href && href.startsWith("http")) { | ||
| const link = clone.ownerDocument.createElement("a"); | ||
| link.setAttribute("href", href); | ||
| link.textContent = label || href; | ||
| btn.replaceWith(link); | ||
| } else if (label) { | ||
| const textNode = | ||
| clone.ownerDocument.createTextNode(` [${label}]`); | ||
| btn.replaceWith(textNode); | ||
| } else { | ||
| btn.remove(); | ||
| } | ||
| }); | ||
|
|
||
| // Unwrap any <a> tags inside remaining buttons so links are not deleted | ||
| clone.querySelectorAll("button a[href]").forEach((a) => { | ||
| const btn = a.closest("button"); | ||
| if (btn && btn.parentNode) { | ||
| btn.parentNode.insertBefore(a, btn); | ||
| } | ||
| }); | ||
|
|
||
| // Remove UI buttons, thought overlays, follow-up suggestion | ||
| // widgets, and interactive toolbars. | ||
| // Note: .hide-from-message-actions is NOT removed — it wraps | ||
|
|
@@ -916,17 +1138,26 @@ export class GeminiParser extends ChatParser { | |
| ) | ||
| .forEach((el) => el.remove()); | ||
|
|
||
| // Unwrap response-element wrappers and message-action guards | ||
| // Unwrap response-element wrappers, image containers, and message-action guards | ||
| clone | ||
| .querySelectorAll("response-element, .hide-from-message-actions") | ||
| .querySelectorAll( | ||
| "response-element, image-container, picture, .hide-from-message-actions", | ||
| ) | ||
| .forEach((el) => { | ||
| while (el.firstChild) { | ||
| el.parentNode.insertBefore(el.firstChild, el); | ||
| } | ||
| el.remove(); | ||
| }); | ||
|
|
||
| const text = convertToMarkdown(clone); | ||
| let text = convertToMarkdown(clone); | ||
| if (domSources.length > 0) { | ||
| const sourcesText = domSources | ||
| .map((s) => `- [${s.title}](${s.href})`) | ||
| .join("\n"); | ||
| text += `\n\n**Sources consulted:**\n${sourcesText}`; | ||
| } | ||
|
|
||
| const trimmed = text.trim(); | ||
| if (trimmed && !seenTexts.has(trimmed)) { | ||
| seenTexts.add(trimmed); | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.