Source code for dartwork_mpl.layout

"""Layout utilities for Matplotlib figures.

Provides ``simple_layout`` — a deterministic, content-aware GridSpec
layout. It measures the union extent of every visible artist on each
axes (texts, title, axis labels, view-limited tick labels, axis
offset text, legend) and arithmetically computes GridSpec edges that
place that union extent at the requested distance from each figure
edge. No optimizer is involved.

The historical ``auto_layout`` name is preserved as a thin wrapper
for backwards compatibility.
"""

from __future__ import annotations

__all__ = [
    "adopt_axis_label_font",
    "auto_layout",
    "get_bounding_box",
    "simple_layout",
    "tight_crop",
]

import contextlib
import warnings
from typing import TYPE_CHECKING, Any

from matplotlib.figure import Figure
from matplotlib.gridspec import GridSpec, GridSpecFromSubplotSpec, SubplotSpec
from matplotlib.transforms import Bbox

from .units import Length

if TYPE_CHECKING:
    from collections.abc import Iterable

    from matplotlib.axes import Axes
    from matplotlib.backend_bases import RendererBase


# Tolerance for the per-iteration GridSpec edge change (display px). A
# scenario is considered converged when every edge moved by less than
# this between consecutive iterations.
_CONVERGE_TOL_PX: float = 0.5

# Maximum direct-calc iterations. Most figures converge in 2; the
# corner cases (AutoLocator fixed-points on log scales, subfigures)
# need up to ~8.
_MAX_ITER: int = 10

# Minimum slack between opposing GridSpec edges, in figure-fraction.
# Keeps left < right and bottom < top so the GridSpec rectangle stays
# valid even when the requested margins consume the whole figure.
_EDGE_EPSILON: float = 1e-3


