diff --git a/pr-assets/pyplot-frame-geometry/matplotlib-before-after.png b/pr-assets/pyplot-frame-geometry/matplotlib-before-after.png new file mode 100644 index 00000000..32fcf34e Binary files /dev/null and b/pr-assets/pyplot-frame-geometry/matplotlib-before-after.png differ diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index bc3710cc..45c98a24 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -60,6 +60,10 @@ "text_color": "#262626", } _MPL_GRID_COLOR = "#b0b0b0" +# The 1x1 axes rectangle implied by the rcParams figure.subplot.* defaults +# (left .125, bottom .11, right .9, top .88). `_mplfig` owns the general +# gridspec resolution; this is the last-resort answer for a detached axes. +_DEFAULT_AXES_RECT = (0.125, 0.11, 0.775, 0.77) # Theme/axis children are immutable declarative specs, so identical ones are # shared across charts — the theme's CSS tokens validate through the native @@ -541,6 +545,11 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: # subplots_adjust() re-resolves the rect instead of keeping it frozen. self._subplot_spec: Optional[Any] = None self._absolute_plot_ratio: Optional[float] = None + # The plot rectangle the exporter demands, in chart pixels + # (left, top, width, height). Set by the grid compositor for a panel, + # whose chart is the panel and not the whole figure; otherwise the + # chart *is* the figure and the rectangle comes from get_position(). + self._plot_box_px: Optional[tuple[float, float, float, float]] = None self._padding: Optional[list[float]] = None self._xmargin = 0.0 self._ymargin = 0.0 @@ -825,6 +834,7 @@ def clear(self) -> None: self._insets = [] self._insets_materialized = False self._absolute_plot_ratio = None + self._plot_box_px = None self._padding = None self._grid = bool(rcParams["axes.grid"]) self._grid_color = _MPL_GRID_COLOR @@ -2590,9 +2600,21 @@ def get_ylim(self) -> tuple[float, float]: return (hi, lo) if self._axis_props("y").get("reverse") else (lo, hi) def get_position(self, original: bool = False) -> Bbox: - """The axes rectangle in figure fractions, as a `Bbox`.""" + """The axes rectangle in figure fractions, as a `Bbox`. + + Grid-aware: a subplot without an explicit rect reports its gridspec + cell under the figure's SubplotParams, so the panels of an ``n x m`` + grid report ``n * m`` distinct boxes exactly as matplotlib does. The + `_figure_rect`-only answer collapsed every panel onto the 1x1 default. + """ del original # compat-noop: shim axes have no active/original position split - return Bbox.from_bounds(*(self._figure_rect or (0.125, 0.11, 0.775, 0.77))) + if self._figure_rect is not None: + return Bbox.from_bounds(*self._figure_rect) + if self.figure is not None: + rect = self.figure._axes_rect(self) + if rect is not None: + return Bbox.from_bounds(*rect) + return Bbox.from_bounds(*_DEFAULT_AXES_RECT) def set_position(self, position: Bbox | tuple[float, float, float, float]) -> None: """Place the axes at a figure-fraction rectangle. @@ -4544,6 +4566,73 @@ def _best_legend_loc( best = min(scores.values()) return next(name for name, score in scores.items() if score <= best + 0.02) + def _outside_padding(self, compact: bool) -> tuple[float, float, float]: + """The top/right/bottom gutters the renderers reserve *outside* the + chart's ``padding``. + + `_svg.layout()` and the browser's ``ChartView._layout()`` both add the + title band, a top-side x axis, the colorbar strip, and the shared + right-side gutter for a secondary y axis on top of whatever padding the + spec carries. This is the shim's single mirror of that rule: + `_frame_padding()` subtracts it so an explicit padding still lands the + plot rect where Matplotlib puts the axes, the grid compositor adds it to + the panel it allocates so that subtraction always fits, and the + equal-aspect solve measures the real plot rect with it. + """ + top = 0.0 + if self._title: + top += 26.0 if compact else 30.0 + if self._axis["x"].get("side") == "top": + top += 26.0 if compact else 32.0 + right = 0.0 + bottom = 0.0 + if self._colorbar is not None: + if self._colorbar.get("orientation") == "horizontal": + bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) + else: + right += 86.0 + (18.0 if self._colorbar.get("label") else 0.0) + if self._twin is not None or any( + secondary._axis == "y" and secondary._side == "right" + for secondary in self._secondary_axes + ): + right += 42.0 if compact else 54.0 + return top, right, bottom + + def _frame_padding(self, width: int, height: int) -> Optional[list[float]]: + """Explicit ``padding`` that renders the axes frame where Matplotlib + draws it — the rectangle `get_position()` reports. + + Without this the renderers fell back to label-aware default margins + (62/14/10/42 px at ordinary export sizes), which have nothing to do with + the ``figure.subplot.*`` frame: a default 640x480 figure drew its frame + at x 0.0969..0.9781 (13.8 % too wide) with its top edge at y 0.0208 + instead of 0.1208. Returns None when the wanted rectangle cannot be + expressed as non-negative padding, leaving the defaults in charge. + """ + if self._colorbar is not None: + # Matplotlib's colorbar() steals its strip from the parent axes + # rectangle, while the renderers reserve it outside the padding. + # Reconciling the two is colorbar-placement work, not framing work. + return None + if self._plot_box_px is not None: + left, top, plot_width, plot_height = self._plot_box_px + else: + x0, y0, rect_width, rect_height = self.get_position().bounds + left = x0 * width + top = (1.0 - y0 - rect_height) * height + plot_width = rect_width * width + plot_height = rect_height * height + extra_top, extra_right, extra_bottom = self._outside_padding(width < 520) + padding = [ + top - extra_top, + width - left - plot_width - extra_right, + height - top - plot_height - extra_bottom, + left, + ] + if any(value < 0.0 for value in padding): + return None + return padding + def _build_chart(self, width: int, height: int) -> Any: if self._y2_of is not None: return self._y2_of._build_chart(width, height) @@ -4553,7 +4642,9 @@ def _build_chart(self, width: int, height: int) -> Any: children = self._chart_children() if self._twin is not None: children.extend(self._twin._chart_children()) - chart_padding = None if self._padding is None else list(self._padding) + chart_padding = ( + self._frame_padding(width, height) if self._padding is None else list(self._padding) + ) adjusted_aspect = False aspect_domains: Optional[tuple[tuple[float, float], tuple[float, float]]] = None if self._aspect_equal and self._aspect_bounds is not None: @@ -4567,14 +4658,13 @@ def _build_chart(self, width: int, height: int) -> Any: ) else: top, right, bottom, left = map(float, chart_padding) - layout_top = top + ((26.0 if compact else 30.0) if self._title else 0.0) - layout_right = right - layout_bottom = bottom - if self._colorbar is not None: - if self._colorbar.get("orientation") == "horizontal": - layout_bottom += 38.0 + (16.0 if self._colorbar.get("label") else 0.0) - else: - layout_right += 86.0 + (18.0 if self._colorbar.get("label") else 0.0) + # Solve over the rect the renderers will actually draw, gutters and + # all — a top-side x axis (matshow) otherwise made the equal-unit + # solve believe the plot box was 26 px taller than it is. + extra_top, extra_right, extra_bottom = self._outside_padding(compact) + layout_top = top + extra_top + layout_right = right + extra_right + layout_bottom = bottom + extra_bottom plot_width = max(40.0, width - left - layout_right) plot_height = max(40.0, height - layout_top - layout_bottom) data_ratio = abs(x1 - x0) / max(abs(y1 - y0), np.finfo(float).eps) diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index 26747024..f8c598f9 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -10,8 +10,12 @@ them. PNG: each panel renders through the engine's native rasterizer to an RGBA -array; NumPy pastes them onto one canvas and the engine's PNG encoder writes -the file. This module and `_mplfig.savefig` are the only places the shim +array; NumPy composites them onto one canvas and the engine's PNG encoder +writes the file. Absolutely placed panels are rendered on a transparent canvas +and alpha-composited, because a panel is wider than its gridspec cell — its +tick labels and title live outside the plot rect — and an opaque paste made +each column erase the one to its left. This module and `_mplfig.savefig` are +the only places the shim reaches past the public API (via `Chart.figure()` + the `_raster`/`_png` modules); everything else goes through `xy`' public surface. """ @@ -24,6 +28,60 @@ import numpy as np +def _composite(destination: np.ndarray, source: np.ndarray) -> None: + """Source-over `source` onto `destination` in place (straight RGBA8). + + Matplotlib draws every axes onto one canvas, so a panel's chrome may hang + over a neighbour's without erasing it. Alpha compositing is the equivalent + for panel-per-render composition; an opaque paste is not. + """ + alpha = source[:, :, 3:4].astype(np.float32) / 255.0 + destination[:, :, :3] = np.round( + source[:, :, :3] * alpha + destination[:, :, :3] * (1.0 - alpha) + ).astype(np.uint8) + destination[:, :, 3] = np.round( + 255.0 * (alpha[:, :, 0] + destination[:, :, 3] / 255.0 * (1.0 - alpha[:, :, 0])) + ).astype(np.uint8) + + +def _compose_canvas( + tiles: list[np.ndarray], + positions: list[tuple[float, float, float, float]], + canvas_size: tuple[int, int], + facecolor: str, + scale: float, +) -> np.ndarray: + """Alpha-composite absolutely placed panel tiles onto one RGBA figure canvas. + + `positions` are whole-panel [left, bottom, width, height] figure fractions, + bottom-origin like matplotlib; document order stacks later axes above + earlier ones, as matplotlib draws. + """ + from xy import _raster + + background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) + canvas = np.empty( + (round(canvas_size[1] * scale), round(canvas_size[0] * scale), 4), dtype=np.uint8 + ) + canvas[...] = background + for tile, (left, bottom, _width, height) in zip(tiles, positions, strict=True): + x = round(left * canvas.shape[1]) + y = round((1.0 - bottom - height) * canvas.shape[0]) + dest_x0, dest_y0 = max(0, x), max(0, y) + src_x0, src_y0 = max(0, -x), max(0, -y) + dest_x1 = min(canvas.shape[1], x + tile.shape[1]) + dest_y1 = min(canvas.shape[0], y + tile.shape[0]) + if dest_x1 > dest_x0 and dest_y1 > dest_y0: + _composite( + canvas[dest_y0:dest_y1, dest_x0:dest_x1], + tile[ + src_y0 : src_y0 + dest_y1 - dest_y0, + src_x0 : src_x0 + dest_x1 - dest_x0, + ], + ) + return canvas + + def compose_html( charts: list[Any], nrows: int, @@ -259,11 +317,17 @@ def stitch_png( return rendered return _png.encode(rendered) + absolute = positions is not None and canvas_size is not None tiles: list[np.ndarray] = [] for chart in charts: fig = chart.figure() spec, blob, borrowed = fig._build_raster_payload(px_width=max(256, int(fig.width))) - spec["canvas_background"] = facecolor + # Absolutely placed panels are wider and taller than their gridspec + # cell — tick labels and titles live outside the plot rect — so they + # must arrive transparent outside their own ink and be composited. An + # opaque figure-colored tile let every panel erase its left neighbour, + # which is why a dense subplot grid rendered only its last column. + spec["canvas_background"] = "none" if absolute else facecolor img = _raster.render_raster(spec, blob, scale, borrowed=borrowed) if isinstance(img, bytes): raise RuntimeError("pyplot grid rasterizer unexpectedly returned encoded PNG bytes") @@ -272,25 +336,7 @@ def stitch_png( raise ValueError("figure has no axes to save") if positions is not None and canvas_size is not None: - background = np.asarray(_raster._parse_color(facecolor), dtype=np.uint8) - canvas = np.empty( - (round(canvas_size[1] * scale), round(canvas_size[0] * scale), 4), - dtype=np.uint8, - ) - canvas[...] = background - for tile, (left, bottom, _width, height) in zip(tiles, positions, strict=True): - x = round(left * canvas.shape[1]) - y = round((1.0 - bottom - height) * canvas.shape[0]) - dest_x0, dest_y0 = max(0, x), max(0, y) - src_x0, src_y0 = max(0, -x), max(0, -y) - dest_x1 = min(canvas.shape[1], x + tile.shape[1]) - dest_y1 = min(canvas.shape[0], y + tile.shape[0]) - if dest_x1 > dest_x0 and dest_y1 > dest_y0: - canvas[dest_y0:dest_y1, dest_x0:dest_x1] = tile[ - src_y0 : src_y0 + dest_y1 - dest_y0, - src_x0 : src_x0 + dest_x1 - dest_x0, - ] - return _png.encode(canvas) + return _png.encode(_compose_canvas(tiles, positions, canvas_size, facecolor, scale)) col_widths = [ max(tiles[index].shape[1] for index in range(col, len(tiles), ncols)) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index a46c2346..651e9f06 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -15,13 +15,34 @@ import numpy as np from ._artists import Text -from ._axes import Axes, _plain_text +from ._axes import _DEFAULT_AXES_RECT, Axes, _plain_text from ._colors import resolve_color from ._rc import rc_figsize_px, rcParams from ._transforms import CoordinateTransform from ._translate import check_unsupported, not_implemented +def _panel_chrome(ax: Axes, plot_w: int) -> tuple[float, float, float, float]: + """``(left, top, right, bottom)`` px of chrome around a free-form panel. + + One definition for the whole absolute-placement path: `_charts` sizes the + panel with it, `_panel_positions` places the panel with it, and + `Axes._frame_padding` pins the plot rect inside the panel with it. When + those three disagree the plot box drifts off its gridspec cell. + + The chrome includes the gutters the renderers reserve outside the padding — + the axes title, a top-side x axis (`matshow`), the secondary-y band — so the + panel is always large enough to hold them without moving the plot rect. The + title is the case matplotlib makes obvious: it draws above the axes without + changing its position. + """ + compact = plot_w + 54 < 520 + left, top = (46.0, 6.0) if compact else (62.0, 10.0) + right, bottom = (8.0, 36.0) if compact else (14.0, 42.0) + extra_top, extra_right, extra_bottom = ax._outside_padding(compact) + return left, top + extra_top, right + extra_right, bottom + extra_bottom + + def _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes: """Insert standards-compliant PNG text chunks before IEND.""" from xy import _png @@ -673,18 +694,35 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: """ if not self._axes: return None - rects = [ax._figure_rect for ax in self._axes] - if any(rect is not None for rect in rects): - default = ( - _GridSpec(self, 1, 1, **self._subplot_adjust).cell_rect((0, 1), (0, 1)) - if self._subplot_adjust - else (0.125, 0.11, 0.775, 0.77) - ) - return [rect if rect is not None else default for rect in rects] - if not self._subplot_adjust and len(self._axes) <= 1: + if ( + all(ax._figure_rect is None for ax in self._axes) + and not self._subplot_adjust + and len(self._axes) <= 1 + ): + return None + return [self._axes_rect(ax) or _DEFAULT_AXES_RECT for ax in self._axes] + + def _axes_rect(self, ax: Axes) -> Optional[tuple[float, float, float, float]]: + """One axes' matplotlib rectangle (figure fractions), or None if foreign. + + The single resolver behind both `_effective_rects` (what the exporters + place) and `Axes.get_position` (what scripts read), so the reported box + and the rendered box cannot drift apart. + """ + if ax._figure_rect is not None: + return ax._figure_rect + if ax not in self._axes: return None - # A uniform grid (adjusted or default SubplotParams): every panel + if any(other._figure_rect is not None for other in self._axes): + # Free-form figure (add_axes/insets): matplotlib leaves a rect-less + # axes at the SubplotParams frame instead of dragging it onto the + # panel grid, which `add_axes` has already redefined as 1 x n. + if not self._subplot_adjust: + return _DEFAULT_AXES_RECT + return _GridSpec(self, 1, 1, **self._subplot_adjust).cell_rect((0, 1), (0, 1)) + # A uniform grid (adjusted or default SubplotParams): the panel # resolves to its gridspec cell rectangle under the frame and spacing. + row, col = divmod(self._axes.index(ax), self._ncols) grid = _GridSpec( self, self._nrows, @@ -693,13 +731,7 @@ def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: height_ratios=self._height_ratios, **self._subplot_adjust, ) - return [ - grid.cell_rect( - (index // self._ncols, index // self._ncols + 1), - (index % self._ncols, index % self._ncols + 1), - ) - for index in range(len(self._axes)) - ] + return grid.cell_rect((row, row + 1), (col, col + 1)) def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: """Per-column widths and per-row heights of the CSS-grid panel layout. @@ -730,12 +762,15 @@ def _charts(self) -> list[Any]: # figure buffer, matching Matplotlib add_axes semantics — # including the axes title, which matplotlib draws above the # axes without moving its position. - compact = plot_w + 54 < 520 - margin_w, margin_h = (54, 42) if compact else (76, 52) - if ax._title: - margin_h += 26 if compact else 30 + left, top, right, bottom = _panel_chrome(ax, plot_w) ax._absolute_plot_ratio = plot_w / plot_h - charts.append(ax._build_chart(plot_w + margin_w, plot_h + margin_h)) + # Pin the plot rect inside the panel: the exporters place the + # panel assuming its plot box sits at exactly this inset, so + # the renderers must not pick their own label-aware margins. + ax._plot_box_px = (left, top, plot_w, plot_h) + charts.append( + ax._build_chart(round(plot_w + left + right), round(plot_h + top + bottom)) + ) else: widths, heights = self._grid_cell_sizes() charts = [ @@ -806,19 +841,16 @@ def _panel_positions( """ positions = [] for ax, rect in zip(self._axes, rects, strict=True): - compact = round(canvas_size[0] * rect[2]) + 54 < 520 - left, bottom = (46, 36) if compact else (62, 42) - width, height = (54, 42) if compact else (76, 52) - if ax._title: - # The panel was built taller for its title (`_charts`); grow - # the placement upward so the plot box stays on the rect. - height += 26 if compact else 30 + # The same chrome `_charts` built the panel with — including the + # axes title, which grows the panel upward so the plot box stays + # on the rect. + left, top, right, bottom = _panel_chrome(ax, max(1, round(canvas_size[0] * rect[2]))) positions.append( ( rect[0] - left / canvas_size[0], rect[1] - bottom / canvas_size[1], - rect[2] + width / canvas_size[0], - rect[3] + height / canvas_size[1], + rect[2] + (left + right) / canvas_size[0], + rect[3] + (top + bottom) / canvas_size[1], ) ) return positions diff --git a/spec/api/styling.md b/spec/api/styling.md index 7666d2ee..7659ab5d 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -148,6 +148,31 @@ xy.x_axis( ) ``` +### Plot rectangle and chrome reservations + +`xy.chart(..., padding=[top, right, bottom, left])` sets the gutters around the +plot rectangle in pixels. Omitted, the renderers pick label-aware defaults — +`10/14/42/62` px, or `6/8/36/46` px on a compact chart (width under 520 px) — +which give an ordinary chart room for its tick labels; `padding=[0, 0, 0, 0]` +plus hidden axes gives an edge-to-edge sparkline. + +Some chrome is reserved **outside** `padding` rather than inside it, so +supplying padding does not have to anticipate it: + +| Reservation | Amount (compact / normal) | +| --- | --- | +| Chart title band | `+26` / `+30` px on top | +| A top-side x axis | `+26` / `+32` px on top | +| Right-side y axis gutter (secondary/named `y`) | `+42` / `+54` px on the right | +| Vertical colorbar | `+86` px on the right (`+18` more with a label) | +| Horizontal colorbar | `+38` px on the bottom (`+16` more with a label) | + +`xy._svg.layout()` is the single resolver for this in the Python exporters, and +the browser client's `ChartView._layout()` mirrors it exactly — the two must +stay in step, because a caller that pins a plot rectangle (as `xy.pyplot` does +to honor Matplotlib's `figure.subplot.*` frame) computes its padding by +subtracting these reservations. + ### Axis ticks and label formatting Tick placement is computed in f64 on the CPU (§16), never through f32, and is diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c11fb4fd..f186e990 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -76,6 +76,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | | `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | +| `Axes.get_position()` and the rendered axes frame | Exact geometry, and the two agree. `get_position()` resolves the axes' gridspec cell under the live SubplotParams (frame, `wspace`/`hspace`, width/height ratios), so an `n x m` grid reports `n * m` distinct boxes; an explicit `add_axes` rect or `set_position()` still wins. The renderers are then pinned to that rectangle instead of choosing label-aware default margins, so a default 640x480 figure draws its frame at `figure.subplot.*` (x0 0.125, w 0.775, top edge 0.12). Reservations the renderers place outside that rectangle — the axes title, a top-side x axis (`matshow`), the secondary-y gutter — grow the allocation rather than moving the frame, matching Matplotlib drawing a title above the axes without changing its position. Measured against `Axes.get_window_extent()` in Matplotlib 3.11, every frame lands within 1 px. **Not yet pinned:** an axes carrying a colorbar keeps the label-aware margins, because Matplotlib's `colorbar()` takes its strip out of the parent axes rectangle while the renderers reserve it outside the padding | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `plt.subplot_mosaic([['A','B'],['C','C']])` / `Figure.subplot_mosaic` | Row sequences (a list of equal-length label strings, or nested label lists) resolve to a uniform grid; each distinct label, in first-appearance order, binds to the next cell, returning `(fig, {label: Axes})` with `figsize=`/`dpi=` sizing the figure. Repeated labels do not span and `'.'` does not blank a cell — the grid keeps one axes per cell — and Matplotlib's single-string forms (`'AB;CC'`, newline-separated blocks) are not parsed into rows | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index dcdf2c03..b40afd04 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -175,6 +175,12 @@ The shim can be called complete for ordinary 2-D scripts when: - [x] Implement `Axes.set_position()` and preserve the requested figure rect. Evidence: `set_position([left, bottom, width, height])` updates `get_position().bounds` and `_figure_rect`. +- [x] Make `Axes.get_position()` grid-aware and render the axes frame on the + rectangle it reports. Evidence: `tests/pyplot/test_frame_geometry.py` + pins reported-vs-rendered agreement for single axes and for every panel of + 2x2/1x3/5x5/8x8 grids (including `subplots_adjust` frames and width + ratios), and checks a dense grid composites all 64 panels instead of only + its last column. - [x] Implement `Axes.set_anchor()` or reject unsupported anchor modes. Evidence: Matplotlib compass anchors are stored and unsupported modes raise `ValueError`. - [x] Finish `axis("equal")`, `axis("scaled")`, `axis("tight")`, and related diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 3e19c127..0887f188 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -31,8 +31,12 @@ def no_matplotlib(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", no_matplotlib) default = ax.get_position() - assert default.bounds == (0.125, 0.11, 0.775, 0.77) - assert (default.x0, default.y0, default.x1, default.y1) == (0.125, 0.11, 0.9, 0.88) + # Resolved through the gridspec now that get_position() is grid-aware, so + # the bottom edge carries matplotlib's own 0.88 - 0.77 rounding. + assert default.bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) + assert (default.x0, default.y0, default.x1, default.y1) == pytest.approx( + (0.125, 0.11, 0.9, 0.88) + ) ax.set_position([0.2, 0.3, 0.4, 0.5]) @@ -73,7 +77,10 @@ def test_axis_tight_sets_data_domains_and_equal_expands_to_panel_ratio() -> None assert x_axis.domain == pytest.approx((-0.1, 2.1)) # axis("equal") uses adjustable='datalim': preserve the ordinary panel # rectangle and expand y until x/y data units have the same pixel scale. - assert y_axis.domain == pytest.approx((-0.3347517730, 1.3347517730)) + # The expansion now solves over the *matplotlib* axes rectangle + # (0.775 x 0.77 of 640x480), so these are Matplotlib 3.11's own limits for + # this figure rather than the ones implied by the old label-aware margins. + assert y_axis.domain == pytest.approx((-0.31967741935483873, 1.3196774193548388)) assert ax.get_position().bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) diff --git a/tests/pyplot/test_frame_geometry.py b/tests/pyplot/test_frame_geometry.py new file mode 100644 index 00000000..7c799fff --- /dev/null +++ b/tests/pyplot/test_frame_geometry.py @@ -0,0 +1,226 @@ +"""The rendered axes frame must land on the rectangle `get_position()` reports. + +Every assertion here reads real emitted geometry: `_svg.layout()` is the single +plot-rect resolver both static exporters use (and the browser client mirrors it), +and the dense-grid check probes the composed PNG buffer itself. Reference values +are Matplotlib 3.11's own — figure.subplot.* frame, gridspec cell arithmetic, and +`Axes.get_window_extent()` — so a drift shows up as a compatibility failure +rather than a snapshot churn. +""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy import _svg + + +def teardown_function(): + plt.close("all") + + +def _plot_rects(fig) -> list[tuple[float, float, float, float]]: + """Absolute (x0, y0_from_top, w, h) px of every panel's plot rect.""" + from xy.pyplot._rc import rc_figsize_px + + canvas = rc_figsize_px(fig._figsize, fig._dpi) + charts = fig._charts() + rects = fig._effective_rects() + if rects is None: + offsets = [(0.0, 0.0)] + else: + offsets = [ + (round(p[0] * canvas[0]), round((1.0 - p[1] - p[3]) * canvas[1])) + for p in fig._panel_positions(rects, canvas) + ] + out = [] + for (ox, oy), chart in zip(offsets, charts, strict=True): + spec, _blob = chart.figure().build_payload() + _w, _h, _compact, plot = _svg.layout(spec) + out.append((ox + plot["x"], oy + plot["y"], plot["w"], plot["h"])) + return out + + +def _reported_rects(fig) -> list[tuple[float, float, float, float]]: + """The same rectangles as scripts read them, converted to top-origin px.""" + from xy.pyplot._rc import rc_figsize_px + + width, height = rc_figsize_px(fig._figsize, fig._dpi) + out = [] + for ax in fig.axes: + x0, y0, w, h = ax.get_position().bounds + out.append((x0 * width, (1.0 - y0 - h) * height, w * width, h * height)) + return out + + +def test_default_axes_renders_on_the_matplotlib_subplot_frame(): + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1, 2], [1, 3, 2]) + + # rcParams figure.subplot.*: left .125, bottom .11, right .9, top .88. + assert ax.get_position().bounds == pytest.approx((0.125, 0.11, 0.775, 0.77)) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx((80.0, 57.6, 496.0, 369.6), abs=0.5) + + +def test_axes_title_does_not_move_the_rendered_frame(): + """matplotlib draws the title above the axes without changing its position.""" + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1], [0, 1]) + (plain,) = _plot_rects(fig) + + plt.close("all") + fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100) + ax.plot([0, 1], [0, 1]) + ax.set_title("titled") + ax.set_xlabel("x") + ax.set_ylabel("y") + (titled,) = _plot_rects(fig) + + assert titled == pytest.approx(plain, abs=0.5) + + +@pytest.mark.parametrize( + "figsize", + [(6.4, 4.8), (3.2, 2.4), (12.0, 3.0), (5.0, 5.0)], +) +def test_reported_and_rendered_frames_agree_for_a_single_axes(figsize): + fig, ax = plt.subplots(figsize=figsize, dpi=100) + ax.plot([0, 1], [0, 1]) + + (reported,) = _reported_rects(fig) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx(reported, abs=0.5) + + +@pytest.mark.parametrize( + ("nrows", "ncols", "figsize", "adjust"), + [ + (2, 2, (6.4, 4.8), {}), + (1, 3, (9.0, 3.0), {"left": 0.05, "right": 0.98, "wspace": 0.35}), + (5, 5, (5.0, 5.0), {"hspace": 0.0, "wspace": 0.0}), + (8, 8, (6.0, 6.0), {}), + ], +) +def test_reported_and_rendered_frames_agree_for_every_grid_panel(nrows, ncols, figsize, adjust): + fig, axes = plt.subplots(nrows, ncols, figsize=figsize, dpi=100) + if adjust: + fig.subplots_adjust(**adjust) + for ax in np.asarray(axes).ravel(): + ax.plot([0, 1], [0, 1]) + + reported = _reported_rects(fig) + rendered = _plot_rects(fig) + assert len(rendered) == nrows * ncols + for want, got in zip(reported, rendered, strict=True): + assert got == pytest.approx(want, abs=1.0) + + +def test_grid_panel_reservations_keep_the_plot_rect_on_its_cell(): + """A top-side x axis (matshow) and a secondary y axis both take room the + renderers reserve outside the padding; the panel must grow, not shift.""" + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), dpi=100) + flat = list(np.asarray(axes).ravel()) + flat[0].matshow(np.arange(9.0).reshape(3, 3)) + flat[1].plot([0, 1], [0, 1]) + flat[1].twinx().plot([0, 1], [3, 4]) + flat[2].plot([0, 1], [0, 1]) + flat[2].set_title("titled") + flat[3].plot([0, 1], [0, 1]) + + for index, (want, got) in enumerate(zip(_reported_rects(fig), _plot_rects(fig), strict=True)): + # matshow is aspect-equal, so only its cell *origin* and height are + # pinned — adjustable='box' centers a square axes inside the cell. + if index == 0: + assert got[1] == pytest.approx(want[1], abs=1.0) + assert got[3] == pytest.approx(want[3], abs=1.0) + else: + assert got == pytest.approx(want, abs=1.0) + + +def test_get_position_reports_one_distinct_box_per_grid_panel(): + fig, axes = plt.subplots(8, 8, figsize=(6.0, 6.0), dpi=100) + boxes = [tuple(np.round(ax.get_position().bounds, 6)) for ax in np.asarray(axes).ravel()] + + assert len(boxes) == 64 + assert len(set(boxes)) == 64 + # Matplotlib's gridspec arithmetic: 0.775 wide / 0.77 tall frame, cells + # separated by wspace/hspace = 0.2 of the average cell. + assert boxes[0][0] == pytest.approx(0.125) + assert boxes[-1][0] + boxes[-1][2] == pytest.approx(0.9) + assert boxes[-1][1] == pytest.approx(0.11) + assert boxes[0][1] + boxes[0][3] == pytest.approx(0.88) + del fig + + +def test_get_position_follows_subplots_adjust_and_ratios(): + fig, axes = plt.subplots(2, 2, figsize=(6.4, 4.8), width_ratios=[1, 3]) + fig.subplots_adjust(left=0.2, right=0.95, wspace=0.0) + flat = list(np.asarray(axes).ravel()) + + left, right = flat[0].get_position(), flat[1].get_position() + assert left.x0 == pytest.approx(0.2) + assert right.x0 + right.width == pytest.approx(0.95) + # wspace=0 makes the columns adjacent, split 1:3 across the frame. + assert left.x0 + left.width == pytest.approx(right.x0) + assert right.width == pytest.approx(3.0 * left.width) + + +def test_explicit_rects_and_set_position_still_win(): + fig = plt.figure(figsize=(6.4, 4.8), dpi=100) + ax = fig.add_axes((0.2, 0.25, 0.6, 0.5)) + ax.plot([0, 1], [0, 1]) + + assert ax.get_position().bounds == pytest.approx((0.2, 0.25, 0.6, 0.5)) + (rendered,) = _plot_rects(fig) + assert rendered == pytest.approx((128.0, 120.0, 384.0, 240.0), abs=0.5) + + ax.set_position([0.1, 0.1, 0.5, 0.5]) + assert ax.get_position().bounds == pytest.approx((0.1, 0.1, 0.5, 0.5)) + + +def test_dense_grid_composite_draws_every_panel(monkeypatch): + """Panels are wider than their gridspec cell, so the compositor must + alpha-blend them; an opaque paste left only the last column visible.""" + from xy import _png + + captured: list[np.ndarray] = [] + real_encode = _png.encode + + def capture(canvas, *args, **kwargs): + captured.append(np.array(canvas)) + return real_encode(canvas, *args, **kwargs) + + monkeypatch.setattr(_png, "encode", capture) + + nrows = ncols = 8 + rng = np.random.default_rng(0) + fig, axes = plt.subplots(nrows, ncols, figsize=(6.0, 6.0), dpi=100) + for ax in np.asarray(axes).ravel(): + ax.imshow(rng.random((8, 8)), cmap="binary") + ax.set(xticks=[], yticks=[]) + rects = _plot_rects(fig) + + fig.savefig(io.BytesIO(), format="png", dpi=100) + assert captured, "savefig did not compose a figure canvas" + canvas = captured[-1] + assert canvas.shape[:2] == (600, 600) + + # Probe the middle of each panel's own plot rect on the composed buffer. + covered = [] + for x0, y0, w, h in rects: + patch = canvas[ + int(y0 + 0.25 * h) : int(y0 + 0.75 * h), + int(x0 + 0.25 * w) : int(x0 + 0.75 * w), + :3, + ].astype(int) + covered.append(float(np.mean(np.any(np.abs(patch - 255) > 12, axis=2)))) + + assert len(covered) == nrows * ncols + assert min(covered) > 0.5, f"blank panels: {[i for i, c in enumerate(covered) if c <= 0.5]}" + # The composed figure canvas stays opaque for the default white facecolor. + assert int(canvas[..., 3].min()) == 255