← Files DataARCHIVED FILE

templates/data-app/base/src/chart-image.js

27.4 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

function copyComputedStyles(source, destination) {
  const computed = getComputedStyle(source);
  for (const property of computed) {
    destination.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
  }
  const sourceChildren = source.children;
  const destinationChildren = destination.children;
  for (let index = 0; index < sourceChildren.length; index += 1) {
    if (destinationChildren[index]) copyComputedStyles(sourceChildren[index], destinationChildren[index]);
  }
}

// Export density is independent of the screen that creates the image. Keep
// large custom exports inside the conservative 4096px canvas edge limit.
export function chartImageResolution(width, height, requestedScale = 3) {
  if (![width, height, requestedScale].every(value => Number.isFinite(value) && value > 0)) {
    throw new Error("Chart image dimensions and scale must be positive finite numbers.");
  }
  const scale = Math.min(requestedScale, 3, 4096 / width, 4096 / height);
  return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), scale };
}

async function imageFromSvg(svg, scale = 1) {
  const width = Number(svg.getAttribute("width"));
  const height = Number(svg.getAttribute("height"));
  if (!svg.hasAttribute("viewBox")) svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
  // Rasterize the vectors and glyphs at the final density. Enlarging a 1x
  // decoded SVG bitmap on a larger canvas would still produce blurry charts.
  svg.setAttribute("width", String(width * scale));
  svg.setAttribute("height", String(height * scale));
  svg.style.width = `${width * scale}px`;
  svg.style.height = `${height * scale}px`;
  const markup = new XMLSerializer().serializeToString(svg);
  const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }));
  try {
    const image = new Image();
    image.decoding = "async";
    await new Promise((resolve, reject) => {
      image.onload = resolve;
      image.onerror = () => reject(new Error("The chart image could not be rendered."));
      image.src = url;
    });
    return image;
  } finally {
    URL.revokeObjectURL(url);
  }
}

function canvasFont(element, fallbackSize = 14) {
  const style = getComputedStyle(element);
  return `${style.fontWeight} ${style.fontSize || `${fallbackSize}px`} ${style.fontFamily}`;
}

export function wrapChartImageText(context, text, maxWidth) {
  const lines = [];
  let line = "";
  const words = text.trim().split(/\s+/u).flatMap((word) => {
    if (context.measureText(word).width <= maxWidth) return [word];
    const pieces = [];
    let piece = "";
    for (const character of word) {
      if (piece && context.measureText(piece + character).width > maxWidth) {
        pieces.push(piece); piece = "";
      }
      piece += character;
    }
    if (piece) pieces.push(piece);
    return pieces;
  });
  for (const word of words) {
    const next = line ? `${line} ${word}` : word;
    if (line && context.measureText(next).width > maxWidth) {
      lines.push(line);
      line = word;
    } else line = next;
  }
  if (line) lines.push(line);
  return lines;
}

const wrapText = wrapChartImageText;

function legendRows(context, component, maxWidth) {
  const markerWidth = 16;
  const markerGap = 8;
  const itemGap = 22;
  const entries = [...component.querySelectorAll(".chart-legend-button")].map((button) => {
    const mark = button.querySelector(".chart-legend-mark");
    const text = button.querySelector("span:last-child")?.textContent.trim();
    if (!mark || !text) return null;
    return {
      text,
      color: getComputedStyle(mark).backgroundColor,
      visible: button.getAttribute("aria-pressed") !== "false",
      line: mark.classList.contains("line"),
      width: markerWidth + markerGap + context.measureText(text).width,
    };
  }).filter(Boolean);
  const rows = [];
  let row = [];
  let width = 0;
  for (const entry of entries) {
    const next = width + (row.length ? itemGap : 0) + entry.width;
    if (row.length && next > maxWidth) {
      rows.push({ entries: row, width });
      row = [];
      width = 0;
    }
    width += (row.length ? itemGap : 0) + entry.width;
    row.push(entry);
  }
  if (row.length) rows.push({ entries: row, width });
  return rows;
}

