← Files DataARCHIVED FILE
templates/data-app/base/src/card-image.js
11.1 KB · Sep 30, 2026 · 23:19 UTC
import { toSvg } from "html-to-image";
const MAX_PIXELS = 16_000_000;
const MAX_DIMENSION = 16_384;
const CAPTURE_TIMEOUT_MS = 10_000;
const OMIT = '[role="menu"], [role="tooltip"], .menu-trigger, .info-wrap, .block-resize-boundary, .recharts-tooltip-wrapper, .chart-funnel-tooltip, [data-card-image-exclude], script, style, link';
const UNSUPPORTED = "iframe, object, embed, video, audio";
export function cardImageDimensions(width, height, scale = 2) {
if (!Number.isFinite(scale) || scale < 1 || scale > 3) {
throw new Error("Card image scale must be a number between 1 and 3.");
}
const dimensions = { width: Math.ceil(width * scale), height: Math.ceil(height * scale) };
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new Error("This card has no visible image dimensions.");
}
if (dimensions.width > MAX_DIMENSION || dimensions.height > MAX_DIMENSION
|| dimensions.width * dimensions.height > MAX_PIXELS) {
throw new Error("This card exceeds the image size limit. Reduce its size or export scale.");
}
return dimensions;
}
function visibleCard(element) {
if (!element?.isConnected || !element.ownerDocument?.defaultView || element.nodeType !== 1) {
throw new Error("This card is not mounted in the current app.");
}
const view = element.ownerDocument.defaultView;
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
const style = view.getComputedStyle(ancestor);
if (ancestor.hidden || style.display === "none" || style.visibility === "hidden"
|| style.visibility === "collapse" || style.contentVisibility === "hidden" || Number(style.opacity) === 0) {
throw new Error("This card is hidden in the current app view.");
}
}
return element.getBoundingClientRect();
}
function included(node) {
return node.nodeType !== 1 || !node.matches(OMIT);
}
function cardFragment(reference, card) {
let id;
try { id = decodeURIComponent(reference.slice(1)); } catch { /* Invalid fragment. */ }
const target = id && card.ownerDocument.getElementById(id);
if (!target || !card.contains(target) || target.closest(OMIT)) {
throw new Error("Card images require SVG paint and clip definitions inside the captured card.");
}
return target;
}
function embeddedUrls(value, allowFragment = false, card) {
for (const match of String(value).matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/giu)) {
if (/^data:/iu.test(match[2])) continue;
if (allowFragment && match[2].startsWith("#")) {
cardFragment(match[2], card);
continue;
}
throw new Error("Card images require embedded assets; an external image or font cannot be captured reliably.");
}
}
function validateStyle(style, card) {
for (const property of style) {
// The serializer embeds backgrounds and masks by fetching their URLs, even
// for fragment references. SVG paint/clip references stay local to the card.
embeddedUrls(style.getPropertyValue(property), !/background|mask/u.test(property), card);
}
}
function embeddedFonts(document, families) {
const fonts = [];
function visit(rules) {
for (const rule of rules) {
if (rule.type === 5 && families.has(rule.style.fontFamily.replace(/["']/gu, "").trim())) {
embeddedUrls(rule.style.getPropertyValue("src"));
fonts.push(rule.cssText);
} else if (rule.cssRules) visit(rule.cssRules);
else if (rule.type === 3) throw new Error("Card images cannot capture imported font stylesheets reliably.");
}
}
for (const sheet of document.styleSheets) {
try { visit(sheet.cssRules); }
catch (error) {
if (error.name === "SecurityError") throw new Error("Card images cannot capture inaccessible font stylesheets reliably.");
throw error;
}
}
return fonts.join("\n");
}
async function prepareAssets(element) {
const view = element.ownerDocument.defaultView;
const nodes = [element, ...element.querySelectorAll("*")].filter((node) => !node.closest(OMIT));
if (nodes.length > 5_000) throw new Error("This card contains too many elements to export as one image.");
const families = new Set();
const images = [];
for (const node of nodes) {
if (node.matches(UNSUPPORTED) || node.shadowRoot) {
throw new Error("This card contains an embedded view that cannot be captured reliably.");
}
if (node.matches("canvas") && (!node.width || !node.height)) {
throw new Error("A canvas in this card has not finished rendering.");
}
if (node.scrollLeft || node.scrollTop) {
throw new Error("Scroll this card's contents to the beginning before exporting its image.");
}
const style = view.getComputedStyle(node);
validateStyle(style, element);
for (const family of style.fontFamily.split(",")) families.add(family.replace(/["']/gu, "").trim());
for (const pseudo of ["::before", "::after"]) {
const pseudoStyle = view.getComputedStyle(node, pseudo);
if (pseudoStyle.content && !["none", "normal"].includes(pseudoStyle.content)) validateStyle(pseudoStyle, element);
}
if (node.matches("img")) {
if (!/^data:/iu.test(node.currentSrc || node.src) || node.srcset) {
throw new Error("Card images require embedded image assets without responsive external sources.");
}
images.push(node.decode().then(() => {
if (!node.naturalWidth || !node.naturalHeight) throw new Error("A card image has not loaded.");
}));
} else if (node.namespaceURI === "http://www.w3.org/2000/svg" && node.localName !== "a") {
const href = node.getAttribute("href") || node.getAttribute("xlink:href");
if (href && !/^data:/iu.test(href)) {
if (!href.startsWith("#") || node.localName === "image") {
throw new Error("Card images cannot capture external SVG references reliably.");
}
const target = cardFragment(href, element);
if (node.localName === "use" && !node.ownerSVGElement.contains(target)) {
throw new Error("Card images require SVG definitions inside the captured chart.");
}
}
}
}
await Promise.all(images);
return embeddedFonts(element.ownerDocument, families);
}
function inlineSvgStyles(source, clone) {
const style = source.ownerDocument.defaultView.getComputedStyle(source);
for (const property of style) clone.style.setProperty(property, style.getPropertyValue(property), style.getPropertyPriority(property));
for (let index = source.children.length - 1; index >= 0; index -= 1) {
if (included(source.children[index])) inlineSvgStyles(source.children[index], clone.children[index]);
else clone.children[index].remove();
}
}
async function capture(element, scale, signal) {
const document = element.ownerDocument;
const view = document.defaultView;
if (document.fonts) await document.fonts.ready;
await new Promise((resolve) => view.requestAnimationFrame(() => view.requestAnimationFrame(resolve)));
signal.throwIfAborted();
const bounds = visibleCard(element);
const dimensions = cardImageDimensions(bounds.width, bounds.height, scale);
let changed = false;
const observer = new view.MutationObserver(() => { changed = true; });
observer.observe(element, { subtree: true, childList: true, characterData: true, attributes: true });
const disconnect = () => observer.disconnect();
signal.addEventListener("abort", disconnect, { once: true });
try {
const fontEmbedCSS = await prepareAssets(element);
signal.throwIfAborted();
const dataUrl = await toSvg(element, {
width: bounds.width, height: bounds.height, fontEmbedCSS, filter: included,
style: { margin: "0", transform: "none", boxSizing: "border-box" },
});
signal.throwIfAborted();
const svg = new view.DOMParser().parseFromString(decodeURIComponent(dataUrl.split(",").slice(1).join(",")), "image/svg+xml");
const foreignObject = svg.querySelector("foreignObject");
if (!foreignObject) throw new Error("The card image could not be serialized.");
// html-to-image deep-clones SVGs without their descendant computed styles.
// Preserve CSS-driven chart marks, text, and the smoothed card background.
const sourceSvgs = [...element.querySelectorAll("svg")]
.filter((node) => !element.contains(node.parentElement.closest("svg")) && !node.closest(OMIT));
const clonedSvgs = [...foreignObject.querySelectorAll("svg")]
.filter((node) => !foreignObject.contains(node.parentElement.closest("svg")));
if (sourceSvgs.length !== clonedSvgs.length) throw new Error("The card changed while its image was captured. Retry the export.");
sourceSvgs.forEach((source, index) => {
const clone = source.cloneNode(true);
inlineSvgStyles(source, clone);
clonedSvgs[index].replaceWith(clone);
});
const image = new view.Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new view.XMLSerializer().serializeToString(svg))}`;
await image.decode();
signal.throwIfAborted();
const after = visibleCard(element);
if (changed || observer.takeRecords().length || after.width !== bounds.width || after.height !== bounds.height) {
throw new Error("The card changed while its image was captured. Retry the export.");
}
const canvas = document.createElement("canvas");
canvas.width = dimensions.width;
canvas.height = dimensions.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("This browser cannot render card images.");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const sample = document.createElement("canvas");
sample.width = sample.height = 64;
const sampleContext = sample.getContext("2d");
if (!sampleContext) throw new Error("This browser cannot validate card images.");
sampleContext.drawImage(canvas, 0, 0, 64, 64);
const pixels = sampleContext.getImageData(0, 0, 64, 64).data;
if (!pixels.some((channel, index) => channel !== pixels[index % 4])) {
throw new Error("The card rendered an empty image; this browser may not support card image export.");
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
signal.throwIfAborted();
if (!blob?.size || blob.type !== "image/png") throw new Error("The card image could not be encoded as PNG.");
return { blob, ...dimensions };
} finally {
signal.removeEventListener("abort", disconnect);
observer.disconnect();
}
}
// Captures only this mounted card, with no clipboard, download, or navigation.
// Dimensions describe output pixels; the current layout and filters are retained.
export async function renderDataAppCardImage(element, { scale = 2 } = {}) {
const bounds = visibleCard(element);
cardImageDimensions(bounds.width, bounds.height, scale);
const controller = new AbortController();
let timer;
try {
return await Promise.race([
capture(element, scale, controller.signal),
new Promise((_, reject) => {
timer = setTimeout(() => {
const error = new Error("Card image export timed out waiting for fonts, images, or layout. Retry when the card has finished loading.");
controller.abort(error);
reject(error);
}, CAPTURE_TIMEOUT_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}
SHA-256: b91c236f6e3b1c1bf5d7ca8085a4378770336b671dc3e36e403efa91995e851c