[docs] def get_bounding_box(boxes: list[Bbox]) -> tuple[float, float, float, float]: """Compute the minimum bounding box that encloses all given box regions. Parameters ---------- boxes : list List of box objects, each having at minimum p0 (bottom-left coordinate), width, and height attributes. Returns ------- tuple[float, float, float, float] Overall bounding box as ``(min_x, min_y, bbox_width, bbox_height)``. """ min_x = float("inf") min_y = float("inf") max_x = float("-inf") max_y = float("-inf") for box in boxes: min_x = min(min_x, box.p0[0]) min_y = min(min_y, box.p0[1]) max_x = max(max_x, box.p0[0] + box.width) max_y = max(max_y, box.p0[1] + box.height) return (min_x, min_y, max_x - min_x, max_y - min_y)
# ───────────────────────────────────────────────────────────────────── # margin parsing # ───────────────────────────────────────────────────────────────────── def _parse_margin( value: Length | str | float | int | None, fig_extent_inch: float ) -> float | None: """Convert a margin spec to a figure-fraction (0-1) along one axis. Accepts: - ``None`` → returns ``None`` (caller substitutes the default). - :class:`Length` → ``length.inch / fig_extent_inch``. - ``str`` ``"5%"`` → ``0.05``. - ``str`` parseable by :class:`Length` (``"5mm"``, ``"0.5cm"``, ``"0.1in"``, ``"24pt"``) → as Length. - ``int`` / ``float`` → already a fraction (``0.05`` = 5 %). Returns ------- float | None Margin in figure-fraction, or ``None`` if the input was ``None``. """ if value is None: return None if isinstance(value, Length): return value.inch / fig_extent_inch if isinstance(value, str): s = value.strip() if s.endswith("%"): return float(s[:-1]) / 100.0 return Length(s).inch / fig_extent_inch if isinstance(value, (int, float)) and not isinstance(value, bool): return float(value) raise TypeError( f"margin must be Length, str, or number; got {type(value).__name__}" ) # ───────────────────────────────────────────────────────────────────── # per-axes content extent measurement # ───────────────────────────────────────────────────────────────────── _BBOX_ERRORS = (RuntimeError, ValueError, AttributeError) def _axes_content_extent_px( ax: Axes, renderer: RendererBase, fig: Figure ) -> tuple[float, float, float, float] | None: """Return ``(x0, x1, y0, y1)`` in display pixels enclosing every visible artist of ``ax``. Walks the same artist set as ``_measure_overflow`` (texts, title, axis labels, view-limited tick labels, axis offset text, legend) plus the axes' own window extent so the result is never smaller than the plot area. Returns ``None`` if the axes has no measurable extent (rare — only for fully invisible axes). """ pts: list[tuple[float, float]] = [] # Plot area corners — guarantees the result is at least the axes # box, so an axes with all artists invisible still gets a valid # extent. try: apos = ax.get_window_extent(renderer) except _BBOX_ERRORS: apos = None if apos is not None and apos.width > 0 and apos.height > 0: pts.append((apos.x0, apos.y0)) pts.append((apos.x1, apos.y1)) # Texts, title, axis labels. text_artists = [*ax.texts, ax.title, ax.xaxis.label, ax.yaxis.label] for txt in text_artists: if txt is None or not txt.get_visible() or not txt.get_text().strip(): continue try: ext = txt.get_window_extent(renderer) except _BBOX_ERRORS: continue if ext.width <= 0 or ext.height <= 0: continue pts.append((ext.x0, ext.y0)) pts.append((ext.x1, ext.y1)) # Tick labels — only those whose tick position falls inside the # current view interval. Auto-generated ticks outside the data # range (e.g. ``10^16`` on a log axis with data up to ``10^9``) # are ignored, mirroring ``_measure_overflow``. for axis in (ax.xaxis, ax.yaxis): vmin, vmax = axis.get_view_interval() tol = abs(vmax - vmin) * 1e-5 for tick in axis.get_ticklabels(): if not tick.get_visible() or not tick.get_text().strip(): continue try: ext = tick.get_window_extent(renderer) pos = tick.get_position() except _BBOX_ERRORS: continue val = pos[0] if axis is ax.xaxis else pos[1] if val < vmin - tol or val > vmax + tol: continue if ext.width <= 0 or ext.height <= 0: continue pts.append((ext.x0, ext.y0)) pts.append((ext.x1, ext.y1)) # ScalarFormatter's "1e9" exponent. Lives on the axis itself, # not the tick list, and is invisible to ``ax.get_tightbbox`` # in some matplotlib versions. ot = axis.get_offset_text() if ot.get_visible() and ot.get_text().strip(): with contextlib.suppress(*_BBOX_ERRORS): ext = ot.get_window_extent(renderer) if ext.width > 0 and ext.height > 0: pts.append((ext.x0, ext.y0)) pts.append((ext.x1, ext.y1)) # Legend. legend = ax.get_legend() if legend is not None and legend.get_visible(): with contextlib.suppress(*_BBOX_ERRORS): ext = legend.get_window_extent(renderer) if ext.width > 0 and ext.height > 0: pts.append((ext.x0, ext.y0)) pts.append((ext.x1, ext.y1)) if not pts: return None xs = [p[0] for p in pts] ys = [p[1] for p in pts] return min(xs), max(xs), min(ys), max(ys) def _resolve_gridspec( fig: Figure, gs: GridSpec | SubplotSpec | None ) -> GridSpec | None: """Pick the GridSpec to update. Walks past ``GridSpecFromSubplotSpec`` to its root so ``.update`` is callable. """ actual: Any if gs is not None: actual = gs.get_gridspec() if isinstance(gs, SubplotSpec) else gs else: actual = fig.axes[0].get_gridspec() if actual is None: return None while isinstance(actual, GridSpecFromSubplotSpec): actual = actual._subplot_spec.get_gridspec() # type: ignore[attr-defined] return actual # type: ignore[no-any-return] # ───────────────────────────────────────────────────────────────────── # orphan tick-label font adoption # ───────────────────────────────────────────────────────────────────── # When an axis carries tick labels but no axis label, the tick labels # become that axis's most prominent textual descriptor. Under every # preset they are styled lighter/smaller than the axis label, so a # self-describing axis renders its descriptor in the subordinate tick # style. These helpers copy the axis-label font onto such "orphan" tick # labels. Color is intentionally excluded so user-set tick colors stay. def _copy_label_font(src: Any, dst: Any) -> None: """Copy fontsize/weight/family/style from ``src`` Text onto ``dst``.""" dst.set_fontsize(src.get_fontsize()) dst.set_fontweight(src.get_fontweight()) dst.set_fontfamily(src.get_fontfamily()) dst.set_fontstyle(src.get_fontstyle()) def _adopt_axis_label_font_core(fig: Figure) -> None: """Make unlabeled axes' tick labels adopt that axis's label font. For each axes and each axis direction (x, y) **independently**: if the axis has no axis label, copy the axis-label font (size, weight, family, style — *not* color) onto that axis's visible, non-empty tick labels (major and minor) and its offset text. Axes that *do* carry a label are left untouched, preserving any user tick-font customization. Assumes the figure has already been drawn so the tick label Text objects exist. The change persists across later redraws and locator regeneration because matplotlib copies the prototype tick's font onto any ticks it creates afterwards. """ for ax in fig.axes: for axis, get_label in ( (getattr(ax, "xaxis", None), getattr(ax, "get_xlabel", None)), (getattr(ax, "yaxis", None), getattr(ax, "get_ylabel", None)), ): if axis is None or get_label is None: continue try: if get_label().strip(): continue # labeled axis — leave ticks as-is label = axis.label targets: list[Any] = [] for minor in (False, True): targets.extend( tick for tick in axis.get_ticklabels(minor=minor) if tick.get_visible() and tick.get_text().strip() ) offset = axis.get_offset_text() if offset.get_visible() and offset.get_text().strip(): targets.append(offset) for tick in targets: _copy_label_font(label, tick) except (AttributeError, ValueError): # Non-standard axes (polar/3D) — skip defensively. continue def adopt_axis_label_font(fig: Figure) -> None: """Draw ``fig`` then apply :func:`_adopt_axis_label_font_core`. Use this when you are not calling :func:`simple_layout` (which already applies the same adoption by default via ``adopt_orphan_tick_font``) but still want unlabeled axes' tick labels to take the axis-label font. A no-op on figures without axes. Note that the adoption reflects the figure state at call time: if you later add an axis label to a previously unlabeled axis, the ticks that already adopted the label font keep it. In the normal flow axis labels are set before layout, so this does not arise. Examples -------- >>> import matplotlib.pyplot as plt >>> import dartwork_mpl as dm >>> dm.style.use("scientific") >>> fig, ax = plt.subplots(figsize=dm.figsize("12cm", "standard")) >>> ax.bar(["A", "B", "C"], [3, 5, 4]) >>> ax.set_ylabel("Count") # x has no label >>> dm.adopt_axis_label_font(fig) # x tick labels now use the label font """ if not fig.axes: return fig.canvas.draw() _adopt_axis_label_font_core(fig) # ───────────────────────────────────────────────────────────────────── # main entry point # ─────────────────────────────────────────────────────────────────────
[docs] def simple_layout( fig: Figure, gs: GridSpec | SubplotSpec | None = None, *, margin: Length | str | float = 0, ml: Length | str | float | None = None, mr: Length | str | float | None = None, mt: Length | str | float | None = None, mb: Length | str | float | None = None, use_all_axes: bool = True, adopt_orphan_tick_font: bool = True, verbose: bool = False, ) -> None: """Place axes content at the requested distance from each figure edge. Walks every visible artist on every axes (texts, title, axis labels, view-limited tick labels, axis offset text, legend) to find the union extent, then computes GridSpec edges so that union sits at ``ml/mr/mt/mb`` from the figure edge. Re-measures and re-applies until the GridSpec change between iterations falls below 0.5 px, handling cases where AutoLocator adjusts tick density when the axes is resized (e.g. log scales, large datetime ranges). Parameters ---------- fig : Figure The figure to lay out. gs : GridSpec | SubplotSpec | None, optional GridSpec or SubplotSpec to update. If ``None``, the GridSpec of ``fig.axes[0]`` is used. margin : Length | str | float, optional Distance from every figure edge to the axes content. Accepts :class:`Length` (``dm.cm(0.5)`` / ``dm.mm(5)``), a unit string (``"5mm"``, ``"0.5cm"``, ``"0.1in"``, ``"24pt"``), a percentage string (``"5%"``), or a bare number interpreted as figure-fraction (``0.05`` = 5 %). Default is ``0`` — ``simple_layout`` then snaps axes content flush against the figure edges, which is the same as the historical ``auto_layout(fig)`` minimum-margin behaviour. ml, mr, mt, mb : Length | str | float | None, optional Per-side overrides for left / right / top / bottom margins. Each accepts the same forms as ``margin``. ``None`` falls back to ``margin``. use_all_axes : bool, optional If ``True`` (default), every axes in the figure contributes to the measurement. If ``False``, only axes belonging to ``gs`` are considered. adopt_orphan_tick_font : bool, optional If ``True`` (default), tick labels (and offset text) on any axis that has no axis label adopt that axis's label font (size, weight, family, style; not color), via :func:`adopt_axis_label_font`. Applied each iteration *before* measurement so the computed margins fit the restyled ticks. Set to ``False`` to leave tick fonts untouched. verbose : bool, optional If ``True``, prints per-iteration GridSpec edges and the change since the previous iteration. Notes ----- The function returns ``None``. It is a no-op on figures with no axes. Examples -------- >>> import matplotlib.pyplot as plt >>> import dartwork_mpl as dm >>> dm.style.use("scientific") >>> fig, ax = plt.subplots(figsize=dm.figsize("13cm", "standard")) >>> ax.plot([1, 2, 3], [1, 4, 9]) >>> ax.set_xlabel("x") >>> ax.set_ylabel("y") >>> dm.simple_layout(fig) # margin = 0 (flush) >>> dm.simple_layout(fig, margin="2%") # 2% buffer >>> dm.simple_layout(fig, margin=dm.mm(2)) # 2 mm buffer >>> dm.simple_layout(fig, ml=dm.cm(1), mr="3%") # mixed units """ if not fig.axes: return actual_gs = _resolve_gridspec(fig, gs) if actual_gs is None: return fw_in, fh_in = fig.get_size_inches() # Resolve each side: per-side override else `margin`. base_h = _parse_margin(margin, fw_in) or 0.0 base_v = _parse_margin(margin, fh_in) or 0.0 ml_f = _parse_margin(ml, fw_in) mr_f = _parse_margin(mr, fw_in) mt_f = _parse_margin(mt, fh_in) mb_f = _parse_margin(mb, fh_in) if ml_f is None: ml_f = base_h if mr_f is None: mr_f = base_h if mt_f is None: mt_f = base_v if mb_f is None: mb_f = base_v last: tuple[float, float, float, float] | None = None gs_id = id(actual_gs) for it in range(_MAX_ITER): fig.canvas.draw() # Restyle orphan tick labels before measuring so the margins fit # the (possibly larger) adopted font. Re-applied each iteration to # survive locator-driven tick regeneration mid-loop. if adopt_orphan_tick_font: _adopt_axis_label_font_core(fig) renderer = fig.canvas.get_renderer() # type: ignore[attr-defined] fbox = fig.bbox fw_px, fh_px = fbox.width, fbox.height if use_all_axes: target_axes: Iterable[Axes] = fig.axes else: target_axes = [ ax for ax in fig.axes if ax.get_gridspec() is not None and id(ax.get_gridspec()) == gs_id ] oh_l: list[float] = [] oh_r: list[float] = [] oh_b: list[float] = [] oh_t: list[float] = [] for ax in target_axes: ext = _axes_content_extent_px(ax, renderer, fig) if ext is None: continue x0, x1, y0, y1 = ext pos = ax.get_position() oh_l.append(pos.x0 - x0 / fw_px) oh_r.append(x1 / fw_px - pos.x1) oh_b.append(pos.y0 - y0 / fh_px) oh_t.append(y1 / fh_px - pos.y1) if not oh_l: # no measurable axes return new_l = ml_f + max(oh_l) new_r = 1.0 - mr_f - max(oh_r) new_b = mb_f + max(oh_b) new_t = 1.0 - mt_f - max(oh_t) # Sanity clamp: keep edges in [0, 1] and ensure at least # ``_EDGE_EPSILON`` slack between left/right and bottom/top. new_l = max(0.0, min(new_l, 1.0 - _EDGE_EPSILON)) new_r = min(1.0, max(new_r, new_l + _EDGE_EPSILON)) new_b = max(0.0, min(new_b, 1.0 - _EDGE_EPSILON)) new_t = min(1.0, max(new_t, new_b + _EDGE_EPSILON)) if last is not None: delta_px = max( abs(new_l - last[0]) * fw_px, abs(new_r - last[1]) * fw_px, abs(new_b - last[2]) * fh_px, abs(new_t - last[3]) * fh_px, ) else: delta_px = float("inf") if verbose: print( f"[simple_layout] iter {it + 1}: " f"L={new_l:.3f} R={new_r:.3f} B={new_b:.3f} T={new_t:.3f} " f"Δ={delta_px:.2f}px" ) actual_gs.update(left=new_l, right=new_r, bottom=new_b, top=new_t) last = (new_l, new_r, new_b, new_t) if delta_px < _CONVERGE_TOL_PX: return
[docs] def auto_layout( fig: Figure, *, padding: float | tuple[float, float, float, float] = 0.08, max_iter: int = 5, tolerance: float = 2.0, verbose: bool = False, ) -> None: """Deprecated alias for :func:`simple_layout`. The previous ``auto_layout`` ran an outer loop over ``simple_layout`` to compensate for an optimizer cap that no longer exists. Direct-calc ``simple_layout`` already handles the same cases in a single call, so ``auto_layout`` is now a thin wrapper that translates the legacy ``padding`` (inches) argument into ``margin`` and forwards the call. Will be removed in a future release; use :func:`simple_layout` directly. ``max_iter`` and ``tolerance`` are accepted for signature compatibility but no longer have an effect — convergence is governed by the deterministic loop inside ``simple_layout``. """ warnings.warn( "dm.auto_layout is deprecated and will be removed in a future " "release; use dm.simple_layout(fig, ...) directly. The previous " "outer iteration is no longer needed.", DeprecationWarning, stacklevel=2, ) if not fig.axes: return if isinstance(padding, (int, float)): ml = mr = Length.from_inch(float(padding)) if padding else 0 mt = mb = Length.from_inch(float(padding)) if padding else 0 else: pl, pr, pb, pt_ = padding ml = Length.from_inch(float(pl)) if pl else 0 mr = Length.from_inch(float(pr)) if pr else 0 mb = Length.from_inch(float(pb)) if pb else 0 mt = Length.from_inch(float(pt_)) if pt_ else 0 simple_layout(fig, ml=ml, mr=mr, mt=mt, mb=mb, verbose=verbose)
# ───────────────────────────────────────────────────────────────────── # diagnostic helpers (kept for backwards compatibility with tests # that import them directly). # ───────────────────────────────────────────────────────────────────── def _measure_overflow(fig: Figure) -> dict[str, float]: """Measure per-side overflow of all visible artists beyond figure bounds. Returns a ``{"left", "right", "bottom", "top"}`` dict in display pixels. Used by the robustness suite as the post-layout invariant check; not called by :func:`simple_layout` itself. """ fig.canvas.draw() renderer = fig.canvas.get_renderer() # type: ignore[attr-defined] canvas_w, canvas_h = fig.canvas.get_width_height() bx0, by0 = 0.0, 0.0 bx1, by1 = float(canvas_w), float(canvas_h) overflow: dict[str, float] = { "left": 0.0, "right": 0.0, "bottom": 0.0, "top": 0.0, } for ax in fig.axes: for txt in [*ax.texts, ax.title, ax.xaxis.label, ax.yaxis.label]: if ( txt is None or not txt.get_visible() or not txt.get_text().strip() ): continue try: ext = txt.get_window_extent(renderer) except _BBOX_ERRORS: continue overflow["left"] = max(overflow["left"], bx0 - ext.x0) overflow["right"] = max(overflow["right"], ext.x1 - bx1) overflow["bottom"] = max(overflow["bottom"], by0 - ext.y0) overflow["top"] = max(overflow["top"], ext.y1 - by1) for axis in (ax.xaxis, ax.yaxis): vmin, vmax = axis.get_view_interval() tol = abs(vmax - vmin) * 1e-5 for tick in axis.get_ticklabels(): if not tick.get_visible() or not tick.get_text().strip(): continue try: ext = tick.get_window_extent(renderer) pos = tick.get_position() except _BBOX_ERRORS: continue val = pos[0] if axis is ax.xaxis else pos[1] if val < vmin - tol or val > vmax + tol: continue overflow["left"] = max(overflow["left"], bx0 - ext.x0) overflow["right"] = max(overflow["right"], ext.x1 - bx1) overflow["bottom"] = max(overflow["bottom"], by0 - ext.y0) overflow["top"] = max(overflow["top"], ext.y1 - by1) legend = ax.get_legend() if legend is not None and legend.get_visible(): leg_ext = None with contextlib.suppress(*_BBOX_ERRORS): leg_ext = legend.get_window_extent(renderer) if leg_ext is not None and leg_ext.width > 0 and leg_ext.height > 0: _LEGEND_FRAME_PAD = 2.0 overflow["left"] = max( overflow["left"], bx0 - (leg_ext.x0 - _LEGEND_FRAME_PAD) ) overflow["right"] = max( overflow["right"], (leg_ext.x1 + _LEGEND_FRAME_PAD) - bx1 ) overflow["bottom"] = max( overflow["bottom"], by0 - (leg_ext.y0 - _LEGEND_FRAME_PAD) ) overflow["top"] = max( overflow["top"], (leg_ext.y1 + _LEGEND_FRAME_PAD) - by1 ) return overflow # ───────────────────────────────────────────────────────────────────── # tight_crop — independent helper, unchanged # ───────────────────────────────────────────────────────────────────── def tight_crop( fig: Figure, *, padding: float = 0.05, verbose: bool = False ) -> tuple[float, float]: """Shrink figure to its content bounding box, eliminating outer whitespace. Unlike ``simple_layout`` which adjusts subplot margins inside a fixed figure size, ``tight_crop`` measures the union bounding box of all artists (axes spines, tick labels, axis labels, legends, suptitle, figure-level text/annotations) and resizes the figure itself to fit that bbox plus a uniform padding. Parameters ---------- fig : Figure The Matplotlib Figure to crop. padding : float, optional Uniform padding in inches around the content bbox. Default is ``0.05`` (~1.3 mm). Set to ``0`` for absolute tight crop. verbose : bool, optional If ``True``, prints before/after dimensions. Returns ------- tuple[float, float] New figure size in inches ``(width, height)``. """ if not fig.axes: w, h = fig.get_size_inches() return float(w), float(h) fig.canvas.draw() renderer = fig.canvas.get_renderer() # type: ignore[attr-defined] bboxes_disp: list[Bbox] = [] for ax in fig.axes: try: bb = ax.get_tightbbox(renderer) except _BBOX_ERRORS: bb = None if bb is not None and bb.width > 0 and bb.height > 0: bboxes_disp.append(bb) leg = ax.get_legend() if leg is not None and leg.get_visible(): try: bb = leg.get_window_extent(renderer) except _BBOX_ERRORS: continue if bb.width > 0 and bb.height > 0: bboxes_disp.append(bb) suptitle = getattr(fig, "_suptitle", None) if suptitle is not None and suptitle.get_visible(): with contextlib.suppress(*_BBOX_ERRORS): bboxes_disp.append(suptitle.get_window_extent(renderer)) for txt in fig.texts: if not txt.get_visible(): continue try: bb = txt.get_window_extent(renderer) except _BBOX_ERRORS: continue if bb.width > 0 and bb.height > 0: bboxes_disp.append(bb) for leg in getattr(fig, "legends", []): if not leg.get_visible(): continue try: bb = leg.get_window_extent(renderer) except _BBOX_ERRORS: continue if bb.width > 0 and bb.height > 0: bboxes_disp.append(bb) if not bboxes_disp: w, h = fig.get_size_inches() return float(w), float(h) union_disp = Bbox.union(bboxes_disp) dpi = fig.get_dpi() cur_w_in, cur_h_in = fig.get_size_inches() cur_w_px = cur_w_in * dpi cur_h_px = cur_h_in * dpi pad_target_px = padding * dpi new_w_in = (union_disp.width + 2 * pad_target_px) / dpi new_h_in = (union_disp.height + 2 * pad_target_px) / dpi old_content_x0 = union_disp.x0 / cur_w_px old_content_y0 = union_disp.y0 / cur_h_px old_content_x1 = union_disp.x1 / cur_w_px old_content_y1 = union_disp.y1 / cur_h_px old_positions = [ (ax, ax.get_position(original=False).bounds) for ax in fig.axes ] fig.set_size_inches(new_w_in, new_h_in) new_w_px = new_w_in * dpi new_h_px = new_h_in * dpi new_content_x0 = pad_target_px / new_w_px new_content_y0 = pad_target_px / new_h_px new_content_w = union_disp.width / new_w_px new_content_h = union_disp.height / new_h_px old_content_w = old_content_x1 - old_content_x0 old_content_h = old_content_y1 - old_content_y0 if old_content_w <= 0 or old_content_h <= 0: w, h = fig.get_size_inches() return float(w), float(h) sx = new_content_w / old_content_w sy = new_content_h / old_content_h for ax, (x, y, w, h) in old_positions: new_x = new_content_x0 + (x - old_content_x0) * sx new_y = new_content_y0 + (y - old_content_y0) * sy new_w = w * sx new_h = h * sy ax.set_position((new_x, new_y, new_w, new_h)) if verbose: pad_left_px = union_disp.x0 pad_right_px = cur_w_px - union_disp.x1 pad_bottom_px = union_disp.y0 pad_top_px = cur_h_px - union_disp.y1 print( f"[tight_crop] {cur_w_in:.2f}x{cur_h_in:.2f}in " f"→ {new_w_in:.2f}x{new_h_in:.2f}in " f"(L:{pad_left_px / dpi:.2f} R:{pad_right_px / dpi:.2f} " f"B:{pad_bottom_px / dpi:.2f} T:{pad_top_px / dpi:.2f}in trimmed)" ) return (new_w_in, new_h_in)