diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts
index 88303f83..c3920c91 100644
--- a/js/src/51_annotations.ts
+++ b/js/src/51_annotations.ts
@@ -383,7 +383,7 @@ Object.assign(ChartView.prototype, {
const laidOut = [];
this._resolvedAnnotationAnchors = new Map();
for (const [annotationIndex, ann] of annotations.entries()) {
- const text = typeof ann.text === "string" ? ann.text : "";
+ const text: string = typeof ann.text === "string" ? ann.text : "";
if (!text) continue;
const style = ann && typeof ann.style === "object" ? ann.style : {};
let px = null;
@@ -448,7 +448,24 @@ Object.assign(ChartView.prototype, {
continue;
}
const d = document.createElement("div");
- d.textContent = text;
+ const mathRanges = String(style.math_italic_ranges || "")
+ .split(",")
+ .map((range) => range.split(":").map(Number))
+ .filter(([start, end]) => Number.isInteger(start) && Number.isInteger(end) && start >= 0 && start < end);
+ if (mathRanges.length) {
+ for (const [index, char] of Array.from(text).entries()) {
+ if (mathRanges.some(([start, end]) => start <= index && index < end)) {
+ const italic = document.createElement("span");
+ italic.style.fontStyle = "italic";
+ italic.textContent = char;
+ d.appendChild(italic);
+ } else {
+ d.appendChild(document.createTextNode(char));
+ }
+ }
+ } else {
+ d.textContent = text;
+ }
const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0;
const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0;
// Rule/band specs carry no anchor; default to the placement that keeps
@@ -520,6 +537,7 @@ Object.assign(ChartView.prototype, {
const opacityIsShape = ann.kind !== "text" && ann.kind !== "callout";
const labelStyle = {};
for (const [key, value] of Object.entries(style)) {
+ if (key === "math_italic_ranges") continue;
if (key === "opacity" && ann.kind === "text") {
labelStyle[key] = value;
continue;
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index 2514840a..cfd02581 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -74,12 +74,15 @@
_AFFINE_POINTS,
_AFFINE_CHANNEL_POINTS,
_STROKED_TRIANGLES,
-) = range(17)
+ _STYLED_TEXT,
+) = range(18)
# Anchor-byte rotation flags — must match TEXT_ROTATED/TEXT_ROTATED_CW in
# src/raster.rs. CCW reads bottom-to-top (y-axis titles), CW top-to-bottom
# (right-margin titles, matplotlib rotation=270).
_TEXT_ROT_CCW = 0x80
_TEXT_ROT_CW = 0x40
+_TEXT_ITALIC = 0x01
+_TEXT_BOLD = 0x02
_SYMBOLS = {
"circle": 0,
"square": 1,
@@ -601,9 +604,36 @@ def heatmap_image(
self.buf += stops.tobytes()
def text(
- self, x: float, y: float, anchor: int, size: float, color: tuple[int, ...], s: str
+ self,
+ x: float,
+ y: float,
+ anchor: int,
+ size: float,
+ color: tuple[int, ...],
+ s: str,
+ *,
+ angle: float = 0.0,
+ italic: bool = False,
+ bold: bool = False,
+ italic_ranges: Sequence[tuple[int, int]] = (),
) -> None:
data = str(s).encode("utf-8")
+ if angle or italic or bold or italic_ranges:
+ self.buf.append(_STYLED_TEXT)
+ self._f(x)
+ self._f(y)
+ self.buf.append(anchor & 0x03)
+ self._f(size)
+ self._raw_f(angle)
+ self.buf.append((_TEXT_ITALIC if italic else 0) | (_TEXT_BOLD if bold else 0))
+ self._u32(len(italic_ranges))
+ for start, end in italic_ranges:
+ self._u32(start)
+ self._u32(end)
+ self._rgba(color)
+ self._u32(len(data))
+ self.buf += data
+ return
self.buf.append(_TEXT_OP)
self._f(x)
self._f(y)
@@ -1077,6 +1107,29 @@ def _annotation_point(
return float(sx(x)), float(sy(y))
+def _native_font_emphasis(style: dict[str, Any]) -> tuple[bool, bool]:
+ """Return baked-font italic/bold approximations for an annotation."""
+ italic = str(style.get("font_style", "")).lower() in {"italic", "oblique"}
+ weight = str(style.get("font_weight", "")).lower()
+ try:
+ bold = float(weight) >= 600
+ except ValueError:
+ bold = weight in {"bold", "semibold", "demibold", "heavy", "black"}
+ return italic, bold
+
+
+def _math_italic_ranges(style: dict[str, Any]) -> list[tuple[int, int]]:
+ ranges: list[tuple[int, int]] = []
+ for item in str(style.get("math_italic_ranges", "")).split(","):
+ try:
+ start, end = (int(value) for value in item.split(":", 1))
+ except ValueError:
+ continue
+ if 0 <= start < end:
+ ranges.append((start, end))
+ return ranges
+
+
def _emit_annotations(
cmd: _Cmd,
annotations: list[dict[str, Any]],
@@ -1171,6 +1224,9 @@ def _emit_annotations(
)
color = _rgba(label_color, _TEXT, float(label_opacity))
rotation = float(style.get("rotation", 0.0)) % 360.0
+ italic, bold = _native_font_emphasis(style)
+ math_ranges = _math_italic_ranges(style)
+ line_offset = 0
if rotation in (90.0, 270.0):
# Vertical text via the rasterizer's rotated glyph paths.
# matplotlib aligns the post-rotation box: vertical_align picks
@@ -1188,14 +1244,24 @@ def _emit_annotations(
base = {0: ascent, 1: (ascent - descent) / 2, 2: -descent}[anchor]
stack = -line_height if cw else line_height # later lines: glyph-down
for index, line in enumerate(lines):
+ line_ranges = [
+ (max(0, start - line_offset), min(len(line), end - line_offset))
+ for start, end in math_ranges
+ if start < line_offset + len(line) and end > line_offset
+ ]
cmd.text(
x + base + index * stack,
y,
- along | (_TEXT_ROT_CW if cw else _TEXT_ROT_CCW),
+ along,
font_size,
color,
line,
+ angle=90.0 if cw else -90.0,
+ italic=italic,
+ bold=bold,
+ italic_ranges=line_ranges,
)
+ line_offset += len(line) + 1
continue
first_y = y - (len(lines) - 1) * line_height / 2
vertical_align = style.get("vertical_align")
@@ -1203,15 +1269,71 @@ def _emit_annotations(
first_y += font_size * 0.35
elif vertical_align == "top":
first_y += font_size * 0.8
+ text_x = x + float(ann.get("dx", 0.0))
+ text_y = first_y + float(ann.get("dy", 0.0))
+ _emit_text_box(cmd, style, lines, text_x, text_y, line_height, font_size, anchor)
for index, line in enumerate(lines):
+ line_ranges = [
+ (max(0, start - line_offset), min(len(line), end - line_offset))
+ for start, end in math_ranges
+ if start < line_offset + len(line) and end > line_offset
+ ]
cmd.text(
- x + float(ann.get("dx", 0.0)),
- first_y + index * line_height + float(ann.get("dy", 0.0)),
+ text_x,
+ text_y + index * line_height,
anchor,
font_size,
color,
line,
+ angle=-rotation,
+ italic=italic,
+ bold=bold,
+ italic_ranges=line_ranges,
)
+ line_offset += len(line) + 1
+
+
+def _emit_text_box(
+ cmd: _Cmd,
+ style: dict[str, Any],
+ lines: list[str],
+ x: float,
+ first_y: float,
+ line_height: float,
+ font_size: float,
+ anchor: int,
+) -> None:
+ """Draw the bounded CSS approximation used by pyplot ``text(bbox=)``."""
+ background = style.get("background")
+ border = str(style.get("border", ""))
+ if background is None and not border:
+ return
+ pad_parts = str(style.get("padding", "0")).split()
+
+ def px(value: str) -> float:
+ try:
+ return max(0.0, float(value.removesuffix("px")))
+ except ValueError:
+ return 0.0
+
+ pad_y = px(pad_parts[0]) if pad_parts else 0.0
+ pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y
+ text_width = max((len(line) for line in lines), default=0) * font_size * 0.48
+ left = x - (text_width / 2 if anchor == 1 else text_width if anchor == 2 else 0.0) - pad_x
+ 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)
+ if background is not None:
+ cmd.fill(points, _parse_color(str(background)))
+ if border:
+ parts = border.split()
+ try:
+ width = max(0.0, float(parts[0].removesuffix("px")))
+ except (IndexError, ValueError):
+ width = 1.0
+ if width:
+ cmd.stroke(points + [points[0]], width, _parse_color(parts[-1]))
def _emit_area(
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 3f235e58..187cee80 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -1960,15 +1960,18 @@ def _annotation_svg(
style.get("opacity", 1.0) if kind == "text" else 1.0,
)
)
+ line_offset = 0
for index, line in enumerate(lines):
bx = tx + float(ann.get("dx", 0)) + base + index * stack
+ styled_line = _svg_mathtext_spans(line, style, line_offset)
labels.append(
f'{escape(line)}'
+ + f'fill="{color}">{styled_line}'
)
+ line_offset += len(line) + 1
continue
x_text = tx + float(ann.get("dx", 0))
y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2
@@ -1977,11 +1980,16 @@ def _annotation_svg(
y_text += font_size * 0.35
elif vertical_align == "top":
y_text += font_size * 0.8
- tspans = "".join(
- f''
- f"{escape(line)}"
- for index, line in enumerate(lines)
- )
+ line_offset = 0
+ tspan_parts = []
+ for index, line in enumerate(lines):
+ styled_line = _svg_mathtext_spans(line, style, line_offset)
+ tspan_parts.append(
+ f''
+ f"{styled_line}"
+ )
+ line_offset += len(line) + 1
+ tspans = "".join(tspan_parts)
text_opacity = float(
style.get(
"label_opacity",
@@ -1990,14 +1998,108 @@ def _annotation_svg(
)
# A callout's `color` paints its arrow; the label prefers its own.
label_color = escape(_css(style.get("label_color"), "")) or color
+ labels.extend(
+ _svg_text_box(style, lines, x_text, y_text, line_height, font_size, anchor)
+ )
+ font_attrs = _svg_font_attrs(style)
+ rotation_attr = (
+ f' transform="rotate({_num(-rotation)} {_num(x_text)} {_num(y_text)})"'
+ if rotation
+ else ""
+ )
labels.append(
- f'{tspans}'
)
return marks, labels
+def _svg_font_attrs(style: dict[str, Any]) -> str:
+ attrs = []
+ for key, attribute in (
+ ("font_family", "font-family"),
+ ("font_weight", "font-weight"),
+ ("font_style", "font-style"),
+ ):
+ if style.get(key) is not None:
+ attrs.append(f' {attribute}="{escape(str(style[key]))}"')
+ return "".join(attrs)
+
+
+def _svg_mathtext_spans(line: str, style: dict[str, Any], offset: int) -> str:
+ ranges: list[tuple[int, int]] = []
+ for item in str(style.get("math_italic_ranges", "")).split(","):
+ try:
+ start, end = (int(value) for value in item.split(":", 1))
+ except ValueError:
+ continue
+ start, end = max(0, start - offset), min(len(line), end - offset)
+ if start < end:
+ ranges.append((start, end))
+ if not ranges:
+ return escape(line)
+ out: list[str] = []
+ cursor = 0
+ for start, end in ranges:
+ if cursor < start:
+ out.append(escape(line[cursor:start]))
+ out.append(f'{escape(line[start:end])}')
+ cursor = end
+ out.append(escape(line[cursor:]))
+ return "".join(out)
+
+
+def _svg_text_box(
+ style: dict[str, Any],
+ lines: list[str],
+ x: float,
+ first_y: float,
+ line_height: float,
+ font_size: float,
+ anchor: str,
+) -> list[str]:
+ """SVG counterpart of the pyplot text-bbox CSS approximation."""
+ background = style.get("background")
+ border = str(style.get("border", ""))
+ if background is None and not border:
+ return []
+ pad_parts = str(style.get("padding", "0")).split()
+
+ def px(value: str) -> float:
+ try:
+ return max(0.0, float(value.removesuffix("px")))
+ except ValueError:
+ return 0.0
+
+ pad_y = px(pad_parts[0]) if pad_parts else 0.0
+ pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y
+ text_width = max((len(line) for line in lines), default=0) * font_size * 0.48
+ left = (
+ x
+ - (text_width / 2 if anchor == "middle" else text_width if anchor == "end" else 0.0)
+ - pad_x
+ )
+ top = first_y - font_size * 0.8 - pad_y
+ height = font_size + (len(lines) - 1) * line_height + pad_y * 2
+ fill = "none" if background is None else escape(str(background))
+ stroke = "none"
+ stroke_width = 0.0
+ if border:
+ parts = border.split()
+ stroke = escape(parts[-1])
+ try:
+ stroke_width = max(0.0, float(parts[0].removesuffix("px")))
+ except (IndexError, ValueError):
+ stroke_width = 1.0
+ return [
+ f''
+ ]
+
+
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/_artists.py b/python/xy/pyplot/_artists.py
index cbbb5e6f..c7044ee4 100644
--- a/python/xy/pyplot/_artists.py
+++ b/python/xy/pyplot/_artists.py
@@ -976,14 +976,28 @@ def __init__(
import numpy as np
self.wedges = wedges
- self.values = np.asarray(values, dtype=np.float64)
- total = float(np.sum(self.values)) if normalize else 1.0
- self.fracs = self.values / total
+ # Matplotlib keeps the input dtype here. In particular, integer pie
+ # data must remain integers so pie_label("{absval:d}") is valid.
+ self._values = np.asarray(values).copy()
self.normalize = bool(normalize)
self._texts: list[list[Text]] = [texts]
if autotexts:
self._texts.append(autotexts)
+ @property
+ def values(self) -> Any:
+ result = self._values.copy()
+ result.flags.writeable = False
+ return result
+
+ @property
+ def fracs(self) -> Any:
+ import numpy as np
+
+ result = self._values / np.sum(self._values) if self.normalize else self._values.copy()
+ result.flags.writeable = False
+ return result
+
@property
def texts(self) -> list[list["Text"]]:
return self._texts
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 3b04d605..1fd870d6 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -14,7 +14,7 @@
# Runtime imports, not TYPE_CHECKING: `typing.get_type_hints()` on the public
# Axes methods must resolve these annotation names (all stdlib or xy-local).
-from collections.abc import Callable, Iterator, Sequence
+from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import datetime, timedelta
from itertools import pairwise
@@ -2277,22 +2277,33 @@ def text(
transform = kwargs.pop("transform", None)
fontweight = kwargs.pop("fontweight", kwargs.pop("weight", None))
fontfamily = kwargs.pop("fontfamily", kwargs.pop("family", None))
+ fontstyle = kwargs.pop("fontstyle", kwargs.pop("style", None))
rotation = kwargs.pop("rotation", None)
+ bbox = kwargs.pop("bbox", None)
check_unsupported(kwargs, "text()")
akw = {"color": resolve_color(color)} if color is not None else {}
+ if bbox is not None:
+ if not isinstance(bbox, Mapping):
+ raise TypeError("text() bbox must be a mapping or None")
+ akw["bbox"] = dict(bbox)
if ha is not None:
akw["anchor"] = {"left": "start", "center": "middle", "right": "end"}.get(
str(ha), "start"
)
style: dict[str, Any] = {}
if fontsize is not None:
- style["font_size"] = float(fontsize)
+ style["font_size"] = _font_size_points(fontsize, rcParams["font.size"])
if va is not None:
style["vertical_align"] = str(va)
if fontweight is not None:
style["font_weight"] = str(fontweight)
if fontfamily is not None:
style["font_family"] = str(fontfamily)
+ if fontstyle is not None:
+ fontstyle = str(fontstyle)
+ if fontstyle not in {"normal", "italic", "oblique"}:
+ raise ValueError("text() style must be 'normal', 'italic', or 'oblique'")
+ style["font_style"] = fontstyle
if rotation is not None:
style["rotation"] = 90.0 if rotation == "vertical" else float(rotation)
if transform is self.transAxes or transform == "axes fraction":
@@ -2301,7 +2312,15 @@ def text(
style["coordinate_space"] = "figure_fraction"
if style:
akw["style"] = style
- return Text(self, self._add("@text", {"args": (x, y, _plain_text(s)), "kwargs": akw}))
+ plain_text, math_italic_ranges = _plain_text_with_math_italic_ranges(s)
+ if math_italic_ranges:
+ akw["style"] = {
+ **(akw.get("style") or {}),
+ "math_italic_ranges": ",".join(
+ f"{start}:{end}" for start, end in math_italic_ranges
+ ),
+ }
+ return Text(self, self._add("@text", {"args": (x, y, plain_text), "kwargs": akw}))
def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwargs: Any) -> Text:
"""Annotate the point ``xy`` with text, optionally offset at ``xytext``.
@@ -4358,6 +4377,7 @@ def _chart_children(self) -> list[Any]:
**_bbox_label_style(
kw["bbox"],
font_size=float((text_kw.get("style") or {}).get("font_size", 11.0)),
+ point_scale=self._point_scale(),
),
**(text_kw.get("style") or {}),
}
@@ -4946,7 +4966,9 @@ def _arrow_visuals(
)
style: dict[str, Any] = {}
if fancy:
- style["head_size"] = float(arrowprops.get("headwidth", 12.0))
+ # Matplotlib's legacy YAArrow default has a substantially broader head
+ # than the thin ``->`` FancyArrowPatch style.
+ style["head_size"] = float(arrowprops.get("headwidth", 20.0))
else:
name = str(arrowstyle).split(",")[0].strip()
tail, head = _ARROWSTYLE_ENDS.get(name, ("none", "triangle"))
@@ -4977,11 +4999,14 @@ def _arrow_visuals(
return color, width, style
-def _bbox_label_style(bbox: dict[str, Any], font_size: float = 11.0) -> dict[str, Any]:
+def _bbox_label_style(
+ bbox: dict[str, Any],
+ font_size: float = 11.0,
+ point_scale: float = 1.0,
+) -> dict[str, Any]:
"""matplotlib text ``bbox`` patch → annotation-label box styles.
- A CSS approximation drawn by the render client's DOM label; the static
- exporters keep the plain label (recorded in spec/matplotlib/compat.md).
+ A CSS approximation shared by the browser and static exporters.
"""
style: dict[str, Any] = {}
face = bbox.get("fc", bbox.get("facecolor", "C0"))
@@ -5011,9 +5036,13 @@ def _bbox_label_style(bbox: dict[str, Any], font_size: float = 11.0) -> dict[str
name = boxstyle.split(",")[0].strip()
if "round" in name:
style["border_radius"] = 8.0 if name == "round4" else 5.0
- # matplotlib pads the patch pad×fontsize around the text.
- pad = max(0.0, _parse_style_options(boxstyle).get("pad", 0.3)) * float(font_size)
- style["padding"] = f"{pad:.3g}px {pad * 1.3:.3g}px"
+ # With an explicit boxstyle, Matplotlib's pad is a fraction of the font
+ # size. Without one, top-level ``pad`` is in points (default 4 pt).
+ if "boxstyle" in bbox:
+ pad = max(0.0, _parse_style_options(boxstyle).get("pad", 0.3)) * float(font_size)
+ else:
+ pad = max(0.0, float(bbox.get("pad", 4.0))) * float(point_scale)
+ style["padding"] = f"{pad:.3g}px"
return style
@@ -5226,6 +5255,43 @@ def _plain_text(value: Any) -> str:
return text.replace("_{", "").replace("^{", "^").replace("}", "")
+def _plain_text_with_math_italic_ranges(value: Any) -> tuple[str, list[tuple[int, int]]]:
+ """Flatten simple mathtext while retaining its default italic spans."""
+ source = str(value)
+ parts: list[str] = []
+ ranges: list[tuple[int, int]] = []
+ cursor = 0
+ output_length = 0
+ while cursor < len(source):
+ start = source.find("$", cursor)
+ if start < 0:
+ tail = _plain_text(source[cursor:])
+ parts.append(tail)
+ break
+ prefix = _plain_text(source[cursor:start])
+ parts.append(prefix)
+ output_length += len(prefix)
+ end = source.find("$", start + 1)
+ if end < 0:
+ tail = _plain_text(source[start:])
+ parts.append(tail)
+ break
+ math = _plain_text(source[start : end + 1])
+ parts.append(math)
+ range_start: Optional[int] = None
+ for index, character in enumerate(math):
+ if character.isalpha() and range_start is None:
+ range_start = output_length + index
+ elif not character.isalpha() and range_start is not None:
+ ranges.append((range_start, output_length + index))
+ range_start = None
+ if range_start is not None:
+ ranges.append((range_start, output_length + len(math)))
+ output_length += len(math)
+ cursor = end + 1
+ return "".join(parts), ranges
+
+
def _masked_float(value: Any) -> np.ndarray:
return np.ma.asarray(value, dtype=np.float64).filled(np.nan)
diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py
index c60c8d3c..2f439af8 100644
--- a/python/xy/pyplot/_grid.py
+++ b/python/xy/pyplot/_grid.py
@@ -281,6 +281,14 @@ def stitch_png(
src_y0 : src_y0 + dest_y1 - dest_y0,
src_x0 : src_x0 + dest_x1 - dest_x0,
]
+ if suptitle:
+ _blend_raster_suptitle(
+ canvas,
+ suptitle,
+ suptitle_style,
+ scale=scale,
+ title_h=min(48, canvas.shape[0]),
+ )
return _png.encode(canvas)
col_widths = [
@@ -305,23 +313,13 @@ def stitch_png(
x = sum(col_widths[:c])
canvas[y : y + tile.shape[0], x : x + tile.shape[1]] = tile
if suptitle:
- from xy import kernels
-
- cmd = _raster._Cmd(scale)
- style = suptitle_style or {}
- cmd.text(
- canvas.shape[1] * float(style.get("x", 0.5)) / scale,
- 17,
- 1,
- float(style.get("size", 14)),
- _raster._parse_color(str(style.get("color", "#262626"))),
+ _blend_raster_suptitle(
+ canvas,
suptitle,
+ suptitle_style,
+ scale=scale,
+ title_h=title_h,
)
- overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h)
- alpha = overlay[:, :, 3:4].astype(np.float64) / 255.0
- canvas[:title_h, :, :3] = np.round(
- overlay[:, :, :3] * alpha + canvas[:title_h, :, :3] * (1.0 - alpha)
- ).astype(np.uint8)
if colorbar:
from xy._svg import _lut
@@ -343,3 +341,33 @@ def stitch_png(
y1 = min(canvas.shape[0], int(ys.max()) + pad_pixels + 1)
canvas = canvas[y0:y1, x0:x1]
return _png.encode(canvas)
+
+
+def _blend_raster_suptitle(
+ canvas: np.ndarray,
+ suptitle: str,
+ style: Optional[dict[str, Any]],
+ *,
+ scale: float,
+ title_h: int,
+) -> None:
+ """Draw a figure suptitle onto either grid or absolute-position PNGs."""
+ from xy import _raster, kernels
+
+ resolved = style or {}
+ cmd = _raster._Cmd(scale)
+ cmd.text(
+ canvas.shape[1] * float(resolved.get("x", 0.5)) / scale,
+ 17,
+ 1,
+ float(resolved.get("size", 14)),
+ _raster._parse_color(str(resolved.get("color", "#262626"))),
+ suptitle,
+ bold=str(resolved.get("weight", "normal")).lower()
+ in {"bold", "semibold", "demibold", "heavy", "black"},
+ )
+ overlay = kernels.rasterize(bytes(cmd.buf), canvas.shape[1], title_h)
+ alpha = overlay[:, :, 3:4].astype(np.float64) / 255.0
+ canvas[:title_h, :, :3] = np.round(
+ overlay[:, :, :3] * alpha + canvas[:title_h, :, :3] * (1.0 - alpha)
+ ).astype(np.uint8)
diff --git a/python/xy/pyplot/_plot_types.py b/python/xy/pyplot/_plot_types.py
index 5fd23ef2..bb40b092 100644
--- a/python/xy/pyplot/_plot_types.py
+++ b/python/xy/pyplot/_plot_types.py
@@ -126,6 +126,14 @@ def _textprops_kwargs(textprops: Any, where: str) -> dict[str, Any]:
fontsize = source.pop("fontsize", source.pop("size", None))
ha = source.pop("ha", source.pop("horizontalalignment", None))
va = source.pop("va", source.pop("verticalalignment", None))
+ weight = source.pop("fontweight", source.pop("weight", None))
+ family = source.pop("fontfamily", source.pop("family", None))
+ fontstyle = source.pop("fontstyle", source.pop("style", None))
+ rotation = source.pop("rotation", None)
+ # Axes.pie_label creates unclipped labels before applying textprops.
+ # Accepting the matching default keeps that implementation detail from
+ # becoming a gallery incompatibility while non-default clipping stays loud.
+ _reject_non_default(where, "clip_on", source.pop("clip_on", None), False)
check_unsupported(source, where)
out: dict[str, Any] = {}
if color is not None:
@@ -134,14 +142,51 @@ def _textprops_kwargs(textprops: Any, where: str) -> dict[str, Any]:
out["anchor"] = {"left": "start", "center": "middle", "right": "end"}.get(str(ha), "start")
style: dict[str, Any] = {}
if fontsize is not None:
- style["font_size"] = float(fontsize)
+ style["font_size"] = _text_font_size_points(fontsize)
if va is not None:
style["vertical_align"] = str(va)
+ if weight is not None:
+ style["font_weight"] = str(weight)
+ if family is not None:
+ style["font_family"] = str(family)
+ if fontstyle is not None:
+ style["font_style"] = _validated_font_style(fontstyle)
+ if rotation is not None:
+ style["rotation"] = 90.0 if rotation == "vertical" else float(rotation)
if style:
out["style"] = style
return out
+def _text_font_size_points(value: Any) -> float:
+ """Resolve Matplotlib's named font sizes against the current base size."""
+ relative = {
+ "xx-small": 0.6,
+ "x-small": 0.75,
+ "small": 0.85,
+ "medium": 1.0,
+ "large": 1.2,
+ "x-large": 1.45,
+ "xx-large": 1.75,
+ }
+ if isinstance(value, str):
+ try:
+ return float(rcParams["font.size"]) * relative[value]
+ except KeyError as exc:
+ raise ValueError(f"unsupported relative font size {value!r}") from exc
+ result = float(value)
+ if result <= 0:
+ raise ValueError("font size must be positive")
+ return result
+
+
+def _validated_font_style(value: Any) -> str:
+ result = str(value)
+ if result not in {"normal", "italic", "oblique"}:
+ raise ValueError("font style must be 'normal', 'italic', or 'oblique'")
+ return result
+
+
def _bilinear_grid_sample(
x_coords: np.ndarray, y_coords: np.ndarray, grid: np.ndarray, px: Any, py: Any
) -> np.ndarray:
@@ -3551,7 +3596,8 @@ def pie(
_reject_non_default("pie", "rotatelabels", rotatelabels, False)
if hatch is not None:
raise not_implemented("pie(hatch=...)")
- values = np.asarray(_from_data(x, data), dtype=np.float64)
+ source_values = np.asarray(_from_data(x, data))
+ values = np.asarray(source_values, dtype=np.float64)
if values.ndim != 1 or len(values) == 0:
raise ValueError("pie x must be a non-empty 1-D array")
offsets = np.zeros(len(values), dtype=np.float64)
@@ -3602,14 +3648,21 @@ def pie(
wedges: list[Wedge] = []
for index in range(len(values)):
selected = sectors == float(index)
+ face = resolve_color(color_values[index])
mark_kwargs: dict[str, Any] = {
- "color": resolve_color(color_values[index]),
+ "color": face,
"name": None if label_values[index] is None else str(label_values[index]),
"opacity": 1.0 if alpha is None else float(alpha),
}
if edgecolor is not None:
mark_kwargs["stroke"] = resolve_color(edgecolor)
mark_kwargs["stroke_width"] = 1.0 if linewidth is None else float(linewidth)
+ else:
+ # A sector is a fan of adjacent triangles. Stroke each fan
+ # triangle with its own face color so anti-aliasing cannot
+ # expose the figure background as radial hairline spokes.
+ mark_kwargs["stroke"] = face
+ mark_kwargs["stroke_width"] = 0.75
entry = self._add(
"@mark",
{
@@ -3674,7 +3727,11 @@ def add_text(distance: float, mid: float, value: str, offset: float) -> Text:
extent = float(radius) * (1.25 + float(np.max(offsets)))
self.set_xlim(float(center[0]) - extent, float(center[0]) + extent)
self.set_ylim(float(center[1]) - extent, float(center[1]) + extent)
- return PieContainer(wedges, values, bool(normalize), texts, autotexts)
+ self.set_aspect("equal", adjustable="box")
+ if not frame:
+ self.set_axis_off()
+ self._hidden_spines.update(("left", "bottom", "top", "right"))
+ return PieContainer(wedges, source_values, bool(normalize), texts, autotexts)
def pie_label(
self,
@@ -3691,11 +3748,13 @@ def pie_label(
``labels`` may be a sequence or a ``{}``-format string receiving
``absval`` and ``frac`` per wedge; ``distance`` places labels as a
fraction of the radius and ``textprops`` styles them.
- ``rotate=True`` raises loudly.
+ ``rotate=True`` orients each label away from its wedge center.
"""
- _reject_non_default("pie_label", "rotate", rotate, False)
if alignment not in ("auto", "center", "outer"):
raise ValueError("pie_label alignment must be 'auto', 'center', or 'outer'")
+ resolved_alignment = "outer" if alignment == "auto" and distance > 1 else alignment
+ if resolved_alignment == "auto":
+ resolved_alignment = "center"
if isinstance(labels, str):
formatted = [
labels.format(absval=value, frac=frac)
@@ -3714,15 +3773,25 @@ def pie_label(
radius = float(entry_data["pie_radius"])
explode = float(entry_data["pie_explode"])
radial = (float(distance) + explode) * radius
+ x = center_x + radial * np.cos(mid)
+ y = center_y + radial * np.sin(mid)
+ base_style: dict[str, Any] = {"vertical_align": "center"}
+ anchor = "middle"
+ if resolved_alignment == "outer":
+ anchor = "start" if x > 0 else "end"
+ if rotate:
+ if resolved_alignment == "outer":
+ base_style["vertical_align"] = "bottom" if y > 0 else "top"
+ base_style["rotation"] = float(np.rad2deg(mid) + (0.0 if x > 0 else 180.0))
+ kwargs = {"anchor": anchor, "style": base_style}
+ kwargs.update({key: value for key, value in text_kwargs.items() if key != "style"})
+ if text_kwargs.get("style"):
+ kwargs["style"] = {**base_style, **text_kwargs["style"]}
entry = self._add(
"@text",
{
- "args": (
- center_x + radial * np.cos(mid),
- center_y + radial * np.sin(mid),
- str(label),
- ),
- "kwargs": dict(text_kwargs),
+ "args": (x, y, str(label)),
+ "kwargs": kwargs,
},
)
result.append(Text(self, entry))
diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md
index c11fb4fd..75bb35d8 100644
--- a/spec/matplotlib/compat.md
+++ b/spec/matplotlib/compat.md
@@ -63,8 +63,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent
| `contour` / `contourf` / `clabel` | Native marching squares over rectilinear grids; warped grids route through native Delaunay/marching-triangle kernels; automatic labels repeat at bounded, separated positions along each level (line knockout for `inline=True` remains a visual approximation) |
| `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) |
-| `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`) on the HTML label; static exporters keep the plain label. 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) |
+| `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) |
| `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 |
diff --git a/src/raster.rs b/src/raster.rs
index 5da85840..d426a724 100644
--- a/src/raster.rs
+++ b/src/raster.rs
@@ -32,6 +32,7 @@ const OP_HEATMAP_IMAGE: u8 = 13;
const OP_AFFINE_POINTS: u8 = 14;
const OP_AFFINE_CHANNEL_POINTS: u8 = 15;
const OP_STROKED_TRIANGLES: u8 = 16;
+const OP_STYLED_TEXT: u8 = 17;
const SS: usize = 4; // vertical supersamples per scanline for polygon AA
@@ -1188,6 +1189,8 @@ fn glyph_index(ch: char) -> Option {
pub const TEXT_ROTATED: u8 = 0x80;
/// 0x40 requests 90°-CW text (top-down right-margin titles, mpl rotation=270).
pub const TEXT_ROTATED_CW: u8 = 0x40;
+const TEXT_ITALIC: u8 = 0x01;
+const TEXT_BOLD: u8 = 0x02;
fn text(cv: &mut Canvas, x: f32, y: f32, anchor: u8, size: f32, rgba: [f32; 4], s: &[u8]) {
let rotated = anchor & TEXT_ROTATED != 0;
@@ -1310,6 +1313,131 @@ fn text(cv: &mut Canvas, x: f32, y: f32, anchor: u8, size: f32, rgba: [f32; 4],
}
}
+#[allow(clippy::too_many_arguments)]
+fn styled_text(
+ cv: &mut Canvas,
+ x: f32,
+ y: f32,
+ anchor: u8,
+ size: f32,
+ angle_degrees: f32,
+ flags: u8,
+ italic_ranges: &[(u32, u32)],
+ rgba: [f32; 4],
+ s: &[u8],
+) {
+ let scale = size / font::BASE_PX as f32;
+ let text = String::from_utf8_lossy(s);
+ let advance = text
+ .chars()
+ .filter_map(glyph_index)
+ .map(|index| font::GLYPHS[index].0 as f32)
+ .sum::()
+ * scale;
+ let mut pen = match anchor {
+ 1 => -advance * 0.5,
+ 2 => -advance,
+ _ => 0.0,
+ };
+ let theta = angle_degrees.to_radians();
+ let (sin_theta, cos_theta) = theta.sin_cos();
+ let bold_shift = if flags & TEXT_BOLD != 0 {
+ (size * 0.055).clamp(0.55, 1.2)
+ } else {
+ 0.0
+ };
+
+ for (char_index, ch) in text.chars().enumerate() {
+ let Some(index) = glyph_index(ch) else {
+ continue;
+ };
+ let italic = flags & TEXT_ITALIC != 0
+ || italic_ranges
+ .iter()
+ .any(|&(start, end)| {
+ start <= char_index as u32 && (char_index as u32) < end
+ });
+ let italic_shear = if italic { 0.22 } else { 0.0 };
+ let (glyph_advance, gw, gh, left, top, offset, len) = font::GLYPHS[index];
+ if gw > 0 && gh > 0 {
+ let coverage = &font::COVERAGE[offset as usize..(offset + len) as usize];
+ let sample =
+ |sx: usize, sy: usize| coverage[sy * gw as usize + sx] as f32 / 255.0;
+ let bilinear = |u: f32, v: f32| {
+ if u < -0.5 || v < -0.5 || u > gw as f32 - 0.5 || v > gh as f32 - 0.5 {
+ return 0.0;
+ }
+ let u = u.clamp(0.0, gw as f32 - 1.0);
+ let v = v.clamp(0.0, gh as f32 - 1.0);
+ let (x0, y0) = (u.floor() as usize, v.floor() as usize);
+ let (x1, y1) = ((x0 + 1).min(gw as usize - 1), (y0 + 1).min(gh as usize - 1));
+ let (fx, fy) = (u - x0 as f32, v - y0 as f32);
+ sample(x0, y0) * (1.0 - fx) * (1.0 - fy)
+ + sample(x1, y0) * fx * (1.0 - fy)
+ + sample(x0, y1) * (1.0 - fx) * fy
+ + sample(x1, y1) * fx * fy
+ };
+
+ let u0 = pen + left as f32 * scale;
+ let v0 = top as f32 * scale;
+ let u1 = u0 + gw as f32 * scale + bold_shift;
+ let v1 = v0 + gh as f32 * scale;
+ let transform = |u: f32, v: f32| {
+ let sheared_u = u - italic_shear * v;
+ (
+ x + sheared_u * cos_theta - v * sin_theta,
+ y + sheared_u * sin_theta + v * cos_theta,
+ )
+ };
+ let corners = [
+ transform(u0, v0),
+ transform(u1, v0),
+ transform(u1, v1),
+ transform(u0, v1),
+ ];
+ let min_x = corners
+ .iter()
+ .map(|point| point.0)
+ .fold(f32::INFINITY, f32::min);
+ let max_x = corners
+ .iter()
+ .map(|point| point.0)
+ .fold(f32::NEG_INFINITY, f32::max);
+ let min_y = corners
+ .iter()
+ .map(|point| point.1)
+ .fold(f32::INFINITY, f32::min);
+ let max_y = corners
+ .iter()
+ .map(|point| point.1)
+ .fold(f32::NEG_INFINITY, f32::max);
+ let (bx0, by0, bx1, by1) = cv.bbox(min_x, min_y, max_x, max_y);
+ for py in by0..by1 {
+ for px in bx0..bx1 {
+ let dx = px as f32 + 0.5 - x;
+ let dy = py as f32 + 0.5 - y;
+ let sheared_u = dx * cos_theta + dy * sin_theta;
+ let local_v = -dx * sin_theta + dy * cos_theta;
+ let local_u = sheared_u + italic_shear * local_v;
+ let atlas_u = (local_u - u0) / scale - 0.5;
+ let atlas_v = (local_v - v0) / scale - 0.5;
+ let mut alpha = bilinear(atlas_u, atlas_v);
+ if bold_shift > 0.0 {
+ alpha = alpha.max(bilinear(
+ (local_u - bold_shift - u0) / scale - 0.5,
+ atlas_v,
+ ));
+ }
+ if alpha > 0.0 {
+ cv.blend(px, py, rgba, alpha);
+ }
+ }
+ }
+ }
+ pen += glyph_advance as f32 * scale;
+ }
+}
+
// ---- command-buffer reader --------------------------------------------------
struct Reader<'a> {
@@ -2030,6 +2158,33 @@ fn rasterize_with_spans(
let s = r.bytes(nb)?;
text(&mut cv, x, y, anchor, size, c, s);
}
+ OP_STYLED_TEXT => {
+ let (x, y) = (r.f32()?, r.f32()?);
+ let anchor = r.u8()?;
+ let size = r.f32()?;
+ let angle = r.f32()?;
+ let flags = r.u8()?;
+ let range_count = r.u32()? as usize;
+ let mut italic_ranges = Vec::with_capacity(range_count);
+ for _ in 0..range_count {
+ italic_ranges.push((r.u32()?, r.u32()?));
+ }
+ let c = r.rgba()?;
+ let nb = r.u32()? as usize;
+ let s = r.bytes(nb)?;
+ styled_text(
+ &mut cv,
+ x,
+ y,
+ anchor,
+ size,
+ angle,
+ flags,
+ &italic_ranges,
+ c,
+ s,
+ );
+ }
OP_POINTS => {
// Batched marks, struct-of-arrays: one header (symbol +
// shared stroke) then cx/cy/r f32 arrays and per-point
diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py
new file mode 100644
index 00000000..47ac9d42
--- /dev/null
+++ b/tests/pyplot/test_gallery_text_pie_compat.py
@@ -0,0 +1,240 @@
+from __future__ import annotations
+
+from io import BytesIO
+
+import numpy as np
+import pytest
+
+import xy.pyplot as plt
+
+
+@pytest.fixture(autouse=True)
+def _clean():
+ plt.close("all")
+ yield
+ plt.close("all")
+
+
+def test_pie_label_preserves_integer_values_for_integer_format_codes() -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie([36, 24, 8, 12])
+
+ labels = ax.pie_label(pie, "{absval:03d}\n{frac:.1%}")
+
+ assert pie.values.dtype.kind in "iu"
+ assert not pie.values.flags.writeable
+ assert [label.get_text() for label in labels] == [
+ "036\n45.0%",
+ "024\n30.0%",
+ "008\n10.0%",
+ "012\n15.0%",
+ ]
+
+
+def test_pie_label_does_not_integer_cast_float_input() -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie([1.0, 2.0])
+
+ with pytest.raises(ValueError, match="format code 'd'"):
+ ax.pie_label(pie, "{absval:d}")
+
+
+def test_positioned_png_keeps_figure_suptitle() -> None:
+ fig, ax = plt.subplots()
+ ax.plot([0, 1], [0, 1])
+ fig.suptitle("visible figure title")
+ fig.subplots_adjust(top=0.85)
+
+ pixels = np.asarray(plt.imread(BytesIO(fig._to_png())))
+
+ assert np.any(pixels[:48, :, :3] < 200)
+
+
+@pytest.mark.parametrize(
+ ("distance", "rotate", "expected"),
+ [
+ (
+ 0.6,
+ False,
+ [
+ ("middle", "center", None),
+ ("middle", "center", None),
+ ("middle", "center", None),
+ ("middle", "center", None),
+ ],
+ ),
+ (
+ 1.1,
+ False,
+ [
+ ("start", "center", None),
+ ("end", "center", None),
+ ("end", "center", None),
+ ("start", "center", None),
+ ],
+ ),
+ (
+ 0.6,
+ True,
+ [
+ ("middle", "center", 45.0),
+ ("middle", "center", 315.0),
+ ("middle", "center", 405.0),
+ ("middle", "center", 315.0),
+ ],
+ ),
+ (
+ 1.1,
+ True,
+ [
+ ("start", "bottom", 45.0),
+ ("end", "bottom", 315.0),
+ ("end", "top", 405.0),
+ ("start", "top", 315.0),
+ ],
+ ),
+ ],
+)
+def test_pie_label_matches_matplotlib_radial_alignment(
+ distance: float,
+ rotate: bool,
+ expected: list[tuple[str, str, float | None]],
+) -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie([1, 1, 1, 1])
+
+ labels = ax.pie_label(pie, ["a", "b", "c", "d"], distance=distance, rotate=rotate)
+
+ actual = [
+ (
+ label._entry["kwargs"]["anchor"],
+ label._entry["kwargs"]["style"]["vertical_align"],
+ label._entry["kwargs"]["style"].get("rotation"),
+ )
+ for label in labels
+ ]
+ for result, reference in zip(actual, expected, strict=True):
+ assert result[:2] == reference[:2]
+ if reference[2] is None:
+ assert result[2] is None
+ else:
+ assert result[2] == pytest.approx(reference[2])
+
+
+def test_pie_label_textprops_override_geometry_and_accept_gallery_font_styles() -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie([1, 2])
+
+ labels = ax.pie_label(
+ pie,
+ ["one", "two"],
+ distance=1.1,
+ rotate=True,
+ textprops={
+ "ha": "center",
+ "va": "center",
+ "rotation": 12,
+ "fontsize": "large",
+ "weight": "bold",
+ "style": "italic",
+ "family": "serif",
+ },
+ )
+
+ for label in labels:
+ kwargs = label._entry["kwargs"]
+ assert kwargs["anchor"] == "middle"
+ assert kwargs["style"] == {
+ "vertical_align": "center",
+ "rotation": 12.0,
+ "font_size": 12.0,
+ "font_weight": "bold",
+ "font_family": "serif",
+ "font_style": "italic",
+ }
+
+
+def test_pie_label_rotation_reaches_static_svg() -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie([1, 1, 1, 1])
+ ax.pie_label(pie, ["a", "b", "c", "d"], rotate=True)
+
+ svg = ax._build_chart(640, 480).figure().to_svg()
+
+ assert svg.count('transform="rotate(-45 ') == 2
+ assert 'transform="rotate(-315 ' in svg
+
+
+def test_text_accepts_matplotlib_style_alias_and_bbox_properties() -> None:
+ fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100)
+ ax.set_xlim(0, 10)
+ ax.set_ylim(0, 10)
+
+ label = ax.text(
+ 3,
+ 8,
+ "boxed italics text in data coords",
+ style="italic",
+ bbox={"facecolor": "red", "alpha": 0.5, "pad": 10},
+ )
+
+ assert label._entry["kwargs"] == {
+ "bbox": {"facecolor": "red", "alpha": 0.5, "pad": 10},
+ "style": {"font_style": "italic"},
+ }
+ chart = ax._build_chart(*fig._panel_px())
+ annotation = next(child for child in chart.children if getattr(child, "kind", None) == "text")
+ assert annotation.style["font_style"] == "italic"
+ assert annotation.style["background"] == "rgba(255,0,0,0.5)"
+ assert annotation.style["padding"] == "13.9px"
+ svg = chart.figure().to_svg()
+ assert 'fill="rgba(255,0,0,0.5)"' in svg
+ assert 'stroke="black"' in svg
+ assert 'font-style="italic"' in svg
+ target = BytesIO()
+ fig.savefig(target, format="png")
+ rgba = np.asarray(plt.imread(BytesIO(target.getvalue())))
+ assert np.any((rgba[..., 0] > 200) & (rgba[..., 1] < 180) & (rgba[..., 2] < 180))
+
+
+def test_text_preserves_mathtext_italic_span_across_all_exporters() -> None:
+ fig, ax = plt.subplots()
+
+ label = ax.text(2, 6, r"an equation: $E=mc^2$", fontsize=15)
+
+ assert label.get_text() == "an equation: E=mc²"
+ assert label._entry["kwargs"]["style"]["math_italic_ranges"] == "13:14,15:17"
+ svg = ax._build_chart(*fig._panel_px()).figure().to_svg()
+ assert 'E=' in svg
+ assert 'mc²' in svg
+ target = BytesIO()
+ fig.savefig(target, format="png")
+ rgba = np.asarray(plt.imread(BytesIO(target.getvalue())))
+ assert np.any(rgba[..., :3] < 0.5)
+
+
+def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None:
+ _fig, ax = plt.subplots()
+ pie = ax.pie(np.array([2, 3, 5], dtype=np.int16))
+
+ values = pie.values
+ assert values.dtype == np.dtype(np.int16)
+ assert not values.flags.writeable
+ np.testing.assert_allclose(pie.fracs, [0.2, 0.3, 0.5])
+
+
+def test_pie_uses_equal_aspect_hidden_axes_and_seam_covering_face_strokes() -> None:
+ _fig, ax = plt.subplots()
+
+ pie = ax.pie([2, 3, 5], startangle=90)
+ spec, _ = ax._build_chart(640, 480).figure().build_payload()
+
+ assert ax._aspect_equal is True
+ assert spec["frame_sides"] == []
+ assert spec["x_axis"]["tick_label_strategy"] == "none"
+ assert spec["y_axis"]["tick_label_strategy"] == "none"
+ assert all(wedge._entry["kwargs"]["stroke_width"] == 0.75 for wedge in pie.wedges)
+ assert all(
+ wedge._entry["kwargs"]["stroke"] == wedge._entry["kwargs"]["color"] for wedge in pie.wedges
+ )
+ assert pie.wedges[0]._entry["pie_mid"] == pytest.approx(np.deg2rad(126))
diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py
index 6233583d..caa677f0 100644
--- a/tests/pyplot/test_layout_text_parity_fixes.py
+++ b/tests/pyplot/test_layout_text_parity_fixes.py
@@ -141,6 +141,7 @@ def test_annotate_draws_arrow_at_xytext() -> None:
assert entry["args"][:2] == (10.0, 4.0)
arrow = next(e for e in ax._entries if e["kind"] == "@arrow")
sx0, sy0, ex0, ey0 = arrow["args"]
+ assert arrow["kwargs"]["style"]["head_size"] == 20.0
# shrink=0.05 pulls both ends 5% toward each other along the segment
np.testing.assert_allclose((sx0, sy0), (10 + 0.05 * (6.28 - 10), 4 + 0.05 * (1 - 4)))
np.testing.assert_allclose((ex0, ey0), (6.28 - 0.05 * (6.28 - 10), 1 - 0.05 * (1 - 4)))
diff --git a/tests/pyplot/test_p3_option_contracts.py b/tests/pyplot/test_p3_option_contracts.py
index 3b78f346..7f1eab8b 100644
--- a/tests/pyplot/test_p3_option_contracts.py
+++ b/tests/pyplot/test_p3_option_contracts.py
@@ -259,10 +259,6 @@ def _stream_args() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
lambda ax: ax.tricontour([0, 1, 2], [0, 1, 0], [1.0, 2.0, 3.0], norm=LogNorm()),
r"tricontour\(norm=LogNorm\)",
),
- (
- lambda ax: ax.pie_label(ax.pie([1.0, 2.0]), "{frac:.0%}", rotate=True),
- "rotate",
- ),
],
)
def test_p3_options_are_rejected_instead_of_silently_discarded(call, match: str) -> None: