mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
9 Commits
fix/chart-
...
image-down
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04388ebc42 | ||
|
|
bc202b54aa | ||
|
|
f095159669 | ||
|
|
215000c137 | ||
|
|
4a814f9a55 | ||
|
|
50a33ae199 | ||
|
|
6be81d3c4a | ||
|
|
fc24d4b4d2 | ||
|
|
00ab3a305b |
BIN
qa-evidence/pr39285-dashboard.png
Normal file
BIN
qa-evidence/pr39285-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
qa-evidence/pr39285-download-menu.png
Normal file
BIN
qa-evidence/pr39285-download-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
BIN
qa-evidence/pr39285-downloaded-image-896x1134.jpg
Normal file
BIN
qa-evidence/pr39285-downloaded-image-896x1134.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
qa-evidence/pr39285-pie-dashboard.png
Normal file
BIN
qa-evidence/pr39285-pie-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
qa-evidence/pr39285-pie-download-menu.png
Normal file
BIN
qa-evidence/pr39285-pie-download-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
qa-evidence/pr39285-pie-downloaded-550x818.jpg
Normal file
BIN
qa-evidence/pr39285-pie-downloaded-550x818.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
49
superset-frontend/src/utils/downloadAsImage.test.ts
Normal file → Executable file
49
superset-frontend/src/utils/downloadAsImage.test.ts
Normal file → Executable 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
156
superset-frontend/src/utils/downloadAsImage.tsx
Normal file → Executable 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,
|
||||
|
||||
Reference in New Issue
Block a user