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); }