const svgNamespace = "http://www.w3.org/2000/svg";

function svgElement(name, attributes = {}) {
  const element = document.createElementNS(svgNamespace, name);
  for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
  return element;
}

// Use the browser's line positions instead of rewrapping labels differently in
// the exported image. SVG text avoids foreignObject's canvas security limits.
function appendFunnelText(svg, element, origin) {
  if (!element) return;
  const style = getComputedStyle(element);
  const lines = [];
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
  const range = document.createRange();
  while (walker.nextNode()) {
    const node = walker.currentNode;
    let offset = 0;
    for (const character of node.textContent) {
      range.setStart(node, offset);
      offset += character.length;
      range.setEnd(node, offset);
      const bounds = range.getBoundingClientRect();
      if (!bounds.height || !bounds.width && /\s/u.test(character)) continue;
      let line = lines.find((entry) => Math.abs(entry.top - bounds.top) < .5);
      if (!line) {
        line = { text: "", top: bounds.top, left: bounds.left, right: bounds.right };
        lines.push(line);
      }
      line.text += character;
      line.left = Math.min(line.left, bounds.left);
      line.right = Math.max(line.right, bounds.right);
    }
  }
  for (const line of lines) {
    const text = svgElement("text", {
      x: (style.direction === "rtl" ? line.right : line.left) - origin.left,
      y: line.top - origin.top,
      fill: style.color,
      "dominant-baseline": "text-before-edge",
      "font-family": style.fontFamily,
      "font-size": style.fontSize,
      "font-weight": style.fontWeight,
      "font-style": style.fontStyle,
      "letter-spacing": style.letterSpacing,
      direction: style.direction,
    });
    text.style.whiteSpace = "pre";
    text.style.fontVariantNumeric = style.fontVariantNumeric;
    text.style.fontFeatureSettings = style.fontFeatureSettings;
    text.textContent = line.text;
    svg.append(text);
  }
}

function funnelImage(component) {
  const funnel = component.querySelector(".chart-funnel");
  const canvas = funnel?.querySelector(".chart-funnel-canvas");
  const ribbon = funnel?.querySelector(".chart-funnel-ribbon");
  if (!canvas) return null;
  const bounds = canvas.getBoundingClientRect();
  const ribbonBounds = ribbon?.getBoundingClientRect();
  const width = Math.ceil(bounds.width);
  const height = Math.ceil(bounds.height);
  const svg = svgElement("svg", { xmlns: svgNamespace, width, height, viewBox: `0 0 ${width} ${height}` });
  // Compact and wide funnels paint the ribbon; the vertical layout instead
  // paints per-stage bars. Never export a CSS-hidden ribbon's zero-size path.
  if (ribbonBounds?.width > 0 && ribbonBounds.height > 0 && getComputedStyle(ribbon).visibility !== "hidden") {
    const clone = ribbon.cloneNode(true);
    copyComputedStyles(ribbon, clone);
    const viewBox = ribbon.viewBox.baseVal;
    const band = svgElement("g", {
      transform: `translate(${ribbonBounds.left - bounds.left} ${ribbonBounds.top - bounds.top})`
        + ` scale(${ribbonBounds.width / viewBox.width} ${ribbonBounds.height / viewBox.height})`
        + ` translate(${-viewBox.x} ${-viewBox.y})`,
    });
    band.append(...clone.childNodes);
    svg.append(band);
  }
  for (const stage of canvas.querySelectorAll(".chart-funnel-stages > li")) {
    const stageBounds = stage.getBoundingClientRect();
    const stageStyle = getComputedStyle(stage);
    const borderWidths = Object.fromEntries(["Left", "Right", "Top", "Bottom"].map((side) =>
      [side, Number.parseFloat(stageStyle[`border${side}Width`]) || 0]));
    for (const [side, thickness] of Object.entries(borderWidths)) {
      if (!thickness) continue;
      const vertical = side === "Left" || side === "Right";
      // Vertical borders already paint the corners. Inset horizontal borders
      // so translucent borders are not painted twice at their intersections.
      const borderWidth = vertical ? thickness : Math.max(0, stageBounds.width - borderWidths.Left - borderWidths.Right);
      if (!borderWidth || !stageBounds.height) continue;
      svg.append(svgElement("rect", {
        x: (vertical ? side === "Left" ? stageBounds.left : stageBounds.right - thickness
          : stageBounds.left + borderWidths.Left) - bounds.left,
        y: (side === "Bottom" ? stageBounds.bottom - thickness : stageBounds.top) - bounds.top,
        width: borderWidth, height: vertical ? stageBounds.height : thickness,
        fill: stageStyle[`border${side}Color`],
      }));
    }
    const pill = stage.querySelector(".chart-funnel-stage-share > span");
    if (pill) {
      const pillBounds = pill.getBoundingClientRect();
      const pillStyle = getComputedStyle(pill);
      svg.append(svgElement("rect", {
        x: pillBounds.left - bounds.left, y: pillBounds.top - bounds.top,
        width: pillBounds.width, height: pillBounds.height,
        rx: Math.min(Number.parseFloat(pillStyle.borderTopLeftRadius) || 0, pillBounds.width / 2, pillBounds.height / 2),
        fill: pillStyle.backgroundColor,
      }));
    }
    const bar = stage.querySelector(".chart-funnel-stage-bar-fill");
    if (bar) {
      const barBounds = bar.getBoundingClientRect();
      const barStyle = getComputedStyle(bar);
      // Missing/zero stages have zero-width fills. Do not invent a minimum
      // painted width, or include bars hidden by the horizontal layouts.
      if (barBounds.width > 0 && barBounds.height > 0 && barStyle.visibility !== "hidden") {
        svg.append(svgElement("rect", {
          x: barBounds.left - bounds.left, y: barBounds.top - bounds.top,
          width: barBounds.width, height: barBounds.height,
          rx: Math.min(Number.parseFloat(barStyle.borderTopLeftRadius) || 0, barBounds.width / 2, barBounds.height / 2),
          fill: barStyle.backgroundColor,
        }));
      }
    }
    for (const element of stage.querySelectorAll(".chart-funnel-stage-name, .chart-funnel-stage-value, .chart-funnel-stage-share > span")) {
      appendFunnelText(svg, element, bounds);
    }
  }
  return { svg, width, height };
}

export function canDownloadChartImage(componentId) {
  if (typeof document === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
    return false;
  }
  const component = document.querySelector(`[data-component-id="${CSS.escape(componentId)}"]`);
  return Boolean(
    component?.querySelector(".recharts-wrapper svg")
      || component?.querySelector(".chart-funnel .chart-funnel-canvas"),
  );
}

