This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 217e4c0223f fix(frontend): use html2canvas for chart image export on
Safari (#44529)
217e4c0223f is described below
commit 217e4c0223f03e86cdd1dfdc0cbc0e1efc14ac98
Author: hadi mobarra <[email protected]>
AuthorDate: Fri Oct 2 03:22:15 2026 +0330
fix(frontend): use html2canvas for chart image export on Safari (#44529)
---
superset-frontend/package-lock.json | 6 +-
superset-frontend/package.json | 1 +
.../src/MapLibre.tsx | 62 +++--
.../test/MapLibre.test.tsx | 31 +++
.../src/DeckGLContainer.test.tsx | 95 ++++++-
.../preset-chart-deckgl/src/DeckGLContainer.tsx | 17 +-
.../src/utils/downloadAsImage.test.ts | 276 +++++++++++++++++++++
superset-frontend/src/utils/downloadAsImage.tsx | 113 ++++++++-
8 files changed, 553 insertions(+), 48 deletions(-)
diff --git a/superset-frontend/package-lock.json
b/superset-frontend/package-lock.json
index 1ba6270c879..60b80ad4b87 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -97,6 +97,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.1.0",
+ "html2canvas": "^1.4.1",
"immer": "^11.1.18",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -17580,7 +17581,6 @@
"resolved":
"https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity":
"sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">= 0.6.0"
}
@@ -19727,7 +19727,6 @@
"resolved":
"https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity":
"sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
- "optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@@ -25216,7 +25215,6 @@
"resolved":
"https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity":
"sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
- "optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
@@ -42759,7 +42757,6 @@
"resolved":
"https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity":
"sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
- "optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@@ -44293,7 +44290,6 @@
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity":
"sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
- "optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index f44d6d6edfa..dd1d2d49c17 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -174,6 +174,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.1.0",
+ "html2canvas": "^1.4.1",
"immer": "^11.1.18",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
diff --git
a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/MapLibre.tsx
b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/MapLibre.tsx
index a9b4603a754..dcdf66b263d 100644
--- a/superset-frontend/plugins/plugin-chart-point-cluster-map/src/MapLibre.tsx
+++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/src/MapLibre.tsx
@@ -45,6 +45,9 @@
maplibregl.setWorkerUrl(`${__webpack_public_path__}maplibre-gl-worker.mjs`);
const DEFAULT_MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
+const isSafari = () =>
+ /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+
interface Viewport {
longitude: number;
latitude: number;
@@ -198,34 +201,51 @@ function MapLibre({
);
}
- const MapComponent = mapProvider === 'mapbox' ? MapboxMap : MapLibreMap;
- const mapboxProps =
- mapProvider === 'mapbox' ? { mapboxAccessToken: mapboxApiKey } : {};
+ const overlay = (
+ <ScatterPlotOverlay
+ locations={clusters}
+ dotRadius={pointRadius}
+ pointRadiusUnit={pointRadiusUnit}
+ rgb={rgb}
+ globalOpacity={globalOpacity}
+ compositeOperation="screen"
+ renderWhileDragging={renderWhileDragging}
+ aggregation={hasCustomMetric ? aggregatorName : undefined}
+ zoom={viewport.zoom}
+ lngLatAccessor={(location: GeoJSONLocation) => {
+ const { coordinates } = location.geometry;
+ return [coordinates[0], coordinates[1]];
+ }}
+ />
+ );
+
+ if (mapProvider === 'mapbox') {
+ return (
+ <MapboxMap
+ {...viewport}
+ mapboxAccessToken={mapboxApiKey}
+ style={{ width, height }}
+ mapStyle={resolvedMapStyle}
+ onMove={handleMove}
+ preserveDrawingBuffer={isSafari()}
+ >
+ {overlay}
+ </MapboxMap>
+ );
+ }
return (
- <MapComponent
+ <MapLibreMap
{...viewport}
- {...mapboxProps}
style={{ width, height }}
mapStyle={resolvedMapStyle}
onMove={handleMove}
+ canvasContextAttributes={
+ isSafari() ? { preserveDrawingBuffer: true } : undefined
+ }
>
- <ScatterPlotOverlay
- locations={clusters}
- dotRadius={pointRadius}
- pointRadiusUnit={pointRadiusUnit}
- rgb={rgb}
- globalOpacity={globalOpacity}
- compositeOperation="screen"
- renderWhileDragging={renderWhileDragging}
- aggregation={hasCustomMetric ? aggregatorName : undefined}
- zoom={viewport.zoom}
- lngLatAccessor={(location: GeoJSONLocation) => {
- const { coordinates } = location.geometry;
- return [coordinates[0], coordinates[1]];
- }}
- />
- </MapComponent>
+ {overlay}
+ </MapLibreMap>
);
}
diff --git
a/superset-frontend/plugins/plugin-chart-point-cluster-map/test/MapLibre.test.tsx
b/superset-frontend/plugins/plugin-chart-point-cluster-map/test/MapLibre.test.tsx
index 7bf5a353d2c..3ae14624770 100644
---
a/superset-frontend/plugins/plugin-chart-point-cluster-map/test/MapLibre.test.tsx
+++
b/superset-frontend/plugins/plugin-chart-point-cluster-map/test/MapLibre.test.tsx
@@ -131,6 +131,10 @@ beforeEach(() => {
lastMapProps = {};
document.body.innerHTML = '';
jest.clearAllMocks();
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Chrome',
+ configurable: true,
+ });
mockFitBounds.mockImplementation(
(
bounds: [[number, number], [number, number]],
@@ -281,6 +285,33 @@ test('passes Mapbox styles through when a key exists', ()
=> {
expect(lastMapProps.mapboxAccessToken).toBe('pk.test');
});
+test('preserves the MapLibre canvas on Safari for image export', () => {
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Version/17.0 Safari/605.1.15',
+ configurable: true,
+ });
+
+ render(<MapLibre {...defaultProps} />);
+
+ expect(lastMapProps.canvasContextAttributes).toEqual({
+ preserveDrawingBuffer: true,
+ });
+});
+
+test('preserves the Mapbox canvas on Safari for image export', () => {
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Version/17.0 Safari/605.1.15',
+ configurable: true,
+ });
+ document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
+ common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
+ })}'></div>`;
+
+ render(<MapLibre {...defaultProps} mapProvider="mapbox" />);
+
+ expect(lastMapProps.preserveDrawingBuffer).toBe(true);
+});
+
test('handles undefined bounds gracefully', () => {
render(<MapLibre {...defaultProps} bounds={undefined} />);
expect(lastMapProps.longitude).toBe(0);
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.test.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.test.tsx
index b035d2ee87d..ddc66e81645 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.test.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.test.tsx
@@ -34,12 +34,20 @@ jest.mock('react-map-gl/maplibre', () => ({
children,
mapStyle,
onMove,
+ canvasContextAttributes,
}: {
children: ReactNode;
mapStyle: unknown;
onMove: (evt: { viewState: Record<string, number> }) => void;
+ canvasContextAttributes?: { preserveDrawingBuffer?: boolean };
}) => (
- <div data-test="maplibre-map" data-map-style={JSON.stringify(mapStyle)}>
+ <div
+ data-test="maplibre-map"
+ data-map-style={JSON.stringify(mapStyle)}
+ data-preserve-drawing-buffer={String(
+ canvasContextAttributes?.preserveDrawingBuffer ?? false,
+ )}
+ >
<button
type="button"
aria-label="move map"
@@ -54,8 +62,20 @@ jest.mock('react-map-gl/maplibre', () => ({
}));
jest.mock('react-map-gl/mapbox', () => ({
- Map: ({ children, mapStyle }: { children: ReactNode; mapStyle: unknown }) =>
(
- <div data-test="mapbox-map" data-map-style={JSON.stringify(mapStyle)}>
+ Map: ({
+ children,
+ mapStyle,
+ preserveDrawingBuffer,
+ }: {
+ children: ReactNode;
+ mapStyle: unknown;
+ preserveDrawingBuffer?: boolean;
+ }) => (
+ <div
+ data-test="mapbox-map"
+ data-map-style={JSON.stringify(mapStyle)}
+ data-preserve-drawing-buffer={String(preserveDrawingBuffer ?? false)}
+ >
{children}
</div>
),
@@ -66,16 +86,24 @@ jest.mock('mapbox-gl', () => ({ accessToken: '' }));
jest.mock(
'./components/DeckGLOverlayMapLibre',
() =>
- ({ layers }: { layers: unknown[] }) => (
- <div data-test="maplibre-overlay" data-layers-count={layers.length} />
+ ({ layers, interleaved }: { layers: unknown[]; interleaved?: boolean }) =>
(
+ <div
+ data-test="maplibre-overlay"
+ data-layers-count={layers.length}
+ data-interleaved={String(interleaved ?? false)}
+ />
),
);
jest.mock(
'./components/DeckGLOverlayMapbox',
() =>
- ({ layers }: { layers: unknown[] }) => (
- <div data-test="mapbox-overlay" data-layers-count={layers.length} />
+ ({ layers, interleaved }: { layers: unknown[]; interleaved?: boolean }) =>
(
+ <div
+ data-test="mapbox-overlay"
+ data-layers-count={layers.length}
+ data-interleaved={String(interleaved ?? false)}
+ />
),
);
@@ -105,6 +133,10 @@ const renderContainer = (
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Chrome',
+ configurable: true,
+ });
});
test('DeckGLContainer converts OSM raster tile templates into MapLibre style
objects', () => {
@@ -168,6 +200,55 @@ test('DeckGLContainer passes Mapbox styles through when a
key exists', () => {
);
});
+test('DeckGLContainer preserves map canvases on Safari', () => {
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Version/17.0 Safari/605.1.15',
+ configurable: true,
+ });
+
+ renderContainer({ mapProvider: 'maplibre' });
+ expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
+ 'data-preserve-drawing-buffer',
+ 'true',
+ );
+ expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
+ 'data-interleaved',
+ 'true',
+ );
+
+ renderContainer({ mapProvider: 'mapbox', mapboxApiKey: 'pk.test' });
+ expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
+ 'data-preserve-drawing-buffer',
+ 'true',
+ );
+ expect(screen.getByTestId('mapbox-overlay')).toHaveAttribute(
+ 'data-interleaved',
+ 'true',
+ );
+});
+
+test('DeckGLContainer does not preserve or interleave canvases outside
Safari', () => {
+ renderContainer({ mapProvider: 'maplibre' });
+ expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
+ 'data-preserve-drawing-buffer',
+ 'false',
+ );
+ expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
+ 'data-interleaved',
+ 'false',
+ );
+
+ renderContainer({ mapProvider: 'mapbox', mapboxApiKey: 'pk.test' });
+ expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
+ 'data-preserve-drawing-buffer',
+ 'false',
+ );
+ expect(screen.getByTestId('mapbox-overlay')).toHaveAttribute(
+ 'data-interleaved',
+ 'false',
+ );
+});
+
test('DeckGLContainer supports layer factories for MapLibre overlays', () => {
const layer = { id: 'layer-1' } as unknown as Layer;
const layerFactory = () => layer;
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.tsx
index 3a4112478d4..e49d547ea77 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/DeckGLContainer.tsx
@@ -51,6 +51,9 @@ const TICK = 250; // milliseconds
const DEFAULT_MAP_STYLE =
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
+const isSafari = () =>
+ /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+
export type DeckGLContainerProps = {
viewport: Viewport;
setControlValue?: (control: string, value: JsonValue) => void;
@@ -171,18 +174,28 @@ export const DeckGLContainer = memo(
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
+ preserveDrawingBuffer={isSafari()}
style={{ width, height }}
>
- <DeckGLOverlayMapbox layers={layers()} />
+ <DeckGLOverlayMapbox
+ layers={layers()}
+ interleaved={isSafari() ? true : undefined}
+ />
</MapboxMap>
) : (
<MapLibreMap
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
+ canvasContextAttributes={
+ isSafari() ? { preserveDrawingBuffer: true } : undefined
+ }
style={{ width, height }}
>
- <DeckGLOverlayMapLibre layers={layers()} />
+ <DeckGLOverlayMapLibre
+ layers={layers()}
+ interleaved={isSafari() ? true : undefined}
+ />
</MapLibreMap>
)}
{children}
diff --git a/superset-frontend/src/utils/downloadAsImage.test.ts
b/superset-frontend/src/utils/downloadAsImage.test.ts
index d4e3593b3e2..c9e38d8e7db 100644
--- a/superset-frontend/src/utils/downloadAsImage.test.ts
+++ b/superset-frontend/src/utils/downloadAsImage.test.ts
@@ -17,9 +17,11 @@
* under the License.
*/
import domToImage from 'dom-to-image-more';
+import html2canvas from 'html2canvas';
import { getInstanceByDom } from 'echarts/core';
import { addWarningToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
+import { isSafari } from 'src/utils/common';
import downloadAsImageOptimized, {
waitForStableScrollHeight,
} from './downloadAsImage';
@@ -29,6 +31,16 @@ jest.mock('dom-to-image-more', () => ({
default: { toJpeg: jest.fn(), toPng: jest.fn() },
}));
+jest.mock('html2canvas', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('src/utils/common', () => ({
+ ...jest.requireActual('src/utils/common'),
+ isSafari: jest.fn(),
+}));
+
jest.mock('echarts/core', () => ({
__esModule: true,
getInstanceByDom: jest.fn(),
@@ -55,6 +67,8 @@ jest.mock('@apache-superset/core/translation', () => ({
const mockToJpeg = domToImage.toJpeg as jest.Mock;
const mockToPng = domToImage.toPng as jest.Mock;
+const mockHtml2Canvas = html2canvas as jest.Mock;
+const mockIsSafari = isSafari as jest.Mock;
const mockAddWarningToast = addWarningToast as jest.Mock;
const mockGetInstanceByDom = getInstanceByDom as jest.Mock;
const mockDispatch = store.dispatch as jest.Mock;
@@ -108,6 +122,10 @@ beforeEach(() => {
// clearAllMocks does not clear a mockReturnValue, so reset the instance
lookup explicitly to
// stop a return value leaking into any clone-path test added after the
ECharts ones below.
mockGetInstanceByDom.mockReset();
+ // Default to a non-Safari engine so the dom-to-image paths under test stay
in
+ // effect; the Safari-specific tests opt in with
mockIsSafari.mockReturnValue(true).
+ mockIsSafari.mockReturnValue(false);
+ mockHtml2Canvas.mockReset();
mockToJpeg.mockResolvedValue('data:image/jpeg;base64,test');
mockToPng.mockResolvedValue('data:image/png;base64,test');
});
@@ -1076,3 +1094,261 @@ test('re-renders an ECharts host only once when it owns
multiple canvas layers',
restore();
document.body.removeChild(container);
});
+
+// Safari/WebKit cannot rasterize the SVG <foreignObject> that
dom-to-image-more
+// builds, so image captures route through html2canvas instead.
+test('captures via html2canvas on Safari instead of dom-to-image', async () =>
{
+ mockIsSafari.mockReturnValue(true);
+ const toDataURL = jest.fn(() => 'data:image/jpeg;base64,safari');
+ mockHtml2Canvas.mockResolvedValue({ toDataURL });
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const handler = downloadAsImageOptimized('div', 'My Chart');
+ await handler(syntheticEventFor(container));
+
+ expect(mockHtml2Canvas).toHaveBeenCalledWith(
+ container,
+ expect.objectContaining({ useCORS: true, scale: 1, logging: false }),
+ );
+ expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.95);
+ expect(mockToJpeg).not.toHaveBeenCalled();
+ expect(mockToPng).not.toHaveBeenCalled();
+ expect(mockAddWarningToast).not.toHaveBeenCalled();
+
+ document.body.removeChild(container);
+});
+
+test('requests a transparent, scaled PNG from html2canvas on Safari', async ()
=> {
+ mockIsSafari.mockReturnValue(true);
+ const toDataURL = jest.fn(() => 'data:image/png;base64,safari');
+ mockHtml2Canvas.mockResolvedValue({ toDataURL });
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ false,
+ undefined,
+ {
+ format: 'png',
+ backgroundType: 'transparent',
+ },
+ );
+ await handler(syntheticEventFor(container));
+
+ expect(mockHtml2Canvas).toHaveBeenCalledWith(
+ container,
+ expect.objectContaining({ backgroundColor: null, scale: 2 }),
+ );
+ expect(toDataURL).toHaveBeenCalledWith('image/png', 0.95);
+
+ document.body.removeChild(container);
+});
+
+test('honours the node filter in the html2canvas capture on Safari', async ()
=> {
+ mockIsSafari.mockReturnValue(true);
+ mockHtml2Canvas.mockResolvedValue({
+ toDataURL: jest.fn(() => 'data:image/jpeg;base64,safari'),
+ });
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const handler = downloadAsImageOptimized('div', 'My Chart');
+ await handler(syntheticEventFor(container));
+
+ const [firstCall] = mockHtml2Canvas.mock.calls;
+ const [, { ignoreElements }] = firstCall;
+ const excluded = document.createElement('div');
+ excluded.className = 'header-controls';
+ const included = document.createElement('div');
+ expect(ignoreElements(excluded)).toBe(true);
+ expect(ignoreElements(included)).toBe(false);
+
+ document.body.removeChild(container);
+});
+
+test('shows a warning toast when the html2canvas capture rejects on Safari',
async () => {
+ mockIsSafari.mockReturnValue(true);
+ mockHtml2Canvas.mockRejectedValue(new Error('capture failed'));
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const handler = downloadAsImageOptimized('div', 'My Chart');
+ await handler(syntheticEventFor(container));
+
+ expect(mockAddWarningToast).toHaveBeenCalledWith(
+ 'Image download failed, please refresh and try again.',
+ );
+ expect(mockToJpeg).not.toHaveBeenCalled();
+
+ document.body.removeChild(container);
+});
+
+test('keeps ag-grid print layout and full dimensions when capturing on
Safari', async () => {
+ jest.useFakeTimers();
+ mockIsSafari.mockReturnValue(true);
+ const { container, agContainer, agRootWrapper, cleanup } =
+ buildAgGridElement();
+ const api = attachMockApi(agContainer);
+ Object.defineProperty(agRootWrapper, 'scrollHeight', {
+ get: () => 900,
+ configurable: true,
+ });
+ Object.defineProperty(agRootWrapper, 'offsetWidth', {
+ get: () => 700,
+ configurable: true,
+ });
+ const toDataURL = jest.fn(() => 'data:image/jpeg;base64,safari');
+ mockHtml2Canvas.mockResolvedValue({ toDataURL });
+
+ const handler = downloadAsImageOptimized('div', 'My Chart');
+ const exportPromise = handler(syntheticEventFor(container));
+ await jest.runAllTimersAsync();
+ await exportPromise;
+
+ expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'print');
+ expect(mockHtml2Canvas).toHaveBeenCalledWith(
+ agRootWrapper,
+ expect.objectContaining({ height: 900, width: 700, scale: 1 }),
+ );
+ expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'normal');
+ expect(mockToJpeg).not.toHaveBeenCalled();
+
+ cleanup();
+});
+
+test('preserves canvas content in the html2canvas clone on Safari', async ()
=> {
+ const { drawImage, restore } = stubCanvasContext();
+ mockIsSafari.mockReturnValue(true);
+ const container = document.createElement('div');
+ const canvas = document.createElement('canvas');
+ canvas.width = 400;
+ canvas.height = 300;
+ container.appendChild(canvas);
+ document.body.appendChild(container);
+ const toDataURL = jest.fn(() => 'data:image/png;base64,safari');
+
+ mockHtml2Canvas.mockImplementation(
+ (
+ _element,
+ options: { onclone: (document: Document, clone: HTMLElement) => void },
+ ) => {
+ options.onclone(document, container.cloneNode(true) as HTMLElement);
+ return Promise.resolve({ toDataURL });
+ },
+ );
+
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'Deck Chart',
+ false,
+ undefined,
+ { format: 'png' },
+ );
+ await handler(syntheticEventFor(container));
+
+ expect(drawImage).toHaveBeenCalledWith(canvas, 0, 0);
+
+ restore();
+ document.body.removeChild(container);
+});
+
+// `processCloneForVisibility` expands clipped content on the clone, so the
source
+// element's measurements understate what the capture must cover. html2canvas
reads
+// width/height only after onclone returns, so the callback must widen them or
long
+// tables and virtualized lists are cropped to their on-screen size on Safari.
+test('widens the Safari capture to the expanded clone size, not the on-screen
size', async () => {
+ mockIsSafari.mockReturnValue(true);
+ const container = document.createElement('div');
+ Object.defineProperty(container, 'scrollHeight', {
+ get: () => 300,
+ configurable: true,
+ });
+ Object.defineProperty(container, 'scrollWidth', {
+ get: () => 400,
+ configurable: true,
+ });
+ document.body.appendChild(container);
+ const toDataURL = jest.fn(() => 'data:image/jpeg;base64,safari');
+
+ let captured: { width?: number; height?: number } | undefined;
+ mockHtml2Canvas.mockImplementation(
+ (
+ _element,
+ options: {
+ width?: number;
+ height?: number;
+ onclone: (document: Document, clone: HTMLElement) => void;
+ },
+ ) => {
+ // The expanded clone is taller and wider than the element it came from.
+ const clone = document.createElement('div');
+ Object.defineProperty(clone, 'scrollHeight', {
+ get: () => 900,
+ configurable: true,
+ });
+ Object.defineProperty(clone, 'scrollWidth', {
+ get: () => 1200,
+ configurable: true,
+ });
+ options.onclone(document, clone);
+ captured = options;
+ return Promise.resolve({ toDataURL });
+ },
+ );
+
+ const handler = downloadAsImageOptimized('div', 'Long Table');
+ await handler(syntheticEventFor(container));
+
+ expect(captured?.height).toBe(900);
+ expect(captured?.width).toBe(1200);
+
+ document.body.removeChild(container);
+});
+
+test('keeps the on-screen capture size as a floor when the clone is not
larger', async () => {
+ mockIsSafari.mockReturnValue(true);
+ const container = document.createElement('div');
+ Object.defineProperty(container, 'scrollHeight', {
+ get: () => 1000,
+ configurable: true,
+ });
+ Object.defineProperty(container, 'scrollWidth', {
+ get: () => 800,
+ configurable: true,
+ });
+ document.body.appendChild(container);
+ const toDataURL = jest.fn(() => 'data:image/jpeg;base64,safari');
+
+ let captured: { width?: number; height?: number } | undefined;
+ mockHtml2Canvas.mockImplementation(
+ (
+ _element,
+ options: {
+ width?: number;
+ height?: number;
+ onclone: (document: Document, clone: HTMLElement) => void;
+ },
+ ) => {
+ // A clone that reports nothing usable must not shrink the capture.
+ const clone = document.createElement('div');
+ options.onclone(document, clone);
+ captured = options;
+ return Promise.resolve({ toDataURL });
+ },
+ );
+
+ const handler = downloadAsImageOptimized('div', 'Chart');
+ await handler(syntheticEventFor(container));
+
+ expect(captured?.height).toBe(1000);
+ expect(captured?.width).toBe(800);
+
+ document.body.removeChild(container);
+});
diff --git a/superset-frontend/src/utils/downloadAsImage.tsx
b/superset-frontend/src/utils/downloadAsImage.tsx
index d0cb07fb61d..ddf16d126bd 100644
--- a/superset-frontend/src/utils/downloadAsImage.tsx
+++ b/superset-frontend/src/utils/downloadAsImage.tsx
@@ -18,10 +18,14 @@
*/
import { SyntheticEvent } from 'react';
import domToImage from 'dom-to-image-more';
+// Type-only import: erased at build time, so html2canvas still reaches the
core
+// bundle only through the dynamic import inside the Safari branch below.
+import type { Options as Html2CanvasOptions } from 'html2canvas';
import { kebabCase } from 'lodash-es';
import { t } from '@apache-superset/core/translation';
import { SupersetTheme } from '@apache-superset/core/theme';
import type { AgGridContainerElement } from '@superset-ui/core/components';
+import { isSafari } from 'src/utils/common';
import {
dispatchWarningToast,
forceLoadAllCharts,
@@ -59,6 +63,17 @@ type CellFixup = { el: HTMLElement; minHeight: string;
overflow: string };
const generateFileStem = (description: string, date = new Date()) =>
`${kebabCase(description)}-${date.toISOString().replace(/[: ]/g, '-')}`;
+const triggerDownload = (
+ dataUrl: string,
+ description: string,
+ isPng: boolean,
+) => {
+ const link = document.createElement('a');
+ link.download = `${generateFileStem(description)}.${isPng ? 'png' : 'jpg'}`;
+ link.href = dataUrl;
+ link.click();
+};
+
const CRITICAL_STYLE_PROPERTIES = new Set([
'display',
'position',
@@ -559,14 +574,36 @@ export default function downloadAsImageOptimized(
}),
};
- const dataUrl = isPng
- ? await domToImage.toPng(agRootWrapper, agImageOptions)
- : await domToImage.toJpeg(agRootWrapper, agImageOptions);
+ let dataUrl: string;
+ if (isSafari()) {
+ // `dom-to-image-more` relies on SVG <foreignObject>, which WebKit
does
+ // not reliably paint. Keep the ag-grid preparation above, then use a
+ // DOM painter for the actual Safari capture.
+ const { default: html2canvas } = await import('html2canvas');
+ const canvas = await html2canvas(agRootWrapper, {
+ backgroundColor:
+ bgcolor === TRANSPARENT_RGBA ? null : (bgcolor ?? null),
+ height: imageHeight,
+ width: originalWidth,
+ scale,
+ useCORS: true,
+ logging: false,
+ ignoreElements: element => !filter(element),
+ onclone: (_document, clone) => {
+ preserveCanvasContent(agRootWrapper, clone);
+ },
+ });
+ dataUrl = canvas.toDataURL(
+ isPng ? 'image/png' : 'image/jpeg',
+ IMAGE_DOWNLOAD_QUALITY,
+ );
+ } else {
+ dataUrl = isPng
+ ? await domToImage.toPng(agRootWrapper, agImageOptions)
+ : await domToImage.toJpeg(agRootWrapper, agImageOptions);
+ }
- const link = document.createElement('a');
- link.download = `${generateFileStem(description)}.${isPng ? 'png' :
'jpg'}`;
- link.href = dataUrl;
- link.click();
+ triggerDownload(dataUrl, description, isPng);
} catch (error) {
console.error('Creating image failed', error);
await dispatchWarningToast(
@@ -593,7 +630,7 @@ export default function downloadAsImageOptimized(
return;
}
- // All other chart types: use the clone-based approach
+ // All other chart types: preserve canvas contents and expand clipped
content.
let cleanup: (() => void) | null = null;
// Only the PNG path upscales the layout (transform: scale(PNG_SCALE)), so
only there does a
@@ -612,6 +649,60 @@ export default function downloadAsImageOptimized(
}
try {
+ if (isSafari()) {
+ // `dom-to-image-more` serializes through SVG <foreignObject>, which
+ // WebKit does not reliably paint. html2canvas clones the document
itself;
+ // restore the clone-path canvas and visibility work in its clone
callback.
+ const { default: html2canvas } = await import('html2canvas');
+ const captureOptions: Partial<Html2CanvasOptions> = {
+ backgroundColor:
+ bgcolor === TRANSPARENT_RGBA ? null : (bgcolor ?? null),
+ height: (elementToPrint as HTMLElement).scrollHeight,
+ width: (elementToPrint as HTMLElement).scrollWidth,
+ scale,
+ // A cross-origin image on a server that sends no CORS headers
cannot be drawn
+ // into an exportable canvas at all: html2canvas skips it when
tainting is
+ // disallowed, and `allowTaint: true` would let it through but taint
the canvas,
+ // making `toDataURL()` throw so the entire download fails instead
of one image
+ // being left out. Keeping tainting off trades a missing image for
an otherwise
+ // complete export, which is also what the dom-to-image path did.
+ useCORS: true,
+ logging: false,
+ ignoreElements: element => !filter(element),
+ onclone: (_document, clone) => {
+ processCloneForVisibility(clone, isDashboardCapture);
+ preserveCanvasContent(elementToPrint, clone, getInstanceByDom);
+ // `processCloneForVisibility` sets height/overflow to `auto` on
the clone, so
+ // content clipped on screen (long tables, virtualized lists)
extends past the
+ // source element's measurements. html2canvas reads width/height
only after this
+ // callback returns, so widening them here captures the expanded
content instead
+ // of cropping it to what was visible. `Math.max` keeps the
on-screen size as a
+ // floor, so a clone that cannot be measured is never captured
smaller than before.
+ captureOptions.width = Math.max(
+ captureOptions.width ?? 0,
+ clone.scrollWidth,
+ );
+ captureOptions.height = Math.max(
+ captureOptions.height ?? 0,
+ clone.scrollHeight,
+ );
+ },
+ };
+ const canvas = await html2canvas(
+ elementToPrint as HTMLElement,
+ captureOptions,
+ );
+ triggerDownload(
+ canvas.toDataURL(
+ isPng ? 'image/png' : 'image/jpeg',
+ IMAGE_DOWNLOAD_QUALITY,
+ ),
+ description,
+ isPng,
+ );
+ return;
+ }
+
const { clone, cleanup: cleanupFn } = createEnhancedClone(
elementToPrint,
theme,
@@ -644,11 +735,7 @@ export default function downloadAsImageOptimized(
cleanup();
cleanup = null;
- const extension = isPng ? 'png' : 'jpg';
- const link = document.createElement('a');
- link.download = `${generateFileStem(description)}.${extension}`;
- link.href = dataUrl;
- link.click();
+ triggerDownload(dataUrl, description, isPng);
} catch (error) {
console.error('Creating image failed', error);
await dispatchWarningToast(