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))