Compare commits

...

9 Commits

Author SHA1 Message Date
Elizabeth Thompson
04388ebc42 feat: replace DPR spoof with ECharts getDataURL for high-res canvas
Uses the public ECharts instance.getDataURL({ pixelRatio }) API to
render each chart off-screen at IMAGE_DOWNLOAD_SCALE before cloning.
The high-res PNG is placed as an <img> in the clone so dom-to-image
picks up sharp pixels — no global mutation, no resize() side effects.

For ag-grid charts any embedded ECharts instances are swapped out of
the live DOM temporarily using the same captured data URLs, then
restored in a finally block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:23:56 +00:00
Elizabeth Thompson
bc202b54aa fix: spoof window.devicePixelRatio so ZRender re-renders at target DPR
ZRender reads window.devicePixelRatio internally during resize() and
ignores it if passed as an option, so the previous attempt to pass
devicePixelRatio via the resize call had no effect. Temporarily spoof
window.devicePixelRatio to IMAGE_DOWNLOAD_SCALE before calling resize()
so ZRender allocates its canvas at the correct resolution and repaints
all content (including labels) at full quality. The global is restored
in a finally block immediately after the resize call completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:16:03 +00:00
Elizabeth Thompson
f095159669 fix: re-render ECharts at download scale for sharp labels
The previous approach upscaled already-rasterized canvas content, which
produces blurry text because interpolation cannot recover sharp vector
paths. Fix: call getInstanceByDom(el).resize({ devicePixelRatio:
IMAGE_DOWNLOAD_SCALE }) before cloning so ECharts re-renders its canvas
at the target resolution. preserveCanvasContent now does a plain 1:1
copy of the already-high-res backing store. Both the ag-grid and
clone-based paths resize then restore ECharts DPR in the finally block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:42:46 +00:00
Elizabeth Thompson
215000c137 qa: add pie/donut chart screenshot evidence for PR #39285 2026-06-06 00:24:26 +00:00
Elizabeth Thompson
4a814f9a55 qa: add screenshot evidence for PR #39285 high-DPI scale verification 2026-06-04 22:45:29 +00:00
Elizabeth Thompson
50a33ae199 feat: improve chart image text quality on download
- Scale canvas content in preserveCanvasContent by
  IMAGE_DOWNLOAD_SCALE / devicePixelRatio so dom-to-image gets a 1:1
  pixel mapping instead of stretching already-rasterized pixels; use
  imageSmoothingQuality 'high' for the upscale drawImage call
- Add WebkitFontSmoothing: antialiased via the style option on both
  toJpeg calls to improve DOM text (ag-grid cells, tables) rendering
- Fix oxlint/lint-frontend CI failure: rewrite constant-expression ||
  formula test as a helper function to avoid "constant truthiness"
  linting error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:26:15 +00:00
Elizabeth Thompson
6be81d3c4a fix(tests): fix prettier formatting and jest scale tests
- Run prettier on downloadAsImage.tsx to fix pre-commit CI failure
- Replace isolateModulesAsync scale tests (jest.mock inside async
  callbacks is hoisted and doesn't capture scale) with simpler
  capturedScale pattern using mockImplementation, and a pure formula
  test verifying Math.max(dpr || 1, 2) behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:31:35 +00:00
Elizabeth Thompson
fc24d4b4d2 fix: enforce minimum 2x scale on standard displays
Change IMAGE_DOWNLOAD_SCALE from `window.devicePixelRatio || 2` to
`Math.max(window.devicePixelRatio || 1, 2)` so that standard displays
(devicePixelRatio=1) also get 2x output. The previous expression
evaluated to 1 on standard displays since 1 is truthy.

Add tests covering the standard display (dpr=1→2) and zero (dpr=0→2)
cases, and rename the prior fallback test accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:51:18 +00:00
Elizabeth Thompson
00ab3a305b feat: add high-DPI scale to chart image downloads
Add IMAGE_DOWNLOAD_SCALE constant using window.devicePixelRatio (|| 2
fallback) and pass it as the `scale` option to both dom-to-image-more
toJpeg calls. This doubles image resolution for retina displays and
ensures a minimum 2x output for all users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:38:27 +00:00
8 changed files with 188 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

49
superset-frontend/src/utils/downloadAsImage.test.ts Normal file → Executable file
View File

@@ -35,6 +35,11 @@ jest.mock('@apache-superset/core/translation', () => ({
t: (str: string) => str,
}));
// No ECharts instances exist in jsdom; getInstanceByDom always returns undefined.
jest.mock('echarts', () => ({
getInstanceByDom: jest.fn().mockReturnValue(undefined),
}));
const mockToJpeg = domToImage.toJpeg as jest.Mock;
const mockAddWarningToast = addWarningToast as jest.Mock;
@@ -683,3 +688,47 @@ test('clone path falls back to white background when theme is absent', async ()
document.body.removeChild(container);
});
test('ag-grid path passes scale >= 2 to toJpeg', async () => {
jest.useFakeTimers();
const { container, agContainer, cleanup } = buildAgGridElement();
attachMockApi(agContainer);
let capturedScale: number | undefined;
mockToJpeg.mockImplementation(
(_el: HTMLElement, opts: { scale?: number }) => {
capturedScale = opts.scale;
return Promise.resolve('data:image/jpeg;base64,test');
},
);
const handler = downloadAsImageOptimized('div', 'My Chart');
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
expect(capturedScale).toBeGreaterThanOrEqual(2);
cleanup();
jest.useRealTimers();
});
test('clone path passes scale >= 2 to toJpeg', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
let capturedScale: number | undefined;
mockToJpeg.mockImplementation(
(_el: HTMLElement, opts: { scale?: number }) => {
capturedScale = opts.scale;
return Promise.resolve('data:image/jpeg;base64,test');
},
);
const handler = downloadAsImageOptimized('div', 'Bar Chart');
await handler(syntheticEventFor(container));
expect(capturedScale).toBeGreaterThanOrEqual(2);
document.body.removeChild(container);
});