export async function renderChartImage(component, { description, provenance = "", width: requestedWidth,
  height: requestedHeight, scale: requestedScale, wrapTitle = false } = {}) {
  const funnel = component && funnelImage(component);
  const source = component?.querySelector(".recharts-wrapper svg");
  if (!source && !funnel) throw new Error("This component does not contain a chart image.");

  const bounds = funnel ?? source.getBoundingClientRect();
  const componentBounds = component.getBoundingClientRect();
  const title = component.querySelector(".component-title-text");
  const axis = component.querySelector(".chart-axis-label");
  const legend = component.querySelector(".chart-legend");
  const footer = component.querySelector(".chart-footer");
  const svg = funnel?.svg ?? source.cloneNode(true);
  svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  svg.setAttribute("width", String(Math.ceil(bounds.width)));
  svg.setAttribute("height", String(Math.ceil(bounds.height)));
  if (!funnel) copyComputedStyles(source, svg);

  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  if (!context) throw new Error("This browser cannot render chart images.");
  const padding = 20;
  const width = requestedWidth ?? Math.ceil(Math.max(componentBounds.width, bounds.width) + padding * 2);
  const availableWidth = width - padding * 2;
  const titleFont = title ? canvasFont(title) : canvasFont(component);
  const secondaryFont = axis ? canvasFont(axis, 12) : `12px ${getComputedStyle(component).fontFamily}`;
  context.font = secondaryFont;
  const descriptionLines = description ? wrapText(context, description, availableWidth) : [];
  const legendFont = legend ? canvasFont(legend.querySelector(".chart-legend-button") ?? legend, 12)
    : secondaryFont;
  const footerBounds = footer?.getBoundingClientRect();
  const footerWidth = footerBounds?.width || availableWidth;
  const footerCenter = footerBounds
    ? padding + footerBounds.left - componentBounds.left + footerBounds.width / 2
    : width / 2;
  context.font = legendFont;
  const rows = legend ? legendRows(context, component, Math.min(availableWidth, footerWidth)) : [];
  context.font = titleFont;
  const titleLines = title ? (wrapTitle ? wrapText(context, title.textContent.trim(), availableWidth) : [title.textContent.trim()]) : [];
  const titleHeight = titleLines.length * 28;
  const descriptionHeight = descriptionLines.length ? descriptionLines.length * 18 + 8 : 0;
  const axisHeight = axis ? 24 : 0;
  const legendHeight = rows.length ? rows.length * 24 + 10 : 0;
  context.font = secondaryFont;
  const annotationNotes = [...component.querySelectorAll(".chart-annotation-note")];
  const annotationColor = annotationNotes.length ? getComputedStyle(annotationNotes[0]).color : null;
  const annotationLines = annotationNotes.flatMap((note) =>
    wrapText(context, note.textContent, availableWidth));
  const annotationHeight = annotationLines.length ? annotationLines.length * 18 + 10 : 0;
  const provenanceLines = provenance ? wrapText(context, provenance, availableWidth) : [];
  const provenanceHeight = provenanceLines.length ? provenanceLines.length * 18 + 14 : 0;
  const colorScale = component.querySelector(".chart-scale-legend");
  const bandLegend = component.querySelector(".chart-scale-band-legend");
  const colorLegendHeight = colorScale ? 30 : bandLegend ? Math.ceil(bandLegend.getBoundingClientRect().height) + 10 : 0;
  const naturalHeight = Math.ceil(padding * 2 + titleHeight + descriptionHeight + bounds.height + axisHeight + legendHeight + annotationHeight + provenanceHeight + colorLegendHeight);
  const height = requestedHeight ?? naturalHeight;
  const intrinsicHeight = component.querySelector('.chart-funnel[data-funnel-layout="vertical"]');
  if (requestedHeight && intrinsicHeight && requestedHeight < naturalHeight) return { minimumHeight: naturalHeight };
  if (requestedHeight && !intrinsicHeight && Math.abs(naturalHeight - requestedHeight) > 1) {
    const plotHeight = bounds.height + requestedHeight - naturalHeight;
    if (plotHeight < 100) return { minimumHeight: naturalHeight + 100 - bounds.height };
    // Let the same renderer reflow the plot at its actual export dimensions.
    // Never stretch or crop the chart to make a requested aspect ratio fit.
    return { plotHeight };
  }
  const resolution = chartImageResolution(width, height, requestedScale);
  canvas.width = resolution.width;
  canvas.height = resolution.height;
  context.scale(resolution.width / width, resolution.height / height);
  const cardSurface = component.querySelector(":scope > .smooth-card-surface[data-ready] > path");
  context.fillStyle = cardSurface ? getComputedStyle(cardSurface).fill : getComputedStyle(component).backgroundColor;
  if (context.fillStyle === "rgba(0, 0, 0, 0)") context.fillStyle = getComputedStyle(document.body).backgroundColor;
  context.fillRect(0, 0, width, height);
  context.textBaseline = "top";
  let top = padding;
  if (title) {
    context.font = titleFont;
    context.fillStyle = getComputedStyle(title).color;
    for (const line of titleLines) { context.fillText(line, padding, top); top += 28; }
  }
  if (descriptionLines.length) {
    context.font = secondaryFont;
    context.fillStyle = getComputedStyle(component).getPropertyValue("--secondary").trim()
      || getComputedStyle(component).color;
    for (const line of descriptionLines) {
      context.fillText(line, padding, top);
      top += 18;
    }
    top += 8;
  }
  context.drawImage(await imageFromSvg(svg, resolution.scale), padding, top, bounds.width, bounds.height);
  top += bounds.height;
  if (axis) {
    context.font = secondaryFont;
    context.fillStyle = getComputedStyle(axis).color;
    const label = axis.textContent.trim();
    context.fillText(label, footerCenter - context.measureText(label).width / 2, top + 5);
    top += axisHeight;
  }
  if (rows.length) {
    context.font = legendFont;
    context.textBaseline = "middle";
    top += 6;
    for (const row of rows) {
      let left = footerCenter - row.width / 2;
      const center = top + 11;
      for (const entry of row.entries) {
        context.globalAlpha = entry.visible ? 1 : 0.42;
        context.fillStyle = entry.color;
        if (entry.line) context.fillRect(left, center - 1, 16, 2);
        else {
          context.beginPath();
          context.roundRect(left + 4, center - 4, 8, 8, 2);
          context.fill();
        }
        context.fillStyle = getComputedStyle(legend).color;
        context.fillText(entry.text, left + 24, center);
        context.globalAlpha = 1;
        left += entry.width + 22;
      }
      top += 24;
    }
    context.textBaseline = "top";
  }
  if (colorScale) {
    const values = colorScale.querySelectorAll("span");
    context.font = secondaryFont;
    context.fillStyle = getComputedStyle(colorScale).color;
    context.fillText(values[0]?.textContent ?? "", padding, top + 6);
    const maximum = values[1]?.textContent ?? "";
    context.fillText(maximum, width - padding - context.measureText(maximum).width, top + 6);
    const swatch = document.createElement("span");
    swatch.style.color = getComputedStyle(colorScale.querySelector("i")).getPropertyValue("--chart-scale-color");
    component.append(swatch);
    const gradient = context.createLinearGradient(width / 2 - 50, 0, width / 2 + 50, 0);
    swatch.style.color = "color-mix(in srgb, var(--chart-scale-color, var(--chart-1)) 12%, var(--surface))";
    // The scale variable belongs to the scale element, not the capture root.
    swatch.style.setProperty("--chart-scale-color", getComputedStyle(colorScale.querySelector("i")).getPropertyValue("--chart-scale-color"));
    gradient.addColorStop(0, getComputedStyle(swatch).color);
    swatch.style.color = "var(--chart-scale-color, var(--chart-1))";
    gradient.addColorStop(1, getComputedStyle(swatch).color);
    swatch.remove();
    context.fillStyle = gradient;
    context.fillRect(width / 2 - 50, top + 8, 100, 8);
    top += colorLegendHeight;
  } else if (bandLegend) {
    const box = bandLegend.getBoundingClientRect();
    const legendSvg = svgElement("svg", { xmlns: svgNamespace, width: availableWidth, height: colorLegendHeight });
    for (const entry of bandLegend.children) {
      for (const swatch of entry.querySelectorAll("i")) {
        const rect = swatch.getBoundingClientRect();
        legendSvg.append(svgElement("rect", { x: rect.left - box.left, y: rect.top - box.top,
          width: rect.width, height: rect.height, fill: getComputedStyle(swatch).backgroundColor }));
      }
      appendFunnelText(legendSvg, entry, box);
    }
    context.drawImage(await imageFromSvg(legendSvg, resolution.scale), padding, top, availableWidth, colorLegendHeight);
    top += colorLegendHeight;
  }
  if (annotationLines.length) {
    top += 10;
    context.font = secondaryFont;
    context.textBaseline = "top";
    context.fillStyle = annotationColor;
    for (const line of annotationLines) { context.fillText(line, padding, top); top += 18; }
  }
  if (provenanceLines.length) {
    top += 14;
    context.font = secondaryFont;
    context.textBaseline = "top";
    context.fillStyle = getComputedStyle(component).color;
    for (const line of provenanceLines) { context.fillText(line, padding, top); top += 18; }
  }
  const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
  if (!blob) throw new Error("The chart image could not be encoded.");
  return { blob, width: canvas.width, height: canvas.height };
}

export async function copyChartImage(componentId, { description } = {}) {
  if (!navigator.clipboard?.write || typeof ClipboardItem !== "function") {
    throw new Error("Copying chart images is unavailable in this browser.");
  }
  const component = document.querySelector(`[data-component-id="${CSS.escape(componentId)}"]`);
  const { blob } = await renderChartImage(component, { description });
  await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
}

function exportElementOpacity(element, root) {
  let opacity = 1;
  for (; element && element !== root; element = element.parentElement) opacity *= Number(getComputedStyle(element).opacity);
  return opacity;
}

function visibleExportText(node, root) {
  if (!node.textContent.trim() || node.parentElement.closest("svg, .chart-funnel, .chart-annotation-evidence, .visually-hidden")) return false;
  for (let element = node.parentElement; element && element !== root; element = element.parentElement) {
    const style = getComputedStyle(element);
    if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
    // Accessibility copies can occupy a normal text range inside a clipped 1px
    // box. Painting that range without its clip would expose invisible labels.
    if (style.clipPath && style.clipPath !== "none" || style.clip && style.clip !== "auto") return false;
  }
  return true;
}

// Paint HTML text at the browser's measured line positions, preserving each
// span's font and color. SVG already owns all text inside the chart itself.
function appendPreviewText(svg, root, origin) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  const range = document.createRange();
  while (walker.nextNode()) {
    const node = walker.currentNode;
    if (!visibleExportText(node, root)) continue;
    const style = getComputedStyle(node.parentElement);
    const lines = [];
    let offset = 0;
    for (const character of node.textContent) {
      range.setStart(node, offset);
      offset += character.length;
      range.setEnd(node, offset);
      const box = range.getBoundingClientRect();
      if (!box.height) continue;
      let line = lines.find(line => Math.abs(line.top - box.top) < .5);
      if (!line) { line = { top: box.top, left: box.left, right: box.right, text: "" }; lines.push(line); }
      line.text += character;
      line.left = Math.min(line.left, box.left);
      line.right = Math.max(line.right, box.right);
    }
    for (const line of lines) {
      const text = svgElement("text", {
        x: (style.direction === "rtl" ? line.right : line.left) - origin.left,
        y: line.top - origin.top,
        fill: style.color, opacity: exportElementOpacity(node.parentElement, root), "dominant-baseline": "text-before-edge",
        "font-family": style.fontFamily, "font-size": style.fontSize,
        "font-weight": style.fontWeight, "font-style": style.fontStyle,
        "letter-spacing": style.letterSpacing, direction: style.direction,
      });
      text.style.whiteSpace = "pre";
      text.style.fontVariantNumeric = style.fontVariantNumeric;
      text.style.fontFeatureSettings = style.fontFeatureSettings;
      text.textContent = line.text;
      svg.append(text);
    }
  }
}

// Preserve the artwork's logical layout while exporting high-density pixels. A temporary clone
// preserves the live chart without rerendering. Preview framing stays outside it.
// No foreignObject or external screenshot service is needed.
export async function renderLiveChartImage(artwork, { scale, transparent = false } = {}) {
  if (!artwork) throw new Error("The chart preview is not ready.");
  await document.fonts?.ready;
  const clone = artwork.cloneNode(true);
  copyComputedStyles(artwork, clone);
  clone.querySelectorAll(".chart-tooltip, .recharts-tooltip-wrapper, .menu-trigger").forEach(element => element.remove());
  Object.assign(clone.style, { position: "fixed", left: "-10000px", top: "0", transform: "none", margin: "0", opacity: "1" });
  clone.setAttribute("aria-hidden", "true");
  clone.inert = true;
  document.body.append(clone);
  try {
    const origin = clone.getBoundingClientRect();
    const width = Math.round(origin.width);
    const height = Math.round(origin.height);
    const canvas = document.createElement("canvas");
    const resolution = chartImageResolution(width, height, scale);
    canvas.width = resolution.width;
    canvas.height = resolution.height;
    const context = canvas.getContext("2d");
    if (!context) throw new Error("This browser cannot render chart images.");
    context.scale(resolution.width / width, resolution.height / height);
    const style = getComputedStyle(clone);
    if (!transparent) {
      context.fillStyle = style.backgroundColor;
      context.fillRect(0, 0, width, height);
    }
    const funnel = funnelImage(clone);
    const source = clone.querySelector(".recharts-wrapper svg");
    if (!source && !funnel) throw new Error("This preview has no chart to export.");
    const plot = funnel ? clone.querySelector(".chart-funnel-canvas") : source;
    const plotBounds = plot.getBoundingClientRect();
    const svg = funnel?.svg ?? source.cloneNode(true);
    svg.setAttribute("xmlns", svgNamespace);
    svg.setAttribute("width", String(plotBounds.width));
    svg.setAttribute("height", String(plotBounds.height));
    context.drawImage(await imageFromSvg(svg, resolution.scale), plotBounds.left - origin.left, plotBounds.top - origin.top,
      plotBounds.width, plotBounds.height);

    const textLayer = svgElement("svg", { xmlns: svgNamespace, width, height, viewBox: `0 0 ${width} ${height}` });
    for (const mark of clone.querySelectorAll(".chart-legend-mark, .chart-scale-band-swatches i")) {
      const box = mark.getBoundingClientRect();
      const style = getComputedStyle(mark);
      textLayer.append(svgElement("rect", { x: box.left - origin.left, y: box.top - origin.top,
        width: box.width, height: box.height, rx: parseFloat(style.borderRadius) || 0,
        fill: style.backgroundColor, opacity: exportElementOpacity(mark, clone) }));
    }
    const colorScale = clone.querySelector(".chart-scale-legend i");
    if (colorScale) {
      const box = colorScale.getBoundingClientRect();
      const probe = document.createElement("span");
      colorScale.append(probe);
      probe.style.color = "color-mix(in srgb, var(--chart-scale-color, var(--chart-1)) 12%, var(--surface))";
      const low = getComputedStyle(probe).color;
      probe.style.color = "var(--chart-scale-color, var(--chart-1))";
      const high = getComputedStyle(probe).color;
      probe.remove();
      const left = box.left - origin.left;
      const gradient = context.createLinearGradient(left, 0, left + box.width, 0);
      gradient.addColorStop(0, low); gradient.addColorStop(1, high);
      context.fillStyle = gradient;
      context.beginPath();
      context.roundRect(left, box.top - origin.top, box.width, box.height, box.height / 2);
      context.fill();
    }
    appendPreviewText(textLayer, clone, origin);
    context.drawImage(await imageFromSvg(textLayer, resolution.scale), 0, 0, width, height);
    const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
    if (!blob) throw new Error("The chart image could not be encoded.");
    return { blob, width: canvas.width, height: canvas.height };
  } finally { clone.remove(); }
}

SHA-256: c48da8343b765813bc1294bcd81967fcd0b2bfe94f18174a1e3d4db6c44d1936