import { toBlob } from 'html-to-image';
import { CANVAS_COLORS } from './constants.ts';
// Mirror the CAPTURE_SELECTOR values from each overlay component.
// Duplicated here so workflowCapture.ts stays a plain .ts file that
// Node can run without a JSX transform (needed for tests).
// To register a new overlay, add its selector string here AND export
// CAPTURE_SELECTOR from the overlay component file.
export const OVERLAY_CAPTURE_SELECTORS = [
'.lineplot-overlay', // LinePlotOverlay + ThresholdHistogram
'.cs-overlay', // CrossSectionOverlay
'.crop-overlay', // CropBoxOverlay
'.markup-overlay', // MarkupOverlay
'.angle-overlay', // AngleMeasureOverlay
];
function encodeBase64(bytes: Uint8Array) {
if (typeof (globalThis as any).Buffer !== 'undefined') {
return (globalThis as any).Buffer.from(bytes).toString('base64');
}
let binary = '';
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
async function blobToDataUrl(blob: Blob | null) {
if (!blob) return null;
const bytes = new Uint8Array(await blob.arrayBuffer());
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
}
function getElementSize(el: HTMLElement) {
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
return {
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)),
height: Math.max(1, Math.round(el.clientHeight || rect.height || 0)),
};
}
export async function waitForImageElement(img: HTMLImageElement) {
if (img.complete && img.naturalWidth > 0) return;
if (typeof img.decode === 'function') {
try {
await img.decode();
return;
} catch {
// Fall back to load/error listeners below.
}
}
await new Promise((resolve) => {
const done = () => {
img.removeEventListener('load', done);
img.removeEventListener('error', done);
resolve();
};
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
});
}
export async function getCaptureImageDataUrl(img: HTMLImageElement) {
const src = img.currentSrc || img.src;
if (!src) return null;
if (!src.startsWith('data:')) return src;
const { width, height } = getElementSize(img);
const scale = Math.min(2, globalThis.window?.devicePixelRatio || 1);
const canvas = globalThis.document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return src;
try {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
} catch {
return src;
}
}
export function createCapturePlaceholder(
el: HTMLElement,
dataUrl: string,
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) }: { stretch?: boolean; documentRef?: Document; getComputedStyleFn?: typeof getComputedStyle } = {},
) {
const rect = el.getBoundingClientRect();
const style = getComputedStyleFn(el);
const placeholder = documentRef.createElement('div');
placeholder.style.display = style.display === 'inline' ? 'inline-block' : style.display;
placeholder.style.width = `${el.clientWidth || rect.width}px`;
placeholder.style.height = `${el.clientHeight || rect.height}px`;
placeholder.style.maxWidth = style.maxWidth;
placeholder.style.maxHeight = style.maxHeight;
placeholder.style.minWidth = style.minWidth;
placeholder.style.minHeight = style.minHeight;
placeholder.style.borderRadius = style.borderRadius;
placeholder.style.backgroundImage = `url("${dataUrl}")`;
placeholder.style.backgroundRepeat = 'no-repeat';
placeholder.style.backgroundPosition = 'center';
placeholder.style.backgroundSize = stretch ? '100% 100%' : 'contain';
placeholder.style.flexShrink = style.flexShrink;
return placeholder;
}
async function renderCanvasToDataUrl(canvas: HTMLCanvasElement) {
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
async function renderElementToDataUrl(el: HTMLElement, toBlobImpl: typeof toBlob) {
const { width, height } = getElementSize(el);
const blob = await toBlobImpl(el, {
width,
height,
backgroundColor: CANVAS_COLORS.bgDeep,
style: {
width: `${width}px`,
height: `${height}px`,
transform: 'none',
},
});
return blobToDataUrl(blob);
}
async function replaceElementsWithPlaceholders(elements: HTMLElement[], renderDataUrl: (el: HTMLElement) => Promise, createPlaceholderFn: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement, stretch: boolean) {
const restorers = [];
for (const el of elements) {
if (!el?.parentNode) continue;
const dataUrl = await renderDataUrl(el);
if (!dataUrl) continue;
const placeholder = createPlaceholderFn(el, dataUrl, { stretch });
el.parentNode.replaceChild(placeholder, el);
restorers.push(() => {
if (placeholder.parentNode) {
placeholder.parentNode.replaceChild(el, placeholder);
}
});
}
return restorers;
}
function defaultQueryAll(root: HTMLElement, selector: string): HTMLElement[] {
return Array.from(root.querySelectorAll(selector)) as HTMLElement[];
}
function defaultNextFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
interface CaptureViewportDeps {
queryAll?: (root: HTMLElement, selector: string) => HTMLElement[];
toBlobImpl?: typeof toBlob;
waitForImageElement?: (img: HTMLImageElement) => Promise;
renderImageToDataUrl?: (img: HTMLImageElement) => Promise;
renderCanvasToDataUrl?: (canvas: HTMLCanvasElement) => Promise;
renderOverlayToDataUrl?: (el: HTMLElement) => Promise;
createPlaceholder?: (el: HTMLElement, dataUrl: string, opts: { stretch: boolean }) => HTMLElement;
nextFrame?: () => Promise;
overlaySelectors?: string[];
}
export async function captureViewportBlob(viewportEl: HTMLElement, options: Record, deps: CaptureViewportDeps = {}) {
const queryAll = deps.queryAll ?? defaultQueryAll;
const toBlobImpl = deps.toBlobImpl ?? toBlob;
const waitForImage = deps.waitForImageElement ?? waitForImageElement;
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl;
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el: HTMLElement) => renderElementToDataUrl(el, toBlobImpl));
const createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
const nextFrame = deps.nextFrame ?? defaultNextFrame;
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS;
const restorers = [];
const overlays = [];
const seen = new Set();
for (const selector of overlaySelectors) {
for (const el of queryAll(viewportEl, selector)) {
if (seen.has(el)) continue;
seen.add(el);
overlays.push(el);
}
}
const images = queryAll(viewportEl, 'img') as HTMLImageElement[];
await Promise.all(images.map(waitForImage));
try {
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage as (el: HTMLElement) => Promise, createPlaceholderFn, false));
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas as (el: HTMLElement) => Promise, createPlaceholderFn, true));
await nextFrame();
await nextFrame();
return await toBlobImpl(viewportEl, options);
} finally {
restorers.reverse().forEach((restore) => restore());
}
}