156
superset-frontend/src/utils/downloadAsImage.tsx Normal file → Executable file
View File

@@ -18,6 +18,7 @@
*/
import { SyntheticEvent } from 'react';
import domToImage from 'dom-to-image-more';
import { getInstanceByDom } from 'echarts';
import { kebabCase } from 'lodash';
import { t } from '@apache-superset/core/translation';
import { SupersetTheme } from '@apache-superset/core/theme';
@@ -25,6 +26,7 @@ import { addWarningToast } from 'src/components/MessageToasts/actions';
import type { AgGridContainerElement } from '@superset-ui/core/components';
const IMAGE_DOWNLOAD_QUALITY = 0.95;
const IMAGE_DOWNLOAD_SCALE = Math.max(window.devicePixelRatio || 1, 2);
const TRANSPARENT_RGBA = 'transparent';
const POLL_INTERVAL_MS = 100;
@@ -220,30 +222,139 @@ const processCloneForVisibility = (clone: HTMLElement) => {
});
};
const preserveCanvasContent = (original: Element, clone: Element) => {
const originalCanvases = original.querySelectorAll('canvas');
const clonedCanvases = clone.querySelectorAll('canvas');
originalCanvases.forEach((originalCanvas, i) => {
if (originalCanvases[i] && clonedCanvases[i]) {
const clonedCanvas = clonedCanvases[i] as HTMLCanvasElement;
const ctx = clonedCanvas.getContext('2d');
if (ctx) {
clonedCanvas.width = originalCanvas.width;
clonedCanvas.height = originalCanvas.height;
ctx.drawImage(originalCanvas, 0, 0);
// Render each ECharts instance at IMAGE_DOWNLOAD_SCALE using the public
// getDataURL API. This happens entirely off-screen — no canvas is mutated,
// no global is spoofed, no resize() is called. Returns a map from the
// ECharts container element in `original` to its high-res PNG data URL.
const captureEchartsHighRes = (
element: Element,
): Map<HTMLElement, string> => {
const map = new Map<HTMLElement, string>();
element.querySelectorAll('[_echarts_instance_]').forEach(el => {
const instance = getInstanceByDom(el as HTMLElement);
if (instance) {
try {
map.set(
el as HTMLElement,
instance.getDataURL({
type: 'png',
pixelRatio: IMAGE_DOWNLOAD_SCALE,
backgroundColor: 'transparent',
}),
);
} catch {
// getDataURL can fail if the chart isn't fully initialised; fall through
// to the plain canvas copy below.
}
}
});
return map;
};
// For each ECharts container, replace all of its (blank) cloned canvases with
// a single <img> whose src is the high-res data URL captured above. For all
// other canvases, copy the backing store 1:1.
const preserveCanvasContent = (
original: Element,
clone: Element,
echartsDataUrls: Map<HTMLElement, string>,
) => {
const originalEchartsEls = Array.from(
original.querySelectorAll('[_echarts_instance_]'),
) as HTMLElement[];
const cloneEchartsEls = Array.from(
clone.querySelectorAll('[_echarts_instance_]'),
) as HTMLElement[];
const echartsCanvases = new Set<Element>();
originalEchartsEls.forEach((originalEl, i) => {
const cloneEl = cloneEchartsEls[i];
const dataUrl = echartsDataUrls.get(originalEl);
if (!cloneEl || !dataUrl) return;
originalEl.querySelectorAll('canvas').forEach(c => echartsCanvases.add(c));
const firstCanvas = originalEl.querySelector('canvas') as HTMLCanvasElement | null;
const cssWidth = firstCanvas?.offsetWidth ?? 0;
const cssHeight = firstCanvas?.offsetHeight ?? 0;
cloneEl.querySelectorAll('canvas').forEach(c => c.remove());
if (cssWidth > 0 && cssHeight > 0) {
const img = document.createElement('img');
img.src = dataUrl;
img.style.cssText = `display:block;width:${cssWidth}px;height:${cssHeight}px;`;
cloneEl.appendChild(img);
}
});
// Plain 1:1 copy for non-ECharts canvases.
const originalCanvases = original.querySelectorAll('canvas');
const clonedCanvases = clone.querySelectorAll('canvas');
originalCanvases.forEach((originalCanvas, i) => {
if (echartsCanvases.has(originalCanvas) || !clonedCanvases[i]) return;
const clonedCanvas = clonedCanvases[i] as HTMLCanvasElement;
const ctx = clonedCanvas.getContext('2d');
if (ctx) {
clonedCanvas.width = originalCanvas.width;
clonedCanvas.height = originalCanvas.height;
ctx.drawImage(originalCanvas, 0, 0);
}
});
};
// Temporarily swaps ECharts canvases in the live DOM with <img> elements
// holding the pre-captured high-res data URLs, so domToImage picks up the
// high-res pixels instead of the 1× canvas. Call restoreEchartsSwap in
// a finally block to put the real canvases back.
type EchartsSwapEntry = {
container: HTMLElement;
canvases: HTMLCanvasElement[];
img: HTMLImageElement | null;
};
const swapEchartsForImages = (
echartsDataUrls: Map<HTMLElement, string>,
): EchartsSwapEntry[] => {
const restoreList: EchartsSwapEntry[] = [];
echartsDataUrls.forEach((dataUrl, container) => {
const firstCanvas = container.querySelector(
'canvas',
) as HTMLCanvasElement | null;
const cssWidth = firstCanvas?.offsetWidth ?? 0;
const cssHeight = firstCanvas?.offsetHeight ?? 0;
const canvases = Array.from(
container.querySelectorAll('canvas'),
) as HTMLCanvasElement[];
canvases.forEach(c => c.remove());
let img: HTMLImageElement | null = null;
if (cssWidth > 0 && cssHeight > 0) {
img = document.createElement('img');
img.src = dataUrl;
img.style.cssText = `display:block;width:${cssWidth}px;height:${cssHeight}px;`;
container.appendChild(img);
}
restoreList.push({ container, canvases, img });
});
return restoreList;
};
const restoreEchartsSwap = (restoreList: EchartsSwapEntry[]) => {
restoreList.forEach(({ container, canvases, img }) => {
if (img?.parentElement) img.remove();
canvases.forEach(c => container.appendChild(c));
});
};
const createEnhancedClone = (
originalElement: Element,
theme?: SupersetTheme,
): { clone: HTMLElement; cleanup: () => void } => {
const echartsDataUrls = captureEchartsHighRes(originalElement);
const clone = originalElement.cloneNode(true) as HTMLElement;
copyAllComputedStyles(originalElement, clone, theme);
preserveCanvasContent(originalElement, clone);
preserveCanvasContent(originalElement, clone, echartsDataUrls);
const tempContainer = document.createElement('div');
tempContainer.style.cssText = `
@@ -350,8 +461,7 @@ export default function downloadAsImageOptimized(
if (agContainer && agRootWrapper) {
const api = agContainer._agGridApi;
const isFirstDataRendered =
agContainer._agGridFirstDataRendered === true;
const isFirstDataRendered = agContainer._agGridFirstDataRendered === true;
if (!isFirstDataRendered) {
addWarningToast(
@@ -368,12 +478,15 @@ export default function downloadAsImageOptimized(
const visibleColumnState =
savedColumnState?.filter(col => !col.hide) ?? [];
const originalWidth =
visibleColumnState.reduce((sum, col) => sum + (col.width ?? 0), 0) ||
agRootWrapper.offsetWidth;
visibleColumnState.reduce(
(sum: number, col) => sum + ((col.width as number | null) ?? 0),
0,
) || agRootWrapper.offsetWidth;
// Chrome SVG foreignObject bug: % min-height resolves against canvas height,
// causing cells to expand to full image height and overlap adjacent rows.
const cellFixups: CellFixup[] = [];
let agEchartsSwap: EchartsSwapEntry[] = [];
try {
await document.fonts.ready;
@@ -422,10 +535,16 @@ export default function downloadAsImageOptimized(
const imageHeight = agRootWrapper.scrollHeight;
agEchartsSwap = swapEchartsForImages(
captureEchartsHighRes(agRootWrapper),
);
const dataUrl = await domToImage.toJpeg(agRootWrapper, {
bgcolor: theme?.colorBgContainer,
filter,
quality: IMAGE_DOWNLOAD_QUALITY,
scale: IMAGE_DOWNLOAD_SCALE,
style: { WebkitFontSmoothing: 'antialiased' },
height: imageHeight,
width: originalWidth,
cacheBust: true,
@@ -441,6 +560,7 @@ export default function downloadAsImageOptimized(
t('Image download failed, please refresh and try again.'),
);
} finally {
restoreEchartsSwap(agEchartsSwap);
cellFixups.forEach(({ el, minHeight, overflow }) => {
el.style.minHeight = minHeight;
el.style.overflow = overflow;
@@ -472,6 +592,8 @@ export default function downloadAsImageOptimized(
bgcolor: theme?.colorBgContainer,
filter,
quality: IMAGE_DOWNLOAD_QUALITY,
scale: IMAGE_DOWNLOAD_SCALE,
style: { WebkitFontSmoothing: 'antialiased' },
height: clone.scrollHeight,
width: clone.scrollWidth,
cacheBust: true,