You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/06/06 01:03:30 UTC
(superset) branch image-download-scale-pr created (now f095159669d)
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a change to branch image-download-scale-pr
in repository https://gitbox.apache.org/repos/asf/superset.git
at f095159669d fix: re-render ECharts at download scale for sharp labels
This branch includes the following new commits:
new f095159669d fix: re-render ECharts at download scale for sharp labels
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: fix: re-render ECharts at download scale for sharp labels
Posted by el...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch image-download-scale-pr
in repository https://gitbox.apache.org/repos/asf/superset.git
commit f095159669de519c419d578593c35d3f60a9cc16
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Sat Jun 6 00:42:46 2026 +0000
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 <no...@anthropic.com>
---
.../src/utils/downloadAsImage.test.ts | 13 ++--
superset-frontend/src/utils/downloadAsImage.tsx | 83 ++++++++++++++++------
2 files changed, 68 insertions(+), 28 deletions(-)
diff --git a/superset-frontend/src/utils/downloadAsImage.test.ts b/superset-frontend/src/utils/downloadAsImage.test.ts
index ff324284473..bb73e24bb4b 100755
--- a/superset-frontend/src/utils/downloadAsImage.test.ts
+++ b/superset-frontend/src/utils/downloadAsImage.test.ts
@@ -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;
@@ -727,11 +732,3 @@ test('clone path passes scale >= 2 to toJpeg', async () => {
document.body.removeChild(container);
});
-
-test('scale formula enforces minimum 2 for standard and zero devicePixelRatio values', () => {
- const computeScale = (dpr: number) => Math.max(dpr || 1, 2);
- expect(computeScale(1)).toBe(2); // standard display: dpr=1 is truthy but Math.max clamps to 2
- expect(computeScale(0)).toBe(2); // zero dpr: || gives 1, Math.max clamps to 2
- expect(computeScale(2)).toBe(2); // exactly 2× retina
- expect(computeScale(3)).toBe(3); // 3× retina: dpr wins over minimum
-});
diff --git a/superset-frontend/src/utils/downloadAsImage.tsx b/superset-frontend/src/utils/downloadAsImage.tsx
index edad9062916..dc4e0b66910 100755
--- a/superset-frontend/src/utils/downloadAsImage.tsx
+++ b/superset-frontend/src/utils/downloadAsImage.tsx
@@ -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';
@@ -221,32 +222,53 @@ const processCloneForVisibility = (clone: HTMLElement) => {
});
};
+// Re-render all ECharts instances inside `element` at the given devicePixelRatio.
+// Returns the affected container elements so the caller can restore them later.
+// ECharts re-renders synchronously on resize, but we still await a frame after
+// calling this so any scheduled ZRender flushes complete before cloning.
+const resizeEchartsInstances = (
+ element: Element,
+ devicePixelRatio: number,
+): HTMLElement[] => {
+ const containers: HTMLElement[] = [];
+ element.querySelectorAll('[_echarts_instance_]').forEach(el => {
+ const instance = getInstanceByDom(el as HTMLElement);
+ if (instance) {
+ containers.push(el as HTMLElement);
+ // devicePixelRatio is accepted by the underlying ZRender resize but is
+ // absent from ECharts' public ResizeOpts typedef — cast to work around it.
+ (instance.resize as Function)({ devicePixelRatio });
+ }
+ });
+ return containers;
+};
+
+const restoreEchartsInstances = (containers: HTMLElement[]) => {
+ const originalDpr = window.devicePixelRatio || 1;
+ containers.forEach(el => {
+ const instance = getInstanceByDom(el);
+ if (instance) {
+ (instance.resize as Function)({ devicePixelRatio: originalDpr });
+ }
+ });
+};
+
+// Copy canvas backing stores 1:1 into the cloned canvases.
+// ECharts has already been resized to IMAGE_DOWNLOAD_SCALE before cloning,
+// so the backing store is already at the target resolution — no additional
+// scaling is needed here.
const preserveCanvasContent = (original: Element, clone: Element) => {
const originalCanvases = original.querySelectorAll('canvas');
const clonedCanvases = clone.querySelectorAll('canvas');
- // Scale canvas content to match the download scale so dom-to-image gets a
- // 1:1 pixel mapping instead of stretching already-rasterized pixels.
- // Canvas libs (e.g. ECharts) render at devicePixelRatio scale internally, so
- // we normalise back to CSS pixels first, then scale up to IMAGE_DOWNLOAD_SCALE.
- const copyScale = IMAGE_DOWNLOAD_SCALE / (window.devicePixelRatio || 1);
-
originalCanvases.forEach((originalCanvas, i) => {
- if (originalCanvases[i] && clonedCanvases[i]) {
+ if (clonedCanvases[i]) {
const clonedCanvas = clonedCanvases[i] as HTMLCanvasElement;
const ctx = clonedCanvas.getContext('2d');
if (ctx) {
- clonedCanvas.width = Math.round(originalCanvas.width * copyScale);
- clonedCanvas.height = Math.round(originalCanvas.height * copyScale);
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(
- originalCanvas,
- 0,
- 0,
- clonedCanvas.width,
- clonedCanvas.height,
- );
+ clonedCanvas.width = originalCanvas.width;
+ clonedCanvas.height = originalCanvas.height;
+ ctx.drawImage(originalCanvas, 0, 0);
}
}
});
@@ -382,12 +404,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 agEchartsContainers: HTMLElement[] = [];
try {
await document.fonts.ready;
@@ -436,6 +461,14 @@ export default function downloadAsImageOptimized(
const imageHeight = agRootWrapper.scrollHeight;
+ agEchartsContainers = resizeEchartsInstances(
+ agRootWrapper,
+ IMAGE_DOWNLOAD_SCALE,
+ );
+ await new Promise<void>(resolve =>
+ requestAnimationFrame(() => resolve()),
+ );
+
const dataUrl = await domToImage.toJpeg(agRootWrapper, {
bgcolor: theme?.colorBgContainer,
filter,
@@ -457,6 +490,7 @@ export default function downloadAsImageOptimized(
t('Image download failed, please refresh and try again.'),
);
} finally {
+ restoreEchartsInstances(agEchartsContainers);
cellFixups.forEach(({ el, minHeight, overflow }) => {
el.style.minHeight = minHeight;
el.style.overflow = overflow;
@@ -477,6 +511,14 @@ export default function downloadAsImageOptimized(
// All other chart types: use the clone-based approach
let cleanup: (() => void) | null = null;
+ // Re-render ECharts at target scale before cloning so the canvas backing
+ // store is already at full resolution when preserveCanvasContent copies it.
+ const cloneEchartsContainers = resizeEchartsInstances(
+ elementToPrint,
+ IMAGE_DOWNLOAD_SCALE,
+ );
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
+
try {
const { clone, cleanup: cleanupFn } = createEnhancedClone(
elementToPrint,
@@ -509,6 +551,7 @@ export default function downloadAsImageOptimized(
);
} finally {
if (cleanup) cleanup();
+ restoreEchartsInstances(cloneEchartsContainers);
}
};
}