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: