Skip to content

Commit ff7b10d

Browse files
Dylan Bradshawclaude
andcommitted
fix(clone): drop <picture> <source> elements so inlined <img> src is not overridden
A <picture>'s <source> out-ranks its <img>'s own src. The sources were cloned verbatim, so the exported SVG re-selected an external URL at rasterization time — and svg-as-image may not load external resources. The photo never painted, however correctly the <img> had been inlined, producing a large SVG that rasterized to a small blank PNG. Skip <source> children of a <picture> when cloning. The <img> clone is already frozen to the variant the live page chose (freezeImgSrcset sets src = original.currentSrc and strips srcset/sizes), so the sources carry nothing still needed and art direction is preserved. Verified against a WordPress hero carousel whose slides use <picture> with a catch-all <source media="(min-width: 0px)">, which made the failure deterministic. With the site's real assets and an identical 723 KB SVG: 48 KB PNG with 0% of the photo painted before, 2046 KB and 100% after. Capturing at a 1600px viewport embeds the 2200x1200 crops; at 500px it embeds the 600x1000 crops. Closes #462 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 509bed1 commit ff7b10d

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

__tests__/core.clone.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,51 @@ describe('deepClone', () => {
109109
})
110110
})
111111

112+
describe('deepClone — <picture> sources', () => {
113+
const PX = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
114+
115+
function makePicture() {
116+
const picture = document.createElement('picture')
117+
picture.innerHTML =
118+
'<source media="(min-width: 1367px)" srcset="https://example.com/big.jpg">' +
119+
'<source media="(min-width: 0px)" srcset="https://example.com/small.jpg">'
120+
const img = document.createElement('img')
121+
img.src = PX
122+
picture.appendChild(img)
123+
document.body.appendChild(picture)
124+
return picture
125+
}
126+
127+
// A <source> out-ranks the <img>'s own src, so leaving it in the export re-selects an
128+
// external URL that svg-as-image may not load — the picture then rasterizes blank even
129+
// though the <img> was inlined correctly.
130+
it('drops <source> children of <picture> so the inlined <img> src wins', async () => {
131+
const picture = makePicture()
132+
try {
133+
const clone = await runClone(picture)
134+
expect(clone.querySelectorAll('source').length).toBe(0)
135+
const img = clone.querySelector('img')
136+
expect(img).not.toBeNull()
137+
expect(img.getAttribute('src')).toBe(PX)
138+
expect(clone.outerHTML).not.toContain('example.com')
139+
} finally {
140+
picture.remove()
141+
}
142+
})
143+
144+
it('keeps <source> outside a <picture>', async () => {
145+
const wrapper = document.createElement('div')
146+
wrapper.innerHTML = '<source srcset="https://example.com/x.jpg">'
147+
document.body.appendChild(wrapper)
148+
try {
149+
const clone = await runClone(wrapper)
150+
expect(clone.querySelectorAll('source').length).toBe(1)
151+
} finally {
152+
wrapper.remove()
153+
}
154+
})
155+
})
156+
112157
describe('deepClone edge cases', () => {
113158
it('clones unsupported node (Comment) as a new Comment', async () => {
114159
const fake = document.createComment('not supported')

src/core/clone.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ export async function deepClone(node, sessionCache, options) {
186186
debugWarn(sessionCache, 'Nested <foreignObject> skipped (SVG spec limitation — not rendered by browsers)')
187187
return null
188188
}
189+
// A <picture>'s <source> out-ranks its <img>'s own src, so it survives into the export
190+
// still pointing at an external URL — and svg-as-image may not load external resources,
191+
// so the picture rasterizes blank no matter how well the <img> was inlined. The <img>
192+
// clone is already frozen to the variant the live page chose (freezeImgSrcset), so the
193+
// sources carry nothing we still need: drop them and let that src win.
194+
if (tag === 'source' && node.parentElement?.localName === 'picture') {
195+
return null
196+
}
189197
}
190198
if (node.nodeType === Node.TEXT_NODE) {
191199
return node.cloneNode(true)

0 commit comments

Comments
 (0)