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
Changes from all commits
c84bf44
4df1a9d
502637d
da3e10b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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,117 @@ 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+([^>]*?)(?:\/>|>(?:\s*<\/Image>)?)/gi, | ||
|
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 4. Image labels break at greater-than signs resolveImageTags stops its attribute capture at the first > instead of respecting quoted attribute values. When Gemini returns an alt or caption such as alt="a > b", the parser falls back to the default label and leaves the remainder of the tag in the response, while the HTML fallback has the same failure mode. |
||
| (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 rawAlt = altMatch | ||
| ? altMatch[1] | ||
| : capMatch | ||
| ? capMatch[1] | ||
| : "Image"; | ||
| const alt = rawAlt | ||
| .replace(/\\/g, "\\\\") | ||
| .replace(/\[/g, "\\[") | ||
| .replace(/\]/g, "\\]"); | ||
|
|
||
| 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 rawTitle = videoDetail[0] || "Video"; | ||
| const title = rawTitle | ||
| .replace(/\\/g, "\\\\") | ||
| .replace(/\[/g, "\\[") | ||
| .replace(/\]/g, "\\]"); | ||
| 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 +748,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 +876,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 +1075,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 +1146,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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Match Image tags with no attributes.
For
<Image/>or<Image></Image>,\s+prevents the regex from matching. The raw tag then remains in the export instead of being removed when it has nosrc. Allow zero attributes while keeping a tag-name boundary.The supplied change details state that tags without an allowed
srcare removed.Proposed fix
📝 Committable suggestion