From 2406112ea0abc3b3f9a7f019ea728894868a99f8 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:38:14 +0500 Subject: [PATCH] Fix static-export legend and colorbar text ignoring theme text_color The SVG and native-PNG exporters painted legend entries, legend titles, and colorbar labels/ticks with the hard-coded light-mode text color (rgba(32,32,32,0.85)), so on dark-themed figures the legend text was nearly invisible in exports while the browser client rendered it with var(--chart-text). Thread the theme-resolved default text color (already used for titles, axis labels, and tick labels) through _legend/_colorbar in _svg.py and _emit_legend/_emit_colorbar in _raster.py. Untouched default: figures without a theme keep the existing light-mode color. --- python/xy/_raster.py | 21 +++++++++++---------- python/xy/_svg.py | 25 +++++++++++++++---------- tests/test_png_export.py | 22 ++++++++++++++++++++++ tests/test_svg_export.py | 24 ++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index b7b82e3..dd9ff6d 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -855,10 +855,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"): @@ -867,6 +867,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)) @@ -1421,7 +1422,7 @@ def _emit_grid(cmd, kind, g, blob, cols, sx, sy, style): _LEGEND_LINE_KINDS = frozenset({"line", "segments", "step", "stairs", "errorbar"}) -def _emit_legend(cmd, named, plot, options): +def _emit_legend(cmd, named, plot, options, text_color=_TEXT): legend = _legend_layout(named, plot, options) if not legend["visible_count"]: # A plot too short for even one entry: no floating frame/title either. @@ -1446,7 +1447,7 @@ def _emit_legend(cmd, named, plot, options): ) 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( @@ -1472,10 +1473,10 @@ def _emit_legend(cmd, named, plot, options): ) 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(cmd, options, plot, right_axis_room=0.0): +def _emit_colorbar(cmd, options, plot, right_axis_room=0.0, text_color=_TEXT): from ._svg import _linear_ticks, _lut orientation = options.get("orientation", "vertical") @@ -1539,7 +1540,7 @@ def _emit_colorbar(cmd, options, plot, right_axis_room=0.0): y + height + 13, 1, 10, - _parse_color(_TEXT), + _parse_color(text_color), f"{value:g}", ) if options.get("label"): @@ -1548,7 +1549,7 @@ def _emit_colorbar(cmd, options, plot, right_axis_room=0.0): y + height + 26, 1, 10, - _parse_color(_TEXT), + _parse_color(text_color), str(options["label"]), ) else: @@ -1563,7 +1564,7 @@ def _emit_colorbar(cmd, options, plot, right_axis_room=0.0): 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 @@ -1574,7 +1575,7 @@ def _emit_colorbar(cmd, options, plot, right_axis_room=0.0): 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 febb860..b4d3fef 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1522,17 +1522,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, ) ) @@ -2293,7 +2294,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. @@ -2327,7 +2330,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 {} @@ -2373,12 +2376,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) @@ -2404,11 +2409,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 "" ) @@ -2425,14 +2430,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))