import html2canvas from 'html2canvas';
export interface RenderedScreenshotRequest {
source?: string;
post_id?: number;
post_status?: string;
name?: string;
url?: string;
viewport?: {
width?: number;
height?: number;
};
full_height?: boolean;
wait_ms?: number;
timeout_ms?: number;
detail?: 'auto' | 'low' | 'high';
limitations?: string[];
}
export interface RenderedScreenshotVisionPayload {
source: 'rendered_screenshot';
name: string;
mime: 'image/jpeg';
url?: string;
width: number;
height: number;
detail: 'auto' | 'low' | 'high';
data_url: string;
}
const DEFAULT_WIDTH = 1365;
const DEFAULT_HEIGHT = 900;
const DEFAULT_WAIT_MS = 800;
const DEFAULT_TIMEOUT_MS = 12000;
const MAX_WAIT_MS = 5000;
const MAX_TIMEOUT_MS = 20000;
const JPEG_QUALITY = 0.86;
const sleep = (ms: number) => new Promise(resolve => window.setTimeout(resolve, ms));
const clampPositiveInteger = (value: unknown, fallback: number, max: number) => {
const number = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(number) || number <= 0) return fallback;
return Math.min(Math.round(number), max);
};
const assertSameOriginUrl = (value: string) => {
const url = new URL(value, window.location.href);
if (url.origin !== window.location.origin) {
throw new Error('Rendered screenshots are limited to same-origin WordPress URLs.');
}
return url.toString();
};
const waitForIframeLoad = (iframe: HTMLIFrameElement, timeoutMs: number) => new Promise((resolve, reject) => {
let done = false;
const timer = window.setTimeout(() => {
if (done) return;
done = true;
reject(new Error('Timed out while loading rendered screenshot target.'));
}, timeoutMs);
iframe.onload = () => {
if (done) return;
done = true;
window.clearTimeout(timer);
resolve();
};
});
const getDocumentHeight = (document: Document) => {
const body = document.body;
const root = document.documentElement;
return Math.max(
body?.scrollHeight || 0,
body?.offsetHeight || 0,
body?.clientHeight || 0,
root.scrollHeight,
root.offsetHeight,
root.clientHeight,
);
};
const warmLazyLoadedContent = async (frameWindow: Window, height: number, viewportHeight: number) => {
const step = Math.max(1, Math.floor(viewportHeight * 0.9));
const positions = new Set([0, Math.max(0, height - viewportHeight)]);
for (let position = step; position < height; position += step) {
positions.add(position);
}
for (const position of positions) {
frameWindow.scrollTo(0, position);
await sleep(120);
}
frameWindow.scrollTo(0, 0);
};
export async function captureRenderedScreenshot(request: RenderedScreenshotRequest): Promise {
if (!request.url) {
throw new Error('Rendered screenshot request did not include a URL.');
}
const url = assertSameOriginUrl(request.url);
const width = clampPositiveInteger(request.viewport?.width, DEFAULT_WIDTH, 2560);
const viewportHeight = clampPositiveInteger(request.viewport?.height, DEFAULT_HEIGHT, 1800);
const waitMs = clampPositiveInteger(request.wait_ms, DEFAULT_WAIT_MS, MAX_WAIT_MS);
const timeoutMs = clampPositiveInteger(request.timeout_ms, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
const iframe = document.createElement('iframe');
iframe.setAttribute('aria-hidden', 'true');
iframe.tabIndex = -1;
iframe.style.position = 'fixed';
iframe.style.left = '-10000px';
iframe.style.top = '0';
iframe.style.width = `${width}px`;
iframe.style.height = `${viewportHeight}px`;
iframe.style.border = '0';
iframe.style.pointerEvents = 'none';
iframe.style.visibility = 'hidden';
document.body.appendChild(iframe);
try {
const loaded = waitForIframeLoad(iframe, timeoutMs);
iframe.src = url;
await loaded;
const frameWindow = iframe.contentWindow;
const frameDocument = iframe.contentDocument;
if (!frameWindow || !frameDocument?.documentElement) {
throw new Error('Rendered screenshot target was not readable.');
}
await frameDocument.fonts?.ready.catch(() => undefined);
await sleep(waitMs);
frameWindow.scrollTo(0, 0);
let height = request.full_height === false ? viewportHeight : getDocumentHeight(frameDocument);
if (request.full_height !== false) {
await warmLazyLoadedContent(frameWindow, height, viewportHeight);
await sleep(Math.min(300, waitMs));
height = Math.max(height, getDocumentHeight(frameDocument));
}
frameWindow.scrollTo(0, 0);
const renderOptions = {
allowTaint: false,
backgroundColor: '#ffffff',
height,
logging: false,
scrollX: 0,
scrollY: 0,
useCORS: true,
width,
windowHeight: viewportHeight,
windowWidth: width,
};
let canvas: HTMLCanvasElement;
try {
canvas = await html2canvas(frameDocument.documentElement, renderOptions);
} catch {
canvas = await html2canvas(frameDocument.documentElement, {
...renderOptions,
onclone: sanitizeUnsupportedCssForCapture,
});
}
return {
source: 'rendered_screenshot',
name: request.name || `Rendered screenshot${request.post_id ? ` for post ${request.post_id}` : ''}`,
mime: 'image/jpeg',
url,
width: canvas.width,
height: canvas.height,
detail: request.detail || 'auto',
data_url: canvas.toDataURL('image/jpeg', JPEG_QUALITY),
};
} finally {
iframe.remove();
}
}
function sanitizeUnsupportedCssForCapture(clonedDocument: Document): void {
clonedDocument
.querySelectorAll('style, link[rel="stylesheet"]')
.forEach(element => element.remove());
const safeStyle = clonedDocument.createElement('style');
safeStyle.textContent = `
* {
background-image: none !important;
box-shadow: none !important;
color: #111827 !important;
text-shadow: none !important;
}
html, body {
background: #ffffff !important;
color: #111827 !important;
font-family: Arial, sans-serif !important;
}
img, video, canvas, svg {
max-width: 100% !important;
}
`;
clonedDocument.head.appendChild(safeStyle);
}