You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by hu...@apache.org on 2026/07/02 23:21:31 UTC

(superset) 01/01: feat(dashboard): add "Export Images to Excel" mode for chart images

This is an automated email from the ASF dual-hosted git repository.

hughhhh pushed a commit to branch hughhhh/dashboard-excel-image-export
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0a06f00b87d39096916862d7d7e62b67fc9be026
Author: Hugh A Miles II <hu...@gmail.com>
AuthorDate: Thu Jul 2 19:21:16 2026 -0400

    feat(dashboard): add "Export Images to Excel" mode for chart images
    
    Add an image-export mode to the async dashboard Excel export. A new
    "Export Images to Excel" menu item posts mode="images"; the Celery task
    then embeds non-table charts as rendered PNGs (via the same ChartScreenshot
    path scheduled reports use, injecting the live dashboard filter state as
    extra_form_data) while table charts stay tabular. mode="data" preserves the
    existing behavior exactly. The change is fully additive and threads a `mode`
    flag from the menu through the API, schema, and task.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../DownloadMenuItems/DownloadMenuItems.test.tsx   |  26 +++-
 .../components/menu/DownloadMenuItems/index.tsx    |  11 +-
 superset/config.py                                 |   4 +
 superset/dashboards/api.py                         |   1 +
 superset/dashboards/excel_export/screenshot.py     |  98 ++++++++++++++
 superset/dashboards/schemas.py                     |  11 +-
 superset/tasks/export_dashboard_excel.py           |  77 ++++++++++-
 superset/utils/excel_streaming.py                  |  22 ++++
 .../dashboards/test_excel_export_screenshot.py     | 146 +++++++++++++++++++++
 .../tasks/test_export_dashboard_excel.py           | 110 +++++++++++++++-
 tests/unit_tests/utils/excel_streaming_tests.py    |  47 +++++++
 11 files changed, 536 insertions(+), 17 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
index 05a6db75efd..353dc786c92 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
@@ -110,19 +110,21 @@ test('Should render all menu items', () => {
 
   // Export options
   expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
+  expect(screen.getByText('Export Images to Excel')).toBeInTheDocument();
   expect(screen.getByText('Export YAML')).toBeInTheDocument();
   expect(screen.getByText('Export as Example')).toBeInTheDocument();
 });
 
-test('Export Data to Excel is hidden when userCanExport is false', () => {
+test('Excel export items are hidden when userCanExport is false', () => {
   render(<MenuWrapperWithProps userCanExport={false} />, { useRedux: true });
 
   expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
+  expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
   // YAML export is not gated and remains visible
   expect(screen.getByText('Export YAML')).toBeInTheDocument();
 });
 
-test('Export Data to Excel posts active_data_mask and shows a pending toast', async () => {
+test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
   mockSupersetClient.post.mockResolvedValue({} as never);
 
   render(<MenuWrapper />, { useRedux: true });
@@ -132,7 +134,25 @@ test('Export Data to Excel posts active_data_mask and shows a pending toast', as
   await waitFor(() => {
     expect(mockSupersetClient.post).toHaveBeenCalledWith({
       endpoint: '/api/v1/dashboard/123/export_xlsx/',
-      jsonPayload: { active_data_mask: {} },
+      jsonPayload: { active_data_mask: {}, mode: 'data' },
+    });
+    expect(mockAddSuccessToast).toHaveBeenCalledWith(
+      "Your export is being prepared. You'll receive an email when it's ready.",
+    );
+  });
+});
+
+test('Export Images to Excel posts mode "images" and shows a pending toast', async () => {
+  mockSupersetClient.post.mockResolvedValue({} as never);
+
+  render(<MenuWrapper />, { useRedux: true });
+
+  await userEvent.click(screen.getByText('Export Images to Excel'));
+
+  await waitFor(() => {
+    expect(mockSupersetClient.post).toHaveBeenCalledWith({
+      endpoint: '/api/v1/dashboard/123/export_xlsx/',
+      jsonPayload: { active_data_mask: {}, mode: 'images' },
     });
     expect(mockAddSuccessToast).toHaveBeenCalledWith(
       "Your export is being prepared. You'll receive an email when it's ready.",
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index a47ebf965df..01091176249 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -167,11 +167,11 @@ export const useDownloadMenuItems = (
     }
   };
 
-  const onExportXlsx = async () => {
+  const onExportXlsx = async (mode: 'data' | 'images') => {
     try {
       await SupersetClient.post({
         endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
-        jsonPayload: { active_data_mask: buildActiveDataMask() },
+        jsonPayload: { active_data_mask: buildActiveDataMask(), mode },
       });
       addSuccessToast(
         t(
@@ -242,7 +242,12 @@ export const useDownloadMenuItems = (
           {
             key: 'export-xlsx',
             label: t('Export Data to Excel'),
-            onClick: onExportXlsx,
+            onClick: () => onExportXlsx('data'),
+          },
+          {
+            key: 'export-xlsx-images',
+            label: t('Export Images to Excel'),
+            onClick: () => onExportXlsx('images'),
           },
         ]
       : []),
diff --git a/superset/config.py b/superset/config.py
index d5453d1ac60..576a507686e 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1385,6 +1385,10 @@ EXCEL_EXPORT_LINK_TTL_SECONDS = 86400
 # endpoint_url for S3-compatible stores (MinIO/LocalStack). Credentials
 # otherwise resolve through the standard boto3 chain.
 EXCEL_EXPORT_S3_CLIENT_KWARGS: dict[str, Any] = {}
+# Viz types treated as tables in the "Export Images to Excel" mode: these charts
+# stay tabular (one worksheet of data) while every other viz type is embedded as
+# a rendered image. Set to None to fall back to the built-in default.
+EXCEL_EXPORT_TABLE_VIZ_TYPES: set[str] | None = None
 
 # ---------------------------------------------------
 # Time grain configurations
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 3941beea326..78c52ca0688 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -1567,6 +1567,7 @@ class DashboardRestApi(
                 "user_id": g.user.id,
                 "active_data_mask": payload.get("active_data_mask", {}),
                 "job_id": job_id,
+                "mode": payload.get("mode", "data"),
             },
             task_id=job_id,
         )
diff --git a/superset/dashboards/excel_export/screenshot.py b/superset/dashboards/excel_export/screenshot.py
new file mode 100644
index 00000000000..5e8f33e5f8a
--- /dev/null
+++ b/superset/dashboards/excel_export/screenshot.py
@@ -0,0 +1,98 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Render a single dashboard chart to a PNG for the image-mode Excel export.
+
+This reuses the same headless render path scheduled reports use
+(:class:`~superset.utils.screenshots.ChartScreenshot`), but points it at an
+Explore URL whose ``form_data`` carries the live dashboard filter state — so an
+embedded image reflects the same filters the data path applies, rather than the
+chart's default saved state.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from flask import current_app
+
+from superset.charts.data.dashboard_filter_context import (
+    get_dashboard_filter_context,
+)
+from superset.utils import json
+from superset.utils.screenshots import ChartScreenshot
+from superset.utils.urls import get_url_path
+
+logger = logging.getLogger(__name__)
+
+
+def render_chart_image(
+    chart: Any,
+    dashboard_id: int,
+    active_data_mask: dict[str, Any],
+    user: Any,
+) -> bytes | None:
+    """
+    Render ``chart`` (as seen on ``dashboard_id``) to PNG bytes.
+
+    The chart is rendered through Explore in standalone mode with the live
+    dashboard filter state injected as ``extra_form_data`` — the same object the
+    data path merges into the query context — so the image and the data stay
+    consistent.
+
+    :param chart: The ``Slice`` to render
+    :param dashboard_id: The dashboard the chart is displayed on (for filter scope)
+    :param active_data_mask: Live dashboard filter state keyed by native filter id
+    :param user: The requesting user; the render runs with their permissions
+    :returns: PNG bytes, or ``None`` if the render failed (the caller skips and
+        notes the chart)
+    """
+    try:
+        filter_context = get_dashboard_filter_context(
+            dashboard_id=dashboard_id,
+            chart_id=chart.id,
+            active_data_mask=active_data_mask,
+        )
+
+        # Start from the chart's saved form data and force the slice id, then
+        # layer the live filters on top so the render matches the data path.
+        form_data: dict[str, Any] = json.loads(chart.params or "{}")
+        form_data["slice_id"] = chart.id
+        if filter_context.extra_form_data:
+            form_data["extra_form_data"] = filter_context.extra_form_data
+
+        url = get_url_path(
+            "ExploreView.root",
+            form_data=json.dumps(form_data),
+        )
+
+        window_size = current_app.config["WEBDRIVER_WINDOW"]["slice"]
+        screenshot = ChartScreenshot(
+            url,
+            chart.digest,
+            window_size=window_size,
+            thumb_size=window_size,
+        )
+        return screenshot.get_screenshot(user=user)
+    except Exception:  # pylint: disable=broad-except
+        logger.exception(
+            "Failed to render image for chart %s in dashboard %s",
+            getattr(chart, "id", "?"),
+            dashboard_id,
+        )
+        return None
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 60f85daab30..7bf3eda1b9a 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -18,7 +18,7 @@ import re
 from typing import Any, Mapping, Union
 
 from marshmallow import fields, post_dump, post_load, pre_load, Schema
-from marshmallow.validate import Length, ValidationError
+from marshmallow.validate import Length, OneOf, ValidationError
 
 from superset import security_manager
 from superset.tags.models import TagType
@@ -645,6 +645,15 @@ class DashboardExportXlsxPostSchema(Schema):
             "each carrying an extraFormData object."
         },
     )
+    mode = fields.String(
+        load_default="data",
+        validate=OneOf(["data", "images"]),
+        metadata={
+            "description": "Export mode: 'data' streams each chart's tabular result "
+            "(default); 'images' embeds non-table charts as rendered images and "
+            "keeps table charts tabular."
+        },
+    )
 
 
 class DashboardExportXlsxResponseSchema(Schema):
diff --git a/superset/tasks/export_dashboard_excel.py b/superset/tasks/export_dashboard_excel.py
index 664e45acb8c..22cc6cd5d6a 100644
--- a/superset/tasks/export_dashboard_excel.py
+++ b/superset/tasks/export_dashboard_excel.py
@@ -19,9 +19,12 @@ Celery task that exports every chart on a dashboard to a single multi-sheet
 ``.xlsx`` file, uploads it to S3, and emails the requesting user a pre-signed
 download link.
 
-The task re-runs each chart's saved query context under the requesting user,
-applies the live dashboard filter state, and streams the results row-by-row into
-a constant-memory workbook so large dashboards never load all data at once.
+In ``"data"`` mode the task re-runs each chart's saved query context under the
+requesting user, applies the live dashboard filter state, and streams the results
+row-by-row into a constant-memory workbook so large dashboards never load all
+data at once. In ``"images"`` mode non-table charts are instead rendered to
+images (through the same headless path as scheduled reports, reflecting the live
+filters) and embedded, while table-like charts stay tabular.
 """
 
 from __future__ import annotations
@@ -45,6 +48,7 @@ from superset.commands.chart.data.get_data_command import ChartDataCommand
 from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
 from superset.dashboards.excel_export import email
 from superset.dashboards.excel_export.layout import get_charts_in_layout_order
+from superset.dashboards.excel_export.screenshot import render_chart_image
 from superset.extensions import celery_app
 from superset.utils import json, s3
 from superset.utils.core import override_user
@@ -52,6 +56,20 @@ from superset.utils.excel_streaming import StreamingXlsxWriter
 
 logger = logging.getLogger(__name__)
 
+# Export modes: "data" streams every chart's tabular result (the default,
+# unchanged behavior); "images" embeds non-table charts as rendered images and
+# keeps only table-like charts tabular.
+EXPORT_MODE_DATA = "data"
+EXPORT_MODE_IMAGES = "images"
+
+# Viz types kept as tabular data in image mode; everything else is rendered as an
+# image. Operators can override the set via ``EXCEL_EXPORT_TABLE_VIZ_TYPES``.
+TABLE_VIZ_TYPES = {"table", "pivot_table_v2", "pivot_table"}
+
+
+class _ChartSkippedError(Exception):
+    """Signals a chart that could not be exported and should be listed as skipped."""
+
 
 def _chart_label(chart: Any) -> str:
     """Human-readable label for a chart in the skipped-charts list."""
@@ -62,6 +80,34 @@ def _record_to_row(record: dict[str, Any], colnames: list[str]) -> list[Any]:
     return [record.get(col) for col in colnames]
 
 
+def _table_viz_types() -> set[str]:
+    """Viz types kept tabular in image mode (config override or built-in default)."""
+    return current_app.config.get("EXCEL_EXPORT_TABLE_VIZ_TYPES") or TABLE_VIZ_TYPES
+
+
+def _renders_as_image(chart: Any, mode: str) -> bool:
+    """Whether this chart is embedded as an image rather than streamed as data."""
+    return mode == EXPORT_MODE_IMAGES and chart.viz_type not in _table_viz_types()
+
+
+def _write_chart_image_sheet(
+    writer: StreamingXlsxWriter,
+    chart: Any,
+    dashboard_id: int,
+    active_data_mask: dict[str, Any],
+    user: Any,
+) -> None:
+    """
+    Render a single chart to an image and embed it as its own sheet.
+
+    :raises _ChartSkippedError: if the chart could not be rendered
+    """
+    image = render_chart_image(chart, dashboard_id, active_data_mask, user)
+    if image is None:
+        raise _ChartSkippedError
+    writer.add_image_sheet(_chart_label(chart), image)
+
+
 def _write_chart_sheets(
     writer: StreamingXlsxWriter,
     chart: Any,
@@ -116,17 +162,29 @@ def _build_workbook(
     dashboard: Any,
     active_data_mask: dict[str, Any],
     job_id: str,
+    mode: str,
+    user: Any,
 ) -> list[str]:
     """Build the workbook on disk; return the list of skipped chart labels."""
     skipped: list[str] = []
     writer = StreamingXlsxWriter(path)
     try:
         for chart in get_charts_in_layout_order(dashboard):
-            if not chart.query_context:
+            as_image = _renders_as_image(chart, mode)
+            # Image charts render from their saved params and don't need a query
+            # context; data (and table) charts still do.
+            if not as_image and not chart.query_context:
                 skipped.append(_chart_label(chart))
                 continue
             try:
-                _write_chart_sheets(writer, chart, dashboard.id, active_data_mask)
+                if as_image:
+                    _write_chart_image_sheet(
+                        writer, chart, dashboard.id, active_data_mask, user
+                    )
+                else:
+                    _write_chart_sheets(writer, chart, dashboard.id, active_data_mask)
+            except _ChartSkippedError:
+                skipped.append(_chart_label(chart))
             except Exception:  # pylint: disable=broad-except
                 logger.exception(
                     "Skipping chart %s in dashboard export %s", chart.id, job_id
@@ -171,14 +229,17 @@ def export_dashboard_excel(
     user_id: int,
     active_data_mask: dict[str, Any],
     job_id: str,
+    mode: str = EXPORT_MODE_DATA,
 ) -> None:
     """
-    Export a dashboard's chart data to an ``.xlsx`` and email a download link.
+    Export a dashboard's charts to an ``.xlsx`` and email a download link.
 
     :param dashboard_id: The dashboard to export
     :param user_id: The requesting user (the task runs with their permissions)
     :param active_data_mask: Live dashboard filter state keyed by native filter id
     :param job_id: Correlation id, also the Celery task id and S3 object name
+    :param mode: ``"data"`` streams every chart's tabular result; ``"images"``
+        embeds non-table charts as rendered images and keeps tables tabular
     """
     # pylint: disable=import-outside-toplevel
     from superset.models.dashboard import Dashboard
@@ -202,7 +263,9 @@ def export_dashboard_excel(
             )
             os.close(file_descriptor)
 
-            skipped = _build_workbook(tmp_path, dashboard, active_data_mask, job_id)
+            skipped = _build_workbook(
+                tmp_path, dashboard, active_data_mask, job_id, mode, user
+            )
 
             bucket = current_app.config["EXCEL_EXPORT_S3_BUCKET"]
             key = (
diff --git a/superset/utils/excel_streaming.py b/superset/utils/excel_streaming.py
index e4fd17d52e9..e3ae8547074 100644
--- a/superset/utils/excel_streaming.py
+++ b/superset/utils/excel_streaming.py
@@ -33,6 +33,7 @@ import re
 from collections.abc import Iterable, Sequence
 from datetime import date, datetime
 from decimal import Decimal
+from io import BytesIO
 from typing import Any
 
 import xlsxwriter
@@ -212,6 +213,27 @@ class StreamingXlsxWriter:
         self.sheet_count += 1
         return written
 
+    def add_image_sheet(self, name: str, image_bytes: bytes) -> None:
+        """
+        Write a single sheet holding a rendered chart image.
+
+        The image is embedded top-left; ``xlsxwriter`` buffers image data and
+        writes it at :meth:`close`, so this composes with ``constant_memory``
+        mode just like the row-streaming sheets.
+
+        :param name: Desired sheet name (sanitized/de-duplicated automatically)
+        :param image_bytes: PNG bytes to embed
+        """
+        sheet_name = sanitize_sheet_name(name, self._used_sheet_names)
+        worksheet = self._workbook.add_worksheet(sheet_name)
+        worksheet.insert_image(
+            0,
+            0,
+            f"{sheet_name}.png",
+            {"image_data": BytesIO(image_bytes)},
+        )
+        self.sheet_count += 1
+
     def add_summary_sheet(self, name: str, lines: Sequence[str]) -> None:
         """
         Write a single-column informational sheet (e.g. a list of skipped charts).
diff --git a/tests/unit_tests/dashboards/test_excel_export_screenshot.py b/tests/unit_tests/dashboards/test_excel_export_screenshot.py
new file mode 100644
index 00000000000..87513a8988d
--- /dev/null
+++ b/tests/unit_tests/dashboards/test_excel_export_screenshot.py
@@ -0,0 +1,146 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+from superset.charts.data.dashboard_filter_context import DashboardFilterContext
+from superset.dashboards.excel_export import screenshot as screenshot_module
+from superset.dashboards.excel_export.screenshot import render_chart_image
+from superset.utils import json
+
+MODULE = "superset.dashboards.excel_export.screenshot"
+
+
+def _chart(
+    chart_id: int = 42,
+    params: str = '{"viz_type": "pie"}',
+    digest: str = "abc123",
+) -> MagicMock:
+    chart = MagicMock()
+    chart.id = chart_id
+    chart.params = params
+    chart.digest = digest
+    return chart
+
+
+def _patched_app() -> Any:
+    app = MagicMock()
+    app.config = {"WEBDRIVER_WINDOW": {"slice": (3000, 1200)}}
+    return app
+
+
+def test_render_chart_image_builds_url_with_slice_id_and_filters() -> None:
+    chart = _chart()
+    active_mask = {"NATIVE_FILTER-1": {"extraFormData": {"filters": [{"col": "a"}]}}}
+    filter_context = DashboardFilterContext(
+        extra_form_data={"filters": [{"col": "a", "op": "IN", "val": ["x"]}]}
+    )
+
+    screenshot_instance = MagicMock()
+    screenshot_instance.get_screenshot.return_value = b"PNGBYTES"
+
+    with (
+        patch(
+            f"{MODULE}.get_dashboard_filter_context", return_value=filter_context
+        ) as mock_ctx,
+        patch(f"{MODULE}.get_url_path", return_value="/explore/url") as mock_url,
+        patch(
+            f"{MODULE}.ChartScreenshot", return_value=screenshot_instance
+        ) as mock_screenshot,
+        patch.object(screenshot_module, "current_app", _patched_app()),
+    ):
+        user = MagicMock()
+        result = render_chart_image(
+            chart, dashboard_id=7, active_data_mask=active_mask, user=user
+        )
+
+    assert result == b"PNGBYTES"
+
+    # Live filter state is resolved for this chart on this dashboard.
+    mock_ctx.assert_called_once_with(
+        dashboard_id=7, chart_id=42, active_data_mask=active_mask
+    )
+
+    # The Explore URL carries the slice id and the live extra_form_data.
+    _, url_kwargs = mock_url.call_args
+    form_data = json.loads(url_kwargs["form_data"])
+    assert form_data["slice_id"] == 42
+    assert form_data["viz_type"] == "pie"
+    assert form_data["extra_form_data"] == filter_context.extra_form_data
+
+    # ChartScreenshot is built with the chart digest + slice window sizing.
+    args, kwargs = mock_screenshot.call_args
+    assert args[0] == "/explore/url"
+    assert args[1] == "abc123"
+    assert kwargs["window_size"] == (3000, 1200)
+    assert kwargs["thumb_size"] == (3000, 1200)
+    screenshot_instance.get_screenshot.assert_called_once_with(user=user)
+
+
+def test_render_chart_image_omits_extra_form_data_when_no_filters() -> None:
+    chart = _chart()
+    filter_context = DashboardFilterContext(extra_form_data={})
+    screenshot_instance = MagicMock()
+    screenshot_instance.get_screenshot.return_value = b"PNG"
+
+    with (
+        patch(f"{MODULE}.get_dashboard_filter_context", return_value=filter_context),
+        patch(f"{MODULE}.get_url_path", return_value="/u") as mock_url,
+        patch(f"{MODULE}.ChartScreenshot", return_value=screenshot_instance),
+        patch.object(screenshot_module, "current_app", _patched_app()),
+    ):
+        render_chart_image(chart, dashboard_id=7, active_data_mask={}, user=MagicMock())
+
+    _, url_kwargs = mock_url.call_args
+    form_data = json.loads(url_kwargs["form_data"])
+    assert "extra_form_data" not in form_data
+
+
+def test_render_chart_image_returns_none_when_screenshot_fails() -> None:
+    chart = _chart()
+    screenshot_instance = MagicMock()
+    screenshot_instance.get_screenshot.return_value = None
+
+    with (
+        patch(
+            f"{MODULE}.get_dashboard_filter_context",
+            return_value=DashboardFilterContext(),
+        ),
+        patch(f"{MODULE}.get_url_path", return_value="/u"),
+        patch(f"{MODULE}.ChartScreenshot", return_value=screenshot_instance),
+        patch.object(screenshot_module, "current_app", _patched_app()),
+    ):
+        result = render_chart_image(
+            chart, dashboard_id=7, active_data_mask={}, user=MagicMock()
+        )
+
+    assert result is None
+
+
+def test_render_chart_image_returns_none_on_exception() -> None:
+    chart = _chart()
+
+    with patch(
+        f"{MODULE}.get_dashboard_filter_context", side_effect=ValueError("boom")
+    ):
+        result = render_chart_image(
+            chart, dashboard_id=7, active_data_mask={}, user=MagicMock()
+        )
+
+    assert result is None
diff --git a/tests/unit_tests/tasks/test_export_dashboard_excel.py b/tests/unit_tests/tasks/test_export_dashboard_excel.py
index 0ef3aa97ade..8fb859b110b 100644
--- a/tests/unit_tests/tasks/test_export_dashboard_excel.py
+++ b/tests/unit_tests/tasks/test_export_dashboard_excel.py
@@ -32,14 +32,36 @@ from superset.utils import json
 MODULE = "superset.tasks.export_dashboard_excel"
 
 
-def _chart(chart_id: int, name: str, has_context: bool = True) -> mock.MagicMock:
+# A minimal valid 1x1 transparent PNG for image-mode tests.
+_PNG_1x1 = (
+    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
+    b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00"
+    b"\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def _chart(
+    chart_id: int,
+    name: str,
+    has_context: bool = True,
+    viz_type: str = "line",
+) -> mock.MagicMock:
     chart = mock.MagicMock()
     chart.id = chart_id
     chart.slice_name = name
+    chart.viz_type = viz_type
     chart.query_context = json.dumps({"queries": [{}]}) if has_context else None
     return chart
 
 
+def _media(path: str) -> list[str]:
+    """Embedded media entries of an xlsx (which is a zip archive)."""
+    import zipfile
+
+    with zipfile.ZipFile(path) as archive:
+        return [n for n in archive.namelist() if n.startswith("xl/media/")]
+
+
 @pytest.fixture
 def mocks() -> Iterator[dict[str, Any]]:
     """Patch every external dependency of the task; keep the real xlsx writer."""
@@ -58,6 +80,7 @@ def mocks() -> Iterator[dict[str, Any]]:
                 "get_dashboard_filter_context",
                 "ChartDataQueryContextSchema",
                 "ChartDataCommand",
+                "render_chart_image",
                 "s3",
                 "email",
             )
@@ -83,11 +106,11 @@ def mocks() -> Iterator[dict[str, Any]]:
         yield patched
 
 
-def _run(job_id: str = "job-1") -> None:
+def _run(job_id: str = "job-1", mode: str = "data") -> None:
     from superset.tasks.export_dashboard_excel import export_dashboard_excel
 
     export_dashboard_excel(
-        dashboard_id=1, user_id=2, active_data_mask={}, job_id=job_id
+        dashboard_id=1, user_id=2, active_data_mask={}, job_id=job_id, mode=mode
     )
 
 
@@ -210,3 +233,84 @@ def test_soft_time_limit_sends_failure_email(mocks: dict[str, Any]) -> None:
 
     mocks["email"].build_failure_email.assert_called_once()
     assert _no_temp_files_left("job-timeout")
+
+
+# --- image mode ---
+
+
+def test_images_mode_embeds_non_table_and_keeps_tables_tabular(
+    mocks: dict[str, Any],
+) -> None:
+    mocks["get_charts_in_layout_order"].return_value = [
+        _chart(10, "Line", viz_type="line"),
+        _chart(20, "Tbl", viz_type="table"),
+    ]
+    mocks["render_chart_image"].return_value = _PNG_1x1
+    mocks["ChartDataCommand"].return_value.run.return_value = {
+        "queries": [{"colnames": ["a"], "data": [{"a": 1}]}]
+    }
+
+    uploaded: dict[str, Any] = {}
+
+    def _capture(path: str, bucket: str, key: str) -> None:
+        uploaded["sheets"] = _read_sheets(path)
+        uploaded["media"] = _media(path)
+
+    mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+    _run(mode="images")
+
+    # The non-table chart is rendered as an image (with the requesting user)...
+    mocks["render_chart_image"].assert_called_once()
+    render_args = mocks["render_chart_image"].call_args[0]
+    assert render_args[0].id == 10
+    assert render_args[3] is mocks["user"]
+    # ...and the table chart still goes through the data path.
+    mocks["ChartDataCommand"].return_value.run.assert_called_once()
+
+    assert set(uploaded["sheets"].keys()) == {"10 - Line", "20 - Tbl"}
+    # Exactly one embedded image (the non-table chart).
+    assert len(uploaded["media"]) == 1
+
+
+def test_images_mode_renders_chart_without_query_context(
+    mocks: dict[str, Any],
+) -> None:
+    # An image chart with no saved query context can still render.
+    mocks["get_charts_in_layout_order"].return_value = [
+        _chart(10, "Line", viz_type="line", has_context=False),
+    ]
+    mocks["render_chart_image"].return_value = _PNG_1x1
+
+    uploaded: dict[str, Any] = {}
+
+    def _capture(path: str, bucket: str, key: str) -> None:
+        uploaded["media"] = _media(path)
+
+    mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+    _run(mode="images")
+
+    mocks["render_chart_image"].assert_called_once()
+    assert len(uploaded["media"]) == 1
+
+
+def test_images_mode_none_render_is_skipped(mocks: dict[str, Any]) -> None:
+    mocks["get_charts_in_layout_order"].return_value = [
+        _chart(10, "Line", viz_type="line"),
+    ]
+    mocks["render_chart_image"].return_value = None
+
+    uploaded: dict[str, Any] = {}
+
+    def _capture(path: str, bucket: str, key: str) -> None:
+        uploaded["sheets"] = _read_sheets(path)
+
+    mocks["s3"].upload_file_to_s3.side_effect = _capture
+
+    _run(mode="images")
+
+    _, kwargs = mocks["email"].build_success_email.call_args
+    assert kwargs["skipped_charts"] == ["10 - Line"]
+    # Nothing rendered → the summary sheet stands in for an empty workbook.
+    assert "Export Summary" in uploaded["sheets"]
diff --git a/tests/unit_tests/utils/excel_streaming_tests.py b/tests/unit_tests/utils/excel_streaming_tests.py
index b7b73f63f47..822db329d8b 100644
--- a/tests/unit_tests/utils/excel_streaming_tests.py
+++ b/tests/unit_tests/utils/excel_streaming_tests.py
@@ -218,3 +218,50 @@ def test_writer_summary_sheet(tmp_path: Path) -> None:
 
     sheets = _read_workbook(path)
     assert sheets["Export Summary"] == [["Skipped charts:"], ["10 - Broken"]]
+
+
+# --- add_image_sheet ---
+
+# A minimal valid 1x1 transparent PNG.
+_PNG_1x1 = (
+    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06"
+    b"\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00"
+    b"\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+def _read_media(path: str) -> list[str]:
+    """Return the embedded media entries of an xlsx (which is a zip archive)."""
+    import zipfile
+
+    with zipfile.ZipFile(path) as archive:
+        return [n for n in archive.namelist() if n.startswith("xl/media/")]
+
+
+def test_add_image_sheet_embeds_image_and_counts(tmp_path: Path) -> None:
+    path = str(tmp_path / "out.xlsx")
+    writer = StreamingXlsxWriter(path)
+    writer.add_image_sheet("10 - Chart", _PNG_1x1)
+    assert writer.sheet_count == 1
+    writer.close()
+
+    # The sheet exists (with a sanitized/unique name) and the PNG is embedded.
+    sheets = _read_workbook(path)
+    assert list(sheets.keys()) == ["10 - Chart"]
+    assert _read_media(path) == ["xl/media/image1.png"]
+
+
+def test_add_image_sheet_dedupes_and_composes_with_data_sheets(
+    tmp_path: Path,
+) -> None:
+    path = str(tmp_path / "out.xlsx")
+    writer = StreamingXlsxWriter(path)
+    writer.add_sheet("10 - Chart", ["a"], [[1]])
+    writer.add_image_sheet("10 - Chart", _PNG_1x1)
+    writer.close()
+
+    assert writer.sheet_count == 2
+    sheets = _read_workbook(path)
+    # The image sheet name is de-duplicated against the existing data sheet.
+    assert list(sheets.keys()) == ["10 - Chart", "10 - Chart~2"]
+    assert _read_media(path) == ["xl/media/image1.png"]