diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index abee6fe..472aaea 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -963,10 +963,10 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
# clip the bounded/truncated equivalent to the plot rectangle.
cmd.clip(px0, py0, plot["w"], plot["h"])
if show_main_legend:
- _emit_legend(cmd, named, plot, spec.get("legend") or {})
+ _emit_legend(cmd, named, plot, spec.get("legend") or {}, default_text)
for extra, items in extra_legends:
if items:
- _emit_legend(cmd, items, plot, extra)
+ _emit_legend(cmd, items, plot, extra, default_text)
if legend_present:
cmd.clip(0, 0, width, height)
if spec.get("colorbar"):
@@ -975,6 +975,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
spec["colorbar"],
plot,
_colorbar_right_axis_room(ya, extra_y_axes, compact),
+ default_text,
)
w_px, h_px = max(1, round(width * scale)), max(1, round(height * scale))
@@ -1659,6 +1660,7 @@ def _emit_legend(
named: list[dict[str, Any]],
plot: dict[str, float],
options: dict[str, Any],
+ text_color: str = _TEXT,
) -> None:
legend = _legend_layout(named, plot, options)
if not legend["visible_count"]:
@@ -1684,7 +1686,7 @@ def _emit_legend(
)
cmd.fill(_rect_pts(x, y, x + box_w, y + box_h), frame)
if title:
- cmd.text(x + pad, y + pad / 2 + 11, 0, 11, _parse_color(_TEXT), str(title))
+ cmd.text(x + pad, y + pad / 2 + 11, 0, 11, _parse_color(text_color), str(title))
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
color_str = _css(
@@ -1710,7 +1712,7 @@ def _emit_legend(
)
else:
cmd.fill(_rect_pts(hx0, cy - 4, hx1, cy + 4), c)
- cmd.text(hx1 + gap, ry + 11, 0, 11, _parse_color(_TEXT), legend["names"][i])
+ cmd.text(hx1 + gap, ry + 11, 0, 11, _parse_color(text_color), legend["names"][i])
def _emit_colorbar(
@@ -1718,6 +1720,7 @@ def _emit_colorbar(
options: dict[str, Any],
plot: dict[str, float],
right_axis_room: float = 0.0,
+ text_color: str = _TEXT,
) -> None:
from ._svg import _linear_ticks, _lut
@@ -1782,7 +1785,7 @@ def _emit_colorbar(
y + height + 13,
1,
10,
- _parse_color(_TEXT),
+ _parse_color(text_color),
f"{value:g}",
)
if options.get("label"):
@@ -1791,7 +1794,7 @@ def _emit_colorbar(
y + height + 26,
1,
10,
- _parse_color(_TEXT),
+ _parse_color(text_color),
str(options["label"]),
)
else:
@@ -1806,7 +1809,7 @@ def _emit_colorbar(
y + height * (1 - (value - lo) / span) + 4,
0,
10,
- _parse_color(_TEXT),
+ _parse_color(text_color),
f"{value:g}",
)
# The native text primitive does not rotate; a compact label above the
@@ -1817,7 +1820,7 @@ def _emit_colorbar(
y - 5,
0,
10,
- _parse_color(_TEXT),
+ _parse_color(text_color),
str(options["label"]),
)
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 13ddd5e..748343f 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -1523,17 +1523,18 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
append_axis_title(axis, is_x=False)
named = [t for t in spec["traces"] if t.get("name")]
if spec.get("show_legend", True) and named:
- chrome.append(_legend(named, plot, spec.get("legend") or {}, clip_id))
+ chrome.append(_legend(named, plot, spec.get("legend") or {}, clip_id, default_text))
for extra in spec.get("extra_legends") or []:
items = extra.get("items") or []
if items:
- chrome.append(_legend(items, plot, extra, clip_id))
+ chrome.append(_legend(items, plot, extra, clip_id, default_text))
if spec.get("colorbar"):
chrome.append(
_colorbar(
spec["colorbar"],
plot,
_colorbar_right_axis_room(ya, extra_y_axes, compact),
+ default_text,
)
)
@@ -2301,7 +2302,9 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An
}
-def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
+def _legend(
+ named: list[dict], plot: dict, options: dict, clip_id: str, text_color: str = _TEXT
+) -> str:
legend = _legend_layout(named, plot, options)
if not legend["visible_count"]:
# A plot too short for even one entry: no floating frame/title either.
@@ -2335,7 +2338,7 @@ def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
if title:
rows.append(
f'{escape(str(title))}'
+ f'font-weight="600" fill="{escape(text_color)}">{escape(str(title))}'
)
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
@@ -2381,12 +2384,14 @@ def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
)
rows.append(
f'{escape(legend["names"][i])}'
+ f'fill="{escape(text_color)}">{escape(legend["names"][i])}'
)
return f'{"".join(rows)}'
-def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
+def _colorbar(
+ options: dict, plot: dict, right_axis_room: float = 0.0, text_color: str = _TEXT
+) -> str:
cmap = str(options.get("colormap", "viridis"))
gradient_id = f"xy-colorbar-{sum(map(ord, cmap))}"
stops = _colormap_stops(cmap)
@@ -2412,11 +2417,11 @@ def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
label_node = (
f'{escape(label)}'
+ f'{_num(y + height / 2)})" fill="{escape(text_color)}">{escape(label)}'
if label and orientation != "horizontal"
else (
f'{escape(label)}'
+ f'text-anchor="middle" fill="{escape(text_color)}">{escape(label)}'
if label
else ""
)
@@ -2433,14 +2438,14 @@ def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
"".join(
f'{value:g}'
+ f'fill="{escape(text_color)}">{value:g}'
for value in tick_positions
)
if orientation != "horizontal"
else "".join(
f'{value:g}'
+ f'fill="{escape(text_color)}">{value:g}'
for value in tick_positions
)
)
diff --git a/tests/test_png_export.py b/tests/test_png_export.py
index b081b44..ea9fbbb 100644
--- a/tests/test_png_export.py
+++ b/tests/test_png_export.py
@@ -182,6 +182,28 @@ def test_png_is_screen_bounded_for_large_lines() -> None:
assert spec["traces"][0]["n_points"] == n # source size still recorded (ยง28)
+def test_raster_legend_text_honors_theme_text_color() -> None:
+ # Red is reserved for the theme text color; every other paint is green,
+ # and the tick labels are overridden, so red pixels can only come from
+ # the legend text.
+ chart = xy.line_chart(
+ xy.line(x=[0.0, 1.0], y=[0.0, 1.0], color="#00ff00", name="walk"),
+ xy.x_axis(style={"tick_label_color": "#00ff00"}),
+ xy.y_axis(style={"tick_label_color": "#00ff00"}),
+ xy.legend(loc="upper right"),
+ xy.theme(
+ text_color="#ff0000",
+ grid_color="transparent",
+ axis_color="#00ff00",
+ ),
+ width=300,
+ height=200,
+ )
+ img = _raster.render_raster(*chart.figure().build_payload(), scale=1)
+ red = (img[:, :, 0] > 150) & (img[:, :, 1] < 80) & (img[:, :, 2] < 80)
+ assert int(red.sum()) > 20
+
+
def test_render_is_non_blank_and_has_background() -> None:
fig = Figure(width=200, height=120).line([0.0, 1.0], [0.0, 1.0])
img = _raster.render_raster(*fig.build_payload(), scale=1)
diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py
index 97ca500..34d35a0 100644
--- a/tests/test_svg_export.py
+++ b/tests/test_svg_export.py
@@ -55,6 +55,30 @@ def test_every_chart_kind_exports_wellformed_svg() -> None:
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
+def test_svg_legend_text_honors_theme_text_color() -> None:
+ chart = xy.line_chart(
+ xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"),
+ xy.legend(loc="upper right", title="models"),
+ xy.theme(text_color="#ffffff"),
+ width=300,
+ height=200,
+ )
+ svg = chart.figure().to_svg()
+ assert re.search(r']*fill="#ffffff"[^>]*>walk', svg)
+ assert re.search(r']*fill="#ffffff"[^>]*>models', svg)
+
+ # Without a theme the legend keeps the light-mode default text color.
+ plain = xy.line_chart(
+ xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"),
+ xy.legend(loc="upper right"),
+ width=300,
+ height=200,
+ )
+ assert re.search(
+ r']*fill="rgba\(32,32,32,0\.85\)"[^>]*>walk', plain.figure().to_svg()
+ )
+
+
def test_svg_stays_screen_bounded_for_large_lines() -> None:
n = 2_000_000
y = np.cumsum(np.random.default_rng(1).normal(size=n))