interface CaptureOptions { scale?: number; } function createTransparentDataUrl(width = 1, height = 1) { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent( `` )}`; } function blobToDataUrl(blob: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => reject(reader.error || new Error('Failed to read blob as data URL')); reader.readAsDataURL(blob); }); } async function urlToDataUrl(url: string) { const resolvedUrl = new URL(url, window.location.href); const requestUrl = resolvedUrl.origin === window.location.origin ? resolvedUrl.toString() : `/api/image-proxy?url=${encodeURIComponent(resolvedUrl.toString())}`; const response = await fetch(requestUrl); if (!response.ok) { throw new Error(`Failed to fetch asset: ${response.status}`); } return blobToDataUrl(await response.blob()); } async function inlineCssUrls(value: string) { const matches = Array.from(value.matchAll(/url\((['"]?)(.*?)\1\)/gi)); if (matches.length === 0) return value; let nextValue = value; for (const match of matches) { const rawUrl = match[2]?.trim(); if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) continue; try { const dataUrl = await urlToDataUrl(rawUrl); nextValue = nextValue.replace(match[0], `url("${dataUrl}")`); } catch { // Leave the original URL in place when it can't be inlined. } } return nextValue; } async function copyComputedStyles(source: Element, clone: HTMLElement | SVGElement) { const computed = window.getComputedStyle(source); for (let index = 0; index < computed.length; index++) { const property = computed[index]; if (!property) continue; let value = computed.getPropertyValue(property); if (!value) continue; if (property === 'background-image' || property === 'mask-image' || property === '-webkit-mask-image') { value = await inlineCssUrls(value); } clone.style.setProperty(property, value, computed.getPropertyPriority(property)); } clone.style.setProperty('animation', 'none'); clone.style.setProperty('transition', 'none'); clone.style.setProperty('caret-color', 'transparent'); } async function cloneNodeForCapture(node: Node): Promise { if (node.nodeType === Node.TEXT_NODE) { return node.cloneNode(false); } if (!(node instanceof Element)) { return node.cloneNode(false); } if (node instanceof HTMLCanvasElement) { const image = document.createElement('img'); try { image.src = node.toDataURL('image/png'); } catch { image.src = createTransparentDataUrl(node.width, node.height); } image.width = node.width; image.height = node.height; await copyComputedStyles(node, image); return image; } const clone = node.cloneNode(false) as HTMLElement | SVGElement; await copyComputedStyles(node, clone); if (node instanceof HTMLImageElement && clone instanceof HTMLImageElement) { const source = node.currentSrc || node.src; clone.loading = 'eager'; clone.decoding = 'sync'; clone.removeAttribute('srcset'); if (source) { if (source.startsWith('data:') || source.startsWith('blob:')) { clone.src = source; } else { try { clone.src = await urlToDataUrl(source); } catch { clone.src = createTransparentDataUrl(node.naturalWidth || node.width || 1, node.naturalHeight || node.height || 1); } } } } if (node instanceof HTMLInputElement && clone instanceof HTMLInputElement) { clone.value = node.value; if (node.checked) clone.setAttribute('checked', 'checked'); } if (node instanceof HTMLTextAreaElement && clone instanceof HTMLTextAreaElement) { clone.value = node.value; clone.textContent = node.value; } if (node instanceof HTMLSelectElement && clone instanceof HTMLSelectElement) { clone.value = node.value; } for (const child of Array.from(node.childNodes)) { clone.appendChild(await cloneNodeForCapture(child)); } return clone; } async function waitForClonedImages(root: HTMLElement) { const images = Array.from(root.querySelectorAll('img')); await Promise.all( images.map( (image) => new Promise((resolve) => { if (image.complete) { resolve(); return; } image.onload = () => resolve(); image.onerror = () => resolve(); }) ) ); } function loadImage(url: string) { return new Promise((resolve, reject) => { const image = new Image(); image.decoding = 'async'; image.onload = () => resolve(image); image.onerror = () => reject(new Error('Failed to load rendered SVG image')); image.src = url; }); } export async function captureElementAsPng(node: HTMLElement, options: CaptureOptions = {}) { const scale = options.scale ?? 2; const width = Math.round(node.offsetWidth); const height = Math.round(node.offsetHeight); const wrapper = document.createElement('div'); wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); wrapper.style.width = `${width}px`; wrapper.style.height = `${height}px`; wrapper.style.overflow = 'hidden'; wrapper.style.margin = '0'; wrapper.style.padding = '0'; wrapper.style.boxSizing = 'border-box'; const clonedNode = await cloneNodeForCapture(node); wrapper.appendChild(clonedNode); await waitForClonedImages(wrapper); const serialized = new XMLSerializer().serializeToString(wrapper); const svg = ` ${serialized} `; const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); try { const image = await loadImage(url); const canvas = document.createElement('canvas'); canvas.width = width * scale; canvas.height = height * scale; const context = canvas.getContext('2d'); if (!context) { throw new Error('Failed to create canvas context'); } context.scale(scale, scale); context.drawImage(image, 0, 0, width, height); return canvas.toDataURL('image/png'); } finally { URL.revokeObjectURL(url); } }