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)