Powerpoint_AI / lib /capture-element.ts
Reubencf's picture
Readme updated
45b3fab
/*
* 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);
}
}