@@ -250,6 +250,131 @@ function markSlottedSubtree(root) {
250250 root . querySelectorAll ( '*' ) . forEach ( el => el . setAttribute ( 'data-sd-slotted' , '' ) ) ;
251251 }
252252}
253+
254+
255+ /**
256+ * Wait for an accessible same-origin Document for a given <iframe>.
257+ * @param {HTMLIFrameElement } iframe
258+ * @param {number } [attempts=3]
259+ * @returns {Promise<Document|null> }
260+ */
261+ async function getAccessibleIframeDocument ( iframe , attempts = 3 ) {
262+ const probe = ( ) => {
263+ try { return iframe . contentDocument || iframe . contentWindow ?. document || null ; } catch { return null ; }
264+ } ;
265+ let doc = probe ( ) ;
266+ let i = 0 ;
267+ while ( i < attempts && ( ! doc || ( ! doc . body && ! doc . documentElement ) ) ) {
268+ await new Promise ( r => setTimeout ( r , 0 ) ) ;
269+ doc = probe ( ) ;
270+ i ++ ;
271+ }
272+ return doc && ( doc . body || doc . documentElement ) ? doc : null ;
273+ }
274+
275+ /**
276+ * Compute the content-box size of an element (client rect minus borders).
277+ * @param {Element } el
278+ * @returns {{contentWidth:number, contentHeight:number, rect:DOMRect} }
279+ */
280+ function measureContentBox ( el ) {
281+ const rect = el . getBoundingClientRect ( ) ;
282+ let bl = 0 , br = 0 , bt = 0 , bb = 0 ;
283+ try {
284+ const cs = getComputedStyle ( el ) ;
285+ bl = parseFloat ( cs . borderLeftWidth ) || 0 ;
286+ br = parseFloat ( cs . borderRightWidth ) || 0 ;
287+ bt = parseFloat ( cs . borderTopWidth ) || 0 ;
288+ bb = parseFloat ( cs . borderBottomWidth ) || 0 ;
289+ } catch { }
290+ const contentWidth = Math . max ( 0 , Math . round ( rect . width - ( bl + br ) ) ) ;
291+ const contentHeight = Math . max ( 0 , Math . round ( rect . height - ( bt + bb ) ) ) ;
292+ return { contentWidth, contentHeight, rect } ;
293+ }
294+
295+ /**
296+ * Temporarily pin the iframe's internal viewport to (w, h) CSS px.
297+ * Injects a <style> into the iframe doc and returns a cleanup function.
298+ * @param {Document } doc
299+ * @param {number } w
300+ * @param {number } h
301+ * @returns {() => void }
302+ */
303+ function pinIframeViewport ( doc , w , h ) {
304+ const style = doc . createElement ( 'style' ) ;
305+ style . setAttribute ( 'data-sd-iframe-pin' , '' ) ;
306+ style . textContent = `
307+ html, body {
308+ margin: 0 !important;
309+ padding: 0 !important;
310+ width: ${ w } px !important;
311+ height: ${ h } px !important;
312+ min-width: ${ w } px !important;
313+ min-height: ${ h } px !important;
314+ box-sizing: border-box !important;
315+ overflow: hidden !important;
316+ background-clip: border-box !important;
317+ }
318+ ` ;
319+ ( doc . head || doc . documentElement ) . appendChild ( style ) ;
320+ return ( ) => { try { style . remove ( ) ; } catch { } } ;
321+ }
322+
323+ /**
324+ * Rasterize a same-origin iframe exactly at its content-box size, as the user requested:
325+ * - Capture iframe.contentDocument.documentElement
326+ * - Force a bitmap (toPng) sized to the iframe viewport (not the content height)
327+ * - Wrap with a styled container that mimics the <iframe> box (borders, radius, etc.)
328+ *
329+ * @param {HTMLIFrameElement } iframe
330+ * @param {object } sessionCache
331+ * @param {object } options
332+ * @returns {Promise<HTMLElement> }
333+ */
334+ async function rasterizeIframe ( iframe , sessionCache , options ) {
335+ const doc = await getAccessibleIframeDocument ( iframe , 3 ) ;
336+ if ( ! doc ) throw new Error ( 'iframe document not accessible/ready' ) ;
337+
338+ const { contentWidth, contentHeight, rect } = measureContentBox ( iframe ) ;
339+
340+ // Prefer snapdom from the iframe realm; fallback to host's window.snapdom
341+ const snap = options ?. snap
342+ if ( ! snap || typeof snap . toPng !== 'function' ) {
343+ throw new Error ( 'snapdom.toPng not available in iframe or window' ) ;
344+ }
345+
346+ // Avoid double scaling; parent capture decides final scale
347+ const nested = { ...options , scale : 1 } ;
348+
349+ // Pin viewport so body background fills exactly content box (fixes 400x110 → 400x150)
350+ const unpin = pinIframeViewport ( doc , contentWidth , contentHeight ) ;
351+ let imgEl ;
352+ try {
353+ imgEl = await snap . toPng ( doc . documentElement , nested ) ;
354+ } finally {
355+ unpin ( ) ;
356+ }
357+
358+ // Build <img> (bitmap) sized to content box
359+
360+ imgEl . style . display = 'block' ;
361+ imgEl . style . width = `${ contentWidth } px` ;
362+ imgEl . style . height = `${ contentHeight } px` ;
363+
364+ // Wrapper that preserves the iframe box (border, radius...) and clips
365+ const wrapper = document . createElement ( 'div' ) ;
366+ sessionCache . nodeMap . set ( wrapper , iframe ) ;
367+ inlineAllStyles ( iframe , wrapper , sessionCache , options ) ;
368+ wrapper . style . overflow = 'hidden' ;
369+ wrapper . style . display = 'block' ;
370+ if ( ! wrapper . style . width ) wrapper . style . width = `${ Math . round ( rect . width ) } px` ;
371+ if ( ! wrapper . style . height ) wrapper . style . height = `${ Math . round ( rect . height ) } px` ;
372+
373+ wrapper . appendChild ( imgEl ) ;
374+ return wrapper ;
375+ }
376+
377+
253378
254379export async function deepClone ( node , sessionCache , options ) {
255380 if ( ! node ) throw new Error ( "Invalid node" ) ;
@@ -303,20 +428,40 @@ export async function deepClone(node, sessionCache, options) {
303428 console . warn ( "Error in filter function:" , err ) ;
304429 }
305430 }
306- if ( node . tagName === "IFRAME" ) {
307- if ( options . placeholders ) {
308- const fallback = document . createElement ( "div" ) ;
309- fallback . style . cssText = `width:${ node . offsetWidth } px;height:${ node . offsetHeight } px;background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;` ;
310- inlineAllStyles ( node , fallback , sessionCache , options ) ;
311- return fallback ;
312- } else {
313- const rect = node . getBoundingClientRect ( ) ;
314- const spacer = document . createElement ( "div" ) ;
315- spacer . style . cssText = `display:inline-block;width:${ rect . width } px;height:${ rect . height } px;visibility:hidden;` ;
316- inlineAllStyles ( node , spacer , sessionCache , options ) ;
317- return spacer ;
431+ if ( node . tagName === "IFRAME" ) {
432+ let sameOrigin = false ;
433+ try { sameOrigin = ! ! ( node . contentDocument || node . contentWindow ?. document ) ; } catch { sameOrigin = false ; }
434+
435+ if ( sameOrigin ) {
436+ try {
437+ const wrapper = await rasterizeIframe ( node , sessionCache , options ) ;
438+ return wrapper ;
439+ } catch ( err ) {
440+ console . warn ( '[SnapDOM] iframe rasterization failed, fallback:' , err ) ;
441+ // fall through
318442 }
319443 }
444+
445+ // Fallback actual (placeholder o spacer)
446+ if ( options . placeholders ) {
447+ const fallback = document . createElement ( "div" ) ;
448+ fallback . style . cssText =
449+ `width:${ node . offsetWidth } px;height:${ node . offsetHeight } px;` +
450+ `background-image:repeating-linear-gradient(45deg,#ddd,#ddd 5px,#f9f9f9 5px,#f9f9f9 10px);` +
451+ `display:flex;align-items:center;justify-content:center;font-size:12px;color:#555;border:1px solid #aaa;` ;
452+ inlineAllStyles ( node , fallback , sessionCache , options ) ;
453+ return fallback ;
454+ } else {
455+ const rect = node . getBoundingClientRect ( ) ;
456+ const spacer = document . createElement ( "div" ) ;
457+ spacer . style . cssText = `display:inline-block;width:${ rect . width } px;height:${ rect . height } px;visibility:hidden;` ;
458+ inlineAllStyles ( node , spacer , sessionCache , options ) ;
459+ return spacer ;
460+ }
461+ }
462+
463+
464+
320465 if ( node . getAttribute ( "data-capture" ) === "placeholder" ) {
321466 const clone2 = node . cloneNode ( false ) ;
322467 sessionCache . nodeMap . set ( clone2 , node ) ;
0 commit comments