Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added pr-assets/mpl-annotation-box-text/christmas.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added pr-assets/mpl-annotation-box-text/labor-day.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 37 additions & 1 deletion python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import math
import struct
from collections.abc import Callable, Sequence
from os import PathLike
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 19 additions & 1 deletion python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<rect x="{_num(left)}" y="{_num(top)}" '
f'width="{_num(text_width + pad_x * 2)}" height="{_num(height)}" '
f'width="{_num(text_width + pad_x * 2)}" height="{_num(height)}"{radius_attr} '
f'fill="{fill}" stroke="{stroke}" stroke-width="{_num(stroke_width)}"/>'
]


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:
Expand Down
58 changes: 41 additions & 17 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `<rect fill>` | `FILL` polygon |
| `border` | CSS `border` (`"1px solid <color>"`) | `<rect stroke>` + `stroke-width` | `STROKE` polyline |
| `padding` | CSS `padding` | grows the rect | grows the polygon |
| `border_radius` | CSS `border-radius` | `<rect rx>` | 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=,
Expand Down
12 changes: 8 additions & 4 deletions spec/matplotlib/compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading