diff --git a/pr-assets/mpl-annotation-box-text/christmas.png b/pr-assets/mpl-annotation-box-text/christmas.png
new file mode 100644
index 00000000..a17ec0a5
Binary files /dev/null and b/pr-assets/mpl-annotation-box-text/christmas.png differ
diff --git a/pr-assets/mpl-annotation-box-text/labor-day.png b/pr-assets/mpl-annotation-box-text/labor-day.png
new file mode 100644
index 00000000..f7e05213
Binary files /dev/null and b/pr-assets/mpl-annotation-box-text/labor-day.png differ
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index cfd02581..b82ac7be 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -11,6 +11,7 @@
from __future__ import annotations
+import math
import struct
from collections.abc import Callable, Sequence
from os import PathLike
@@ -32,6 +33,7 @@
_axis_tick_font_size,
_axis_tick_label_layout,
_axis_tick_label_strategy,
+ _box_corner_radius,
_colorbar_right_axis_room,
_colormap_stops,
_column,
@@ -648,6 +650,35 @@ def _rect_pts(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, f
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
+def _round_rect_pts(
+ x0: float, y0: float, x1: float, y1: float, radius: float, *, steps: int = 4
+) -> list[tuple[float, float]]:
+ """A rounded rectangle as a closed polygon, corners arc-approximated.
+
+ The rasterizer draws polygons, not paths, so a `boxstyle="round"` bbox is
+ flattened here: `steps` segments per quarter turn is enough that a 5–8 px
+ corner reads as round at export scale. Degenerate radii fall back to the
+ square rect so callers never special-case it.
+ """
+ radius = max(0.0, min(radius, (x1 - x0) / 2.0, (y1 - y0) / 2.0))
+ if radius <= 0.0:
+ return _rect_pts(x0, y0, x1, y1)
+ pts: list[tuple[float, float]] = []
+ # (center, start angle) per corner, walking clockwise in screen space
+ # (y down) from the top-left so the winding matches _rect_pts.
+ corners = (
+ ((x0 + radius, y0 + radius), math.pi),
+ ((x1 - radius, y0 + radius), -math.pi / 2.0),
+ ((x1 - radius, y1 - radius), 0.0),
+ ((x0 + radius, y1 - radius), math.pi / 2.0),
+ )
+ for (cx, cy), start in corners:
+ for i in range(steps + 1):
+ angle = start + (math.pi / 2.0) * (i / steps)
+ pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
+ return pts
+
+
def _grad_line(
space: str,
direction: str,
@@ -1323,7 +1354,12 @@ def px(value: str) -> float:
top = first_y - font_size * 0.8 - pad_y
right = left + text_width + pad_x * 2
bottom = top + font_size + (len(lines) - 1) * line_height + pad_y * 2
- points = _rect_pts(left, top, right, bottom)
+ # `boxstyle="round"`/`round4` set border_radius, which the browser applies
+ # as CSS border-radius; round the same corners here or the exported box is
+ # square where the live one is not.
+ points = _round_rect_pts(
+ left, top, right, bottom, _box_corner_radius(style, right - left, bottom - top)
+ )
if background is not None:
cmd.fill(points, _parse_color(str(background)))
if border:
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 187cee80..4223901d 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -2093,13 +2093,31 @@ def px(value: str) -> float:
stroke_width = max(0.0, float(parts[0].removesuffix("px")))
except (IndexError, ValueError):
stroke_width = 1.0
+ # `boxstyle="round"`/`round4` set border_radius; the browser gets it as CSS
+ # border-radius, so the exporters have to round the same corners or an
+ # exported box is square where the live one is not.
+ radius = _box_corner_radius(style, text_width + pad_x * 2, height)
+ radius_attr = f' rx="{_num(radius)}"' if radius > 0 else ""
return [
f''
]
+def _box_corner_radius(style: dict[str, Any], width: float, height: float) -> float:
+ """`border_radius` in px, clamped to the box like CSS does.
+
+ Shared by the SVG and native raster text-box emitters so an exported
+ ``boxstyle="round"`` bbox is rounded exactly once, the same way.
+ """
+ try:
+ radius = float(str(style.get("border_radius", 0) or 0).removesuffix("px"))
+ except (TypeError, ValueError):
+ return 0.0
+ return max(0.0, min(radius, width / 2.0, height / 2.0))
+
+
def _segment_marks(
t: dict[str, Any], blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, color: str
) -> str:
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 1fd870d6..4ca85dbd 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -4440,6 +4440,21 @@ def _chart_children(self) -> list[Any]:
)
)
else:
+ # matplotlib paints Text with rcParams["text.color"]
+ # (black by default). The engine's own annotation-label
+ # fallback is a design token that differs per renderer
+ # (SVG #667085, native rgba(32,32,32,.85), browser
+ # --chart-annotation-text), so an uncoloured pyplot label
+ # renders grey in the exporters. Pin matplotlib's default
+ # here — the same thing the callout branch above already
+ # does — so all three renderers agree without moving the
+ # engine's non-pyplot defaults.
+ text_kw["style"] = {
+ "label_color": text_kw.get("color")
+ or resolve_color(rcParams.get("text.color", "black"))
+ or "black",
+ **(text_kw.get("style") or {}),
+ }
children.append(xy.text(x, y, *e["args"][2:], **text_kw))
return children
@@ -5009,29 +5024,38 @@ def _bbox_label_style(
A CSS approximation shared by the browser and static exporters.
"""
style: dict[str, Any] = {}
- face = bbox.get("fc", bbox.get("facecolor", "C0"))
alpha = bbox.get("alpha")
+
+ def with_alpha(resolved: str) -> str:
+ """Composite the patch ``alpha`` into one resolved colour.
+
+ Matplotlib's ``bbox`` ``alpha`` is the *patch* alpha, not a face
+ alpha: the SVG backend emits it as element ``opacity``, which dims
+ the face and the edge together (so ``alpha=0.1`` leaves the default
+ black edge effectively invisible). CSS paints ``background`` and
+ ``border`` separately, so each has to carry the alpha itself.
+ """
+ if alpha is None:
+ return resolved
+ from ._colors import _rgba_floats
+
+ try:
+ r, g, b, a = _rgba_floats(resolved)
+ except ValueError: # exotic CSS name: keep the colour, lose alpha
+ return resolved
+ return f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)},{float(alpha) * a:.3g})"
+
+ face = bbox.get("fc", bbox.get("facecolor", "C0"))
if face is not None and face != "none":
resolved = resolve_color(face)
if resolved is not None:
- if alpha is not None:
- from ._colors import _rgba_floats
-
- try:
- r, g, b, a = _rgba_floats(resolved)
- except ValueError: # exotic CSS name: keep the fill, lose alpha
- style["background"] = resolved
- else:
- style["background"] = (
- f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)},"
- f"{float(alpha) * a:.3g})"
- )
- else:
- style["background"] = resolved
+ style["background"] = with_alpha(resolved)
edge = bbox.get("ec", bbox.get("edgecolor", "black"))
if edge is not None and edge != "none":
- line_width = float(bbox.get("lw", bbox.get("linewidth", 1.0)))
- style["border"] = f"{line_width:g}px solid {resolve_color(edge)}"
+ resolved_edge = resolve_color(edge)
+ if resolved_edge is not None:
+ line_width = float(bbox.get("lw", bbox.get("linewidth", 1.0)))
+ style["border"] = f"{line_width:g}px solid {with_alpha(resolved_edge)}"
boxstyle = str(bbox.get("boxstyle", "square"))
name = boxstyle.split(",")[0].strip()
if "round" in name:
diff --git a/spec/api/styling.md b/spec/api/styling.md
index e9785377..668b9253 100644
--- a/spec/api/styling.md
+++ b/spec/api/styling.md
@@ -539,6 +539,34 @@ them through the annotation's own `color` / `stroke_color` / `stroke_width` /
`opacity` arguments. Only annotation **labels** are DOM (`annotation_label`)
and thus fully CSS-styleable.
+### Annotation label boxes
+
+A text/label/callout annotation may carry a boxed background through four
+style keys. The render client applies them as ordinary CSS on the label
+element (`border_radius` → `border-radius`, numbers gaining `px`); the SVG and
+native-PNG exporters reimplement the same four keys so an export matches the
+live label.
+
+| Key | Browser | SVG export | Native PNG export |
+| --- | --- | --- | --- |
+| `background` | CSS `background` | `` | `FILL` polygon |
+| `border` | CSS `border` (`"1px solid "`) | `` + `stroke-width` | `STROKE` polyline |
+| `padding` | CSS `padding` | grows the rect | grows the polygon |
+| `border_radius` | CSS `border-radius` | `` | polygon corners arc-flattened, 4 segments per quarter turn |
+
+Each renderer clamps `border_radius` to half the shorter box side, as CSS
+does, so an oversized radius degrades to a stadium rather than an inverted
+polygon. The exporters size the box from an estimated text width
+(`0.48em` per character), so a box tracks its text approximately, not exactly.
+
+**Label color** resolves as `label_color` → `color` → the renderer's own
+default, and the three defaults are *not* the same value: the browser uses
+`--chart-annotation-text` (falling back to `--chart-text`), the SVG exporter
+`#667085`, and the native rasterizer `rgba(32,32,32,.85)` (which composites to
+`rgb(65,65,65)` on white). A caller that needs one colour across all three must
+say so; `xy.pyplot` does, pinning `label_color` from
+`rcParams["text.color"]` on every text/annotate label.
+
## Static export
`fig.to_image(format="png", *, width=, height=, scale=2.0, background=,
diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md
index 75bb35d8..eb035bde 100644
--- a/spec/matplotlib/compat.md
+++ b/spec/matplotlib/compat.md
@@ -64,7 +64,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent
| `quiver`, `barbs`, `streamplot` | Native vector endpoint/arrowhead and bounded streamline kernels feeding one instanced segment mark. Barbs are a visual approximation: magnitude maps to a bounded tick count, not WMO 50/10/5 increments. Streamplot always uses the shim's own bounded fixed-step integrator (identical output with or without Matplotlib installed, but paths approximate Matplotlib's adaptive ones); `start_points`, `integration_direction`, array widths/colors and `num_arrows` are honored, and remaining non-default integration options fail loudly |
| `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust |
| `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels), including dtype-preserving value formats, radial label rotation/alignment, and common text properties |
-| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) |
+| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-\|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) in browser and static exports; its `alpha` is the *patch* alpha and dims face and edge together, as Matplotlib's element `opacity` does, and `boxstyle="round"`/`round4` corners are rounded in SVG (`rx`) and native PNG as well as in the browser. An arrow-less `text`/`annotate` label is painted with `rcParams["text.color"]` in all three renderers rather than each renderer's own annotation-label default. Text is unclipped like Matplotlib (`clip_on=False`), and axes-fraction text right of the axes box (x > 1, e.g. seaborn-style row titles) reserves right margin in every exporter. `rotation=90/270` renders vertical text in browser, PNG, and SVG with Matplotlib's rotate-then-align box semantics; other angles rotate in browser and SVG output only (native PNG draws them horizontally) |
| `from xy.pyplot import FacetGrid` (seaborn-shaped) | Row/column small multiples with seaborn's `map` contract (subset → activate panel → call the pyplot function), shared domains, edge-only axis labels, top-row column titles, and `margin_titles=True` rotated row titles. `hue=`/`palette=`, `col_wrap=`, `map_dataframe`, and `add_legend` fail loudly |
| `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG |
| `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` chooses the least occupied corner from bounded samples of the current data |
@@ -105,9 +105,13 @@ raise `NotImplementedError`, with these documented exceptions that are accepted
as visual approximations rather than rejected: the barbs glyph and imshow
smoothing collapse above, `annotate(arrowprops=...)` connection curves and
fancy/wedge outlines drawn as quadratic-curve tapered fills rather than
-Matplotlib's exact patch paths, `bbox=` boxes drawn only by the HTML label,
-and errorbar limit flags rendered as one-sided bars without Matplotlib's caret
-arrows.
+Matplotlib's exact patch paths, `bbox=` boxes sized from an estimated text
+width with a fixed corner radius per box style (5 px for `round`, 8 px for
+`round4`) rather than Matplotlib's `pad × fontsize` box path — measured
+against Matplotlib 3.11.1 at 10 pt, `round` is 4.17 px there against 5 px
+here — a `bbox` `alpha` combined with a CSS *named* edge color keeping a solid
+edge because named colors are not composited, and errorbar limit flags
+rendered as one-sided bars without Matplotlib's caret arrows.
## Sharp edges
diff --git a/tests/pyplot/test_annotation_box_text_fidelity.py b/tests/pyplot/test_annotation_box_text_fidelity.py
new file mode 100644
index 00000000..546ed529
--- /dev/null
+++ b/tests/pyplot/test_annotation_box_text_fidelity.py
@@ -0,0 +1,325 @@
+"""Annotation bbox/text fidelity against Matplotlib, on real emitted output.
+
+Measured against matplotlib 3.11.1 rendering cell 23 of
+``examples/pdsh/pdsh_04_09_text_and_annotation.ipynb`` with the real
+``examples/pdsh/data/births.csv``. The reference values quoted in the
+assertions come from that figure's own SVG/PNG, not from taste:
+
+- ``bbox=dict(boxstyle="round", alpha=0.1)`` → Matplotlib emits
+ ``style="fill: #1f77b4; opacity: 0.1; stroke: #000000"``. ``opacity`` is
+ *element* opacity, so face and edge are dimmed together.
+- an arrow-less ``annotate`` ("Labor Day Weekend") → Matplotlib paints it
+ with ``rcParams["text.color"]``, i.e. pure black; its PNG's darkest
+ glyph pixel is ``(0, 0, 0)``.
+- ``boxstyle="round"`` at 10 pt → a 3.00 pt (4.17 px at 96 dpi) corner
+ radius in Matplotlib's own path; ``round4`` curves rather than arcs.
+"""
+
+from __future__ import annotations
+
+import struct
+from typing import Any
+from xml.etree import ElementTree
+
+import pytest
+
+import xy.pyplot as plt
+from xy import _raster
+
+
+@pytest.fixture(autouse=True)
+def _clean():
+ plt.close("all")
+ yield
+ plt.close("all")
+
+
+def _annotation_rects(fig, ax) -> list[dict[str, str]]:
+ """Every stroked ```` in the emitted SVG, as raw attributes."""
+ svg = ax._build_chart(*fig._panel_px()).figure().to_svg()
+ root = ElementTree.fromstring(svg)
+ return [e.attrib for e in root.iter() if e.tag.endswith("rect") and "stroke" in e.attrib]
+
+
+def _annotation_texts(fig, ax) -> dict[str, dict[str, str]]:
+ """Emitted SVG ```` attributes, keyed by the rendered string."""
+ svg = ax._build_chart(*fig._panel_px()).figure().to_svg()
+ root = ElementTree.fromstring(svg)
+ out: dict[str, dict[str, str]] = {}
+ for e in root.iter():
+ if not e.tag.endswith("text"):
+ continue
+ text = "".join(e.itertext()).strip()
+ if text:
+ out[text] = e.attrib
+ return out
+
+
+def _first_fill(cmd: _raster._Cmd) -> tuple[int, list[tuple[float, float]], tuple[int, ...]]:
+ """Decode the first FILL command out of a raw native display list.
+
+ Layout (little-endian, must match ``src/raster.rs``): opcode byte,
+ u32 vertex count, 2 f32 per vertex, then 4 bytes of RGBA.
+ """
+ buf = bytes(cmd.buf)
+ assert buf[0] == _raster._FILL, f"expected a FILL opcode, got {buf[0]}"
+ (count,) = struct.unpack_from(" tuple[int, ...]:
+ """RGBA of the first STROKE command in a raw native display list."""
+ buf = bytes(cmd.buf)
+ offset = 0
+ while offset < len(buf):
+ if buf[offset] == _raster._STROKE:
+ (count,) = struct.unpack_from(" None:
+ """Matplotlib's patch ``alpha`` dims face *and* edge (element opacity)."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.annotate(
+ "Christmas",
+ xy=(0.9, 0.1),
+ xytext=(-30, 0),
+ textcoords="offset points",
+ ha="right",
+ va="center",
+ bbox=dict(boxstyle="round", alpha=0.1),
+ arrowprops=dict(arrowstyle="wedge,tail_width=0.5", alpha=0.1),
+ )
+
+ (rect,) = _annotation_rects(fig, ax)
+ # Face keeps the alpha it always had; the edge now carries it too, so the
+ # default black edge is as invisible as Matplotlib's.
+ assert rect["fill"] == "rgba(31,119,180,0.1)"
+ assert rect["stroke"] == "rgba(0,0,0,0.1)"
+
+
+def test_bbox_without_alpha_keeps_an_opaque_edge() -> None:
+ """No ``alpha`` means no compositing — Matplotlib draws a solid edge."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.annotate(
+ "Thanksgiving",
+ xy=(0.5, 0.5),
+ xytext=(-40, -30),
+ textcoords="offset points",
+ bbox=dict(boxstyle="round4,pad=.5", fc="0.9"),
+ arrowprops=dict(arrowstyle="->"),
+ )
+
+ (rect,) = _annotation_rects(fig, ax)
+ assert rect["fill"] == "rgb(230,230,230)" # fc="0.9"
+ assert rect["stroke"] == "black"
+
+
+def test_bbox_alpha_dims_an_explicit_edge_colour() -> None:
+ """``alpha`` applies to whatever ``ec`` resolves to, not just to black."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ # "0.4" is Matplotlib's grey shorthand, the form the pdsh corpus uses.
+ ax.text(0.5, 0.5, "boxed", bbox=dict(boxstyle="round", fc="none", ec="0.4", alpha=0.5))
+
+ (rect,) = _annotation_rects(fig, ax)
+ assert rect["fill"] == "none"
+ assert rect["stroke"] == "rgba(102,102,102,0.5)"
+
+
+def test_css_named_edge_colour_keeps_the_edge_but_loses_the_alpha() -> None:
+ """Documented degradation, unchanged from the face-alpha path.
+
+ ``resolve_color`` passes CSS colour *names* through verbatim and
+ ``_rgba_floats`` only parses hex/rgb/rgba plus eight basic names, so a
+ named edge cannot be composited. Keeping the edge is the safe outcome —
+ the alternative is dropping the border entirely. Matplotlib would dim
+ this edge to 50%; xy leaves it solid.
+ """
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.text(0.5, 0.5, "boxed", bbox=dict(boxstyle="round", fc="none", ec="gray", alpha=0.5))
+
+ (rect,) = _annotation_rects(fig, ax)
+ assert rect["stroke"] == "gray"
+
+
+def test_bbox_alpha_reaches_the_native_raster_stroke_colour() -> None:
+ """The native display list carries the same composited edge alpha."""
+ style: dict[str, Any] = {
+ "background": "rgba(31,119,180,0.1)",
+ "border": "1px solid rgba(0,0,0,0.1)",
+ "padding": "4px",
+ }
+ cmd = _raster._Cmd(1.0)
+ _raster._emit_text_box(cmd, style, ["Christmas"], 100.0, 100.0, 13.2, 11.0, 0)
+
+ # 0.1 alpha -> round(0.1 * 255) == 26 in the RGBA byte quad.
+ assert _first_stroke_rgba(cmd) == (0, 0, 0, 26)
+
+
+# --- D2: an arrow-less annotation is Matplotlib-black, not a design token ----
+
+
+def test_arrowless_annotation_text_is_matplotlib_black_in_svg() -> None:
+ """Matplotlib paints Text with rcParams["text.color"] (black)."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.annotate(
+ "Labor Day Weekend",
+ xy=(0.5, 0.9),
+ xycoords="data",
+ ha="center",
+ xytext=(0, -20),
+ textcoords="offset points",
+ )
+
+ texts = _annotation_texts(fig, ax)
+ # Previously "#667085", the SVG exporter's own annotation-label token.
+ assert texts["Labor Day Weekend"]["fill"] == "black"
+
+
+def test_arrowless_annotation_text_is_black_in_the_native_stream(monkeypatch) -> None:
+ """The native text command must carry opaque black, not a design token.
+
+ Before, this label reached the rasterizer as ``rgba(32,32,32,.85)`` —
+ the ``_TEXT`` token — which composites to (65, 65, 65) on white, while
+ Matplotlib's own PNG bottoms out at (0, 0, 0).
+ """
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.annotate(
+ "Labor Day Weekend",
+ xy=(0.5, 0.9),
+ xycoords="data",
+ ha="center",
+ xytext=(0, -20),
+ textcoords="offset points",
+ )
+
+ seen: list[tuple[str, tuple[int, ...]]] = []
+ original = _raster._Cmd.text
+
+ def spy(self, x, y, anchor, size, color, text, *args, **kwargs):
+ seen.append((str(text), tuple(color)))
+ return original(self, x, y, anchor, size, color, text, *args, **kwargs)
+
+ monkeypatch.setattr(_raster._Cmd, "text", spy)
+ fig._to_png()
+
+ labels = [color for text, color in seen if text == "Labor Day Weekend"]
+ assert labels, f"annotation label never reached the rasterizer; saw {seen}"
+ assert labels[0] == (0, 0, 0, 255)
+
+
+def test_explicit_text_colour_still_wins_over_the_matplotlib_default() -> None:
+ """``color=`` must not be overridden by the pinned default (pdsh cell 13)."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.text(0.5, 0.5, "Christmas ", ha="right", size=10, color="gray")
+
+ texts = _annotation_texts(fig, ax)
+ assert texts["Christmas"]["fill"] == "gray"
+
+
+# --- D3: an exported boxstyle="round" box has rounded corners ----------------
+
+
+def test_round_boxstyle_exports_a_non_zero_corner_radius_in_svg() -> None:
+ """``rx`` must be present and non-zero, as CSS border-radius already was."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.annotate(
+ "Independence Day",
+ xy=(0.5, 0.5),
+ bbox=dict(boxstyle="round", fc="none", ec="gray"),
+ xytext=(10, -40),
+ textcoords="offset points",
+ ha="center",
+ arrowprops=dict(arrowstyle="->"),
+ )
+
+ (rect,) = _annotation_rects(fig, ax)
+ assert float(rect["rx"]) > 0.0
+ # Matplotlib's own path radius for boxstyle="round" at 10 pt is 3.00 pt
+ # (4.17 px at 96 dpi); the shim's CSS approximation is 5 px.
+ assert float(rect["rx"]) == pytest.approx(5.0)
+
+
+def test_square_boxstyle_keeps_square_corners_in_svg() -> None:
+ """Only the rounded box styles round — ``square`` must emit no ``rx``."""
+ fig, ax = plt.subplots()
+ ax.plot([0.0, 1.0], [0.0, 1.0])
+ ax.text(0.5, 0.5, "plain", bbox=dict(boxstyle="square", fc="0.9"))
+
+ (rect,) = _annotation_rects(fig, ax)
+ assert "rx" not in rect
+
+
+def test_round_boxstyle_exports_rounded_corners_in_the_native_stream() -> None:
+ """The FILL polygon gains arc vertices instead of four square corners."""
+ style: dict[str, Any] = {
+ "background": "rgb(230,230,230)",
+ "border": "1px solid black",
+ "padding": "4px",
+ "border_radius": 8.0,
+ }
+ cmd = _raster._Cmd(1.0)
+ _raster._emit_text_box(cmd, style, ["Thanksgiving"], 100.0, 100.0, 13.2, 11.0, 0)
+ count, pts, _rgba = _first_fill(cmd)
+
+ # 4 corners x (4 arc steps + 1) vertices, versus 4 for a square rect.
+ assert count == 20
+ xs = [x for x, _ in pts]
+ ys = [y for _, y in pts]
+ # A rounded corner means no vertex sits at the rect's own corner.
+ assert (min(xs), min(ys)) not in pts
+
+
+def test_square_box_keeps_a_four_vertex_native_polygon() -> None:
+ """Without ``border_radius`` the native box is the plain rect it was."""
+ style: dict[str, Any] = {
+ "background": "rgb(230,230,230)",
+ "border": "1px solid black",
+ "padding": "4px",
+ }
+ cmd = _raster._Cmd(1.0)
+ _raster._emit_text_box(cmd, style, ["Thanksgiving"], 100.0, 100.0, 13.2, 11.0, 0)
+
+ count, _pts, _rgba = _first_fill(cmd)
+ assert count == 4
+
+
+def test_corner_radius_is_clamped_to_the_box_like_css() -> None:
+ """An absurd radius must not invert the polygon or escape the box."""
+ style: dict[str, Any] = {
+ "background": "rgb(230,230,230)",
+ "padding": "2px",
+ "border_radius": 500.0,
+ }
+ cmd = _raster._Cmd(1.0)
+ _raster._emit_text_box(cmd, style, ["x"], 100.0, 100.0, 13.2, 11.0, 0)
+ _count, pts, _rgba = _first_fill(cmd)
+
+ xs = [x for x, _ in pts]
+ ys = [y for _, y in pts]
+ width = max(xs) - min(xs)
+ height = max(ys) - min(ys)
+ assert width > 0 and height > 0
+ # Clamped to half the shorter side, so the polygon still spans the box.
+ assert height == pytest.approx(11.0 + 4.0, abs=1e-3)