Spaces:
Running
Running
| /* | |
| * capture-element.ts | |
| * Purpose: Captures DOM slide content as an image while handling cross-origin-safe image loading. | |
| * Used by: Export and image-capture flows. | |
| * Depends on: html-to-image style capture logic and the image proxy route. | |
| */ | |
| interface CaptureOptions { | |
| scale?: number; | |
| } | |
| function createTransparentDataUrl(width = 1, height = 1) { | |
| return `data:image/svg+xml;charset=utf-8,${encodeURIComponent( | |
| `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.max(1, width)}" height="${Math.max(1, height)}"></svg>` | |
| )}`; | |
| } | |
| function blobToDataUrl(blob: Blob) { | |
| return new Promise<string>((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<Node> { | |
| 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<void>((resolve) => { | |
| if (image.complete) { | |
| resolve(); | |
| return; | |
| } | |
| image.onload = () => resolve(); | |
| image.onerror = () => resolve(); | |
| }) | |
| ) | |
| ); | |
| } | |
| function loadImage(url: string) { | |
| return new Promise<HTMLImageElement>((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 = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> | |
| <foreignObject x="0" y="0" width="100%" height="100%">${serialized}</foreignObject> | |
| </svg> | |
| `; | |
| 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); | |
| } | |
| } | |