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(

Reply via email to