Skip to content

Commit 30cf0a5

Browse files
authored
feat: restore Altair SVG output as base64-encoded Data URLs (#9104)
## 📝 Summary This PR restores the behavior of outputting Altair SVG charts as base64-encoded Data URLs, following the improvements made to marimo's layout rendering. ## Background and Justification - **Layout Compatibility:** The issue where Altair SVG Data URLs failed to render within layout elements (like `mo.vstack`) reported in #9015 has been addressed in #9043. By wrapping SVG Data URLs in `<img>` tags within `mime_to_html`, they now render correctly across all layout components. - **Image Mark Limitation (#9013):** While using Data URLs for SVGs prevents external resources (such as `mark_image`) from loading due to browser security restrictions, this is consistent with the behavior in JupyterLab. It is a limitation of the SVG-as-image format itself rather than a bug in marimo's formatter. - **Consistency:** Converting SVG to Data URLs ensures that Altair's SVG output is handled consistently with other image-based formats (like PNG or JPEG). ## Changes - Updated `marimo/_output/formatters/altair_formatters.py` to encode `image/svg+xml` string responses into base64 Data URLs. - Updated `tests/_output/formatters/test_altair_formatters.py` to reflect the change in the expected output format. ## 📋 Pre-Review Checklist <!-- These checks need to be completed before a PR is reviewed --> - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [ ] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [ ] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made.
1 parent 37d2b69 commit 30cf0a5

3 files changed

Lines changed: 82 additions & 14 deletions

File tree

marimo/_output/formatters/altair_formatters.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import json
5+
import re
56
from typing import Any
67
from urllib.request import urlopen
78

@@ -80,6 +81,14 @@ def _show_chart(chart: AltairChartType) -> tuple[KnownMimeType, str]:
8081
data_url = io_to_data_url(mime_response, mime_type)
8182
return (mime_type, data_url or "")
8283
if isinstance(mime_response, str):
84+
if (
85+
mime_type == "image/svg+xml"
86+
and not altair.renderers.options.get("raw_svg")
87+
):
88+
_maybe_warn_external_resources(mime_response)
89+
svg_bytes = mime_response.encode()
90+
data_url = io_to_data_url(svg_bytes, mime_type)
91+
return (mime_type, data_url or "")
8392
return mime_type, mime_response
8493
return mime_type, json.dumps(mime_response)
8594

@@ -128,6 +137,25 @@ def _format_png_mimebundle(
128137
return "application/vnd.marimo+mimebundle", json.dumps(mimebundle)
129138

130139

140+
# Check if the SVG contains external resources that may not render
141+
# correctly when encoded as a Data URL.
142+
# https://github.com/marimo-team/marimo/pull/9104
143+
def _maybe_warn_external_resources(svg: str) -> None:
144+
# Strictly detecting external resource usage in SVG is difficult;
145+
# as a heuristic, we check for 'href' or 'xlink:href' attributes
146+
# that point to external resources (ignoring internal '#' or 'data:' URLs).
147+
if re.search(
148+
r'<[^>]*\b(?:xlink:)?href\s*=\s*["\'](?!\s*(?:#|data:))[^"\']+', svg
149+
):
150+
msg = (
151+
"This SVG contains external resources (href/xlink:href) "
152+
"that may not render correctly when encoded as a Data URL. "
153+
"If images are missing, try enabling raw SVG rendering with: "
154+
"altair.renderers.enable('svg', raw_svg=True)."
155+
)
156+
LOGGER.warning(msg)
157+
158+
131159
# This is only needed since it seems that altair does not
132160
# handle this internally.
133161
# https://github.com/marimo-team/marimo/issues/2302

marimo/_smoke_tests/altair_examples/altair_svg_rendering.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@
1212

1313
import marimo
1414

15-
__generated_with = "0.22.0"
15+
__generated_with = "0.23.1"
1616
app = marimo.App(width="medium")
1717

1818
with app.setup:
1919
import marimo as mo
2020
import altair as alt
2121
import pandas as pd
2222

23+
# Verify vl-convert-python is installed; it's required for the SVG renderer.
24+
import vl_convert as vlc
25+
2326

2427
@app.cell
2528
def _():
@@ -29,7 +32,6 @@ def _():
2932

3033
@app.cell
3134
def _(data):
32-
# Issue #9015: SVG charts in layouts render raw base64 string
3335
alt.renderers.enable("svg")
3436

3537
chart = alt.Chart(data).mark_point().encode(x="x", y="y")
@@ -45,8 +47,10 @@ def _(chart):
4547

4648
@app.cell
4749
def _(chart):
48-
# This renders the raw base64-encoded string (issue #9015)
49-
mo.vstack([chart])
50+
# SVG outputs should be correctly rendered in vstack or hstack
51+
# (reported in Issue #9015 and fixed in PR #9043).
52+
# For vstack, align="start" is needed to preserve the image size
53+
mo.vstack([chart], align="start")
5054
return
5155

5256

@@ -78,17 +82,20 @@ def _(chart_with_images):
7882

7983
@app.cell
8084
def _(chart_with_images):
81-
# Image marks should not be broken
85+
# Image marks are broken.
86+
# The root cause is the browser's security restriction.
87+
# When marimo detects an SVG with external resources (e.g., image URLs),
88+
# it warns the user to enable 'raw_svg=True' for correct rendering.
8289
alt.renderers.enable("svg")
8390
chart_with_images
8491
return
8592

8693

8794
@app.cell
8895
def _(chart_with_images):
89-
# Image marks should not be broken
90-
alt.renderers.enable("svg")
91-
mo.hstack([chart_with_images])
96+
# Image marks are correctly rendered when 'raw_svg=True' is enabled.
97+
alt.renderers.enable("svg", raw_svg=True)
98+
chart_with_images
9299
return
93100

94101

tests/_output/formatters/test_altair_formatters.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
FORMAT_LOCALE_URL,
1414
TIME_FORMAT_LOCALE_URL,
1515
AltairFormatter,
16+
_maybe_warn_external_resources,
1617
)
1718
from marimo._output.formatters.formatters import register_formatters
1819
from marimo._output.formatting import get_formatter
@@ -157,24 +158,34 @@ def test_altair_formatter_mimebundle():
157158

158159

159160
@pytest.mark.skipif(not HAS_DEPS, reason="altair not installed")
160-
def test_altair_formatter_svg():
161+
@pytest.mark.parametrize(
162+
("raw_svg", "expected"),
163+
[
164+
(True, "<svg></svg>"),
165+
(False, "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="),
166+
],
167+
)
168+
def test_altair_formatter_svg(raw_svg: bool, expected: str):
161169
AltairFormatter().register()
162170

163171
import altair as alt
164172

165173
# Create a mock chart with a _repr_mimebundle_ method that returns SVG
166174
mock_chart = alt.Chart(get_data()).mark_point()
167-
with patch.object(
168-
alt.Chart,
169-
"_repr_mimebundle_",
170-
return_value={"image/svg+xml": "<svg></svg>"},
175+
with (
176+
patch.dict(alt.renderers.options, {"raw_svg": raw_svg}),
177+
patch.object(
178+
alt.Chart,
179+
"_repr_mimebundle_",
180+
return_value={"image/svg+xml": "<svg></svg>"},
181+
),
171182
):
172183
formatter = get_formatter(mock_chart)
173184
assert formatter is not None
174185
mime, content = formatter(mock_chart)
175186

176187
assert mime == "image/svg+xml"
177-
assert content == "<svg></svg>"
188+
assert content == expected
178189

179190

180191
@pytest.mark.skipif(not HAS_DEPS, reason="altair not installed")
@@ -286,3 +297,25 @@ def get_formatted_content(chart):
286297
alt.renderers.set_embed_options()
287298
content = get_formatted_content(get_chart())
288299
assert content["usermeta"]["embedOptions"] == {}
300+
301+
302+
@pytest.mark.parametrize(
303+
("content", "expected"),
304+
[
305+
('<image xlink:href="https://ffox.png"/>', True),
306+
('<image href="#id"/>', False),
307+
('<image href="data:image/png;base64,xxx"/>', False),
308+
('<image href=" https://ffox.png"/>', True),
309+
('<image href=" #id"/>', False),
310+
],
311+
)
312+
def test_maybe_warn_external_resource(content: str, expected: bool) -> None:
313+
with patch(
314+
"marimo._output.formatters.altair_formatters.LOGGER.warning"
315+
) as mock_warning:
316+
_maybe_warn_external_resources(content)
317+
if expected:
318+
mock_warning.assert_called_once()
319+
assert "raw_svg=True" in mock_warning.call_args[0][0]
320+
else:
321+
mock_warning.assert_not_called()

0 commit comments

Comments
 (0)