diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 15bd85564..01048298a 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -804,6 +804,11 @@ def _arrange_insets(self): (x1, y1), (x2, y2) = self.panel_area_coordinates elif inset.align_to == "plot": (x1, y1), (x2, y2) = self.plot_area_coordinates + elif inset.align_to == "footer": + if not self.plot.labels.get("footer", ""): + continue + + (x1, y1), (x2, y2) = self.footer_area_coordinates else: # "full" # Note that this isn't necessarily the figure's coordinates, # rather the entire ggplot area. @@ -980,6 +985,24 @@ def panel_area_coordinates( y1, y2 = self.b.panel_bottom, self.t.panel_top return ((x1, y1), (x2, y2)) + @property + def footer_area_coordinates( + self, + ) -> tuple[tuple[float, float], tuple[float, float]]: + """ + Lower-left and upper-right coordinates of the footer band + + This is the strip reserved at the bottom of the figure for the + plot footer text and its margins. It spans the full plot width. + It has zero height when there is no footer text. + """ + # Full plot width, matching where the footer background is drawn + x1 = self.l.offset + x2 = self.l.offset + self.plot_width + y1 = self.b.offset + y2 = self.b.offset + self.b.footer_height + return ((x1, y1), (x2, y2)) + def _calculate_panel_spacing(self) -> GridSpecParams: """ Spacing between the panels (wspace & hspace) diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 4fd8a5ae5..c5c7b2248 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -3,7 +3,9 @@ from copy import deepcopy from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal +from warnings import warn +from ..exceptions import PlotnineWarning from ._inset_image import _InsetImage if TYPE_CHECKING: @@ -48,6 +50,12 @@ class inset_element: - `"plot"` — the panel plus axes, labels, titles, captions and legends - `"full"` — everything the host plot occupies plus plot margin + - `"footer"` — the footer band at the bottom of the plot. Only + non-degenerate when the host has footer text (set with + `labs(footer=...)`); without it the band has zero height, the + inset is skipped, and a warning is issued. The band does not + grow to fit a taller inset — the inset overflows beyond it + instead. on_top : When `True` (default) the inset paints above the host plot. When `False`, the inset paints between the host's @@ -98,7 +106,7 @@ class inset_element: bottom: float right: float top: float - align_to: Literal["panel", "plot", "full"] = "panel" + align_to: Literal["panel", "plot", "full", "footer"] = "panel" on_top: bool = True anchor: Anchor = "center" @@ -249,10 +257,14 @@ class Insets(list[inset_element]): List of insets attached to a ggplot """ + # The host plot these insets are drawn into. + _host: ggplot + def _setup(self, parent: ggplot): """ Inherit the host figure and figure-owner-only theme props """ + self._host = parent for inset in self: inset._setup(parent) @@ -273,6 +285,16 @@ def draw(self, which: Literal["above", "below"]): insets = [inset for inset in self if not inset.on_top][::-1] for inset in insets: + if inset.align_to == "footer" and not self._host.labels.get( + "footer", "" + ): + warn( + "An inset with align_to='footer' was placed, but the " + "plot has no footer text. The inset will not be shown. " + "Set a footer with labs(footer=...).", + PlotnineWarning, + ) + continue inset._draw_in_host() def __and__(self, rhs) -> Insets: diff --git a/plotnine/composition/_inset_image.py b/plotnine/composition/_inset_image.py index 3d3ef7525..2df9e66e3 100644 --- a/plotnine/composition/_inset_image.py +++ b/plotnine/composition/_inset_image.py @@ -4,12 +4,14 @@ from typing import TYPE_CHECKING, Literal import numpy as np +from PIL import Image from PIL.Image import Image as PILImage from ..themes.theme import theme if TYPE_CHECKING: from matplotlib.figure import Figure + from matplotlib.image import BboxImage from matplotlib.patches import Rectangle from matplotlib.transforms import Bbox @@ -62,6 +64,10 @@ class _InsetImage: # ratio doesn't match. _anchor: tuple[float, float] + # BboxImage artist created in draw(); its data is updated by + # _arrange_in_box once the layout engine computes the final bbox. + _image_artist: BboxImage + def __init__( self, image: PILImage | np.ndarray, @@ -70,8 +76,8 @@ def __init__( ): from matplotlib.transforms import Bbox - self._image = image - self._image_size = _image_size(image) # (W, H) px + self._image = _to_pil_image(image) + self._image_size = self._image.size # (W, H) px self._frac_bbox = Bbox.unit() self._anchor = _resolve_anchor(anchor) self.theme = theme() @@ -106,6 +112,8 @@ def _arrange_in_box( Fractional figure-coordinates of the box assigned to this inset by `inset_element.align_to`. """ + from matplotlib.transforms import TransformedBbox + l, b, r, t = _fit_aspect( left, bottom, @@ -118,6 +126,26 @@ def _arrange_in_box( self._frac_bbox.bounds = (l, b, r - l, t - b) # pyright: ignore[reportAttributeAccessIssue] self.patch.set_bounds(left, bottom, right - left, top - bottom) + # The layout engine has finalised the bbox, so its device-pixel + # size is now known. _fit_aspect preserves the source aspect, so + # both axes scale by the same factor. + source_w, source_h = self._image_size + tbox = TransformedBbox(self._frac_bbox, self.figure.transFigure) + tw = max(1, round(tbox.width)) + th = max(1, round(tbox.height)) + downscaling = tw < source_w and th < source_h + + # LANCZOS to shrink: it antialiases the downscale. + # Not LANCZOS to enlarge: it looks hazy and rings badly on + # hard edges, so NEAREST. + if downscaling: + resampling = Image.Resampling.LANCZOS + else: + resampling = Image.Resampling.NEAREST + + resized = self._image.resize((tw, th), resampling) + self._image_artist.set_data(np.asarray(resized)) + def draw(self): from matplotlib.image import BboxImage from matplotlib.transforms import TransformedBbox @@ -127,11 +155,13 @@ def draw(self): self.theme._setup(self) # pyright: ignore[reportArgumentType] self._draw_plot_background() - image_artist = BboxImage( - TransformedBbox(self._frac_bbox, self.figure.transFigure) + tbox = TransformedBbox(self._frac_bbox, self.figure.transFigure) + # The image data is set later by _arrange_in_box, once the layout + # engine has computed the final device-pixel dimensions. + self._image_artist = BboxImage( + tbox, interpolation="none", resample=False ) - image_artist.set_data(np.asarray(self._image)) - self.figure.add_artist(image_artist) + self.figure.add_artist(self._image_artist) self.theme.apply() @@ -150,16 +180,22 @@ def _draw_plot_background(self): self.theme.targets.plot_background = self.patch -def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]: +def _to_pil_image(obj: PILImage | np.ndarray) -> PILImage: """ - Return the (width, height) of a PIL image or ndarray in pixels + Normalise an image input to a PIL Image for resampling + + A `PIL.Image` is returned unchanged. A uint8 ndarray (L / RGB / + RGBA) is wrapped directly. A float ndarray follows matplotlib's + `[0, 1]` convention and is clipped then scaled to uint8; the final + inset output is an 8-bit raster regardless, so nothing visible is + lost. """ if isinstance(obj, PILImage): - return obj.size # PIL exposes (W, H) - + return obj arr = np.asarray(obj) - h, w = arr.shape[:2] # ndarray is HWC or HW - return w, h + if arr.dtype != np.uint8: + arr = (np.clip(arr, 0, 1) * 255).round().astype(np.uint8) + return Image.fromarray(arr) # Named anchors → (h, v) fractions in [0, 1]², where `h = 0` diff --git a/tests/baseline_images/test_inset_element/footer_inset_no_footer_text.png b/tests/baseline_images/test_inset_element/footer_inset_no_footer_text.png new file mode 100644 index 000000000..687c236e2 Binary files /dev/null and b/tests/baseline_images/test_inset_element/footer_inset_no_footer_text.png differ diff --git a/tests/baseline_images/test_inset_element/footer_logo_right_aligned.png b/tests/baseline_images/test_inset_element/footer_logo_right_aligned.png new file mode 100644 index 000000000..2f15f1c52 Binary files /dev/null and b/tests/baseline_images/test_inset_element/footer_logo_right_aligned.png differ diff --git a/tests/baseline_images/test_inset_element/footer_plot_right_aligned.png b/tests/baseline_images/test_inset_element/footer_plot_right_aligned.png new file mode 100644 index 000000000..9bab5503a Binary files /dev/null and b/tests/baseline_images/test_inset_element/footer_plot_right_aligned.png differ diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png index 2b6836d7d..690eacc4e 100644 Binary files a/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png and b/tests/baseline_images/test_inset_element/image_aspect_fit_bottom.png differ diff --git a/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png index 7a9210ca9..ff58d8e2e 100644 Binary files a/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png and b/tests/baseline_images/test_inset_element/image_aspect_fit_top_right.png differ diff --git a/tests/baseline_images/test_inset_element/image_standalone.png b/tests/baseline_images/test_inset_element/image_standalone.png index cd8c53adc..6a30d570a 100644 Binary files a/tests/baseline_images/test_inset_element/image_standalone.png and b/tests/baseline_images/test_inset_element/image_standalone.png differ diff --git a/tests/test_inset_element.py b/tests/test_inset_element.py index a0df9dcb8..22e13e209 100644 --- a/tests/test_inset_element.py +++ b/tests/test_inset_element.py @@ -1,18 +1,26 @@ import numpy as np +import pandas as pd import pytest from PIL import Image, ImageDraw from plotnine import ( + aes, + coord_cartesian, element_line, element_rect, element_text, facet_wrap, + geom_col, + ggplot, labs, theme, + theme_void, ) from plotnine._utils.yippie import geom as g from plotnine._utils.yippie import plot from plotnine.composition import inset_element, plot_annotation +from plotnine.exceptions import PlotnineWarning +from plotnine.themes.elements import margin def _smiley() -> np.ndarray: @@ -374,3 +382,65 @@ def test_inset_on_facet_host(): + inset_element(plot.tomato, 0, 0, 0.25, 0.25) ) assert p == "inset_on_facet_host" + + +class TestFooterInset: + """ + align_to="footer" maps the inset bbox onto the footer band + """ + + p = ( + plot.white + + labs(footer="Source: Example Corp") + + theme( + plot_margin=0.01, + plot_footer=element_text( + margin=margin(t=0.01, b=0.01, unit="fig") + ), + plot_footer_background=element_rect(fill="#E9E9E9"), + ) + ) + + def test_footer_logo_right_aligned(self): + # A logo placed in the right portion of the footer band, inline + # with left-justified footer text. + p = self.p + inset_element( + SMILEY_FACE, + left=0.85, + bottom=0.15, + right=1 - 0.01, + top=0.85, + align_to="footer", + anchor="right", + ) + assert p == "footer_logo_right_aligned" + + def test_footer_plot_right_aligned(self): + # A plot placed in the right portion of the footer band, inline + # with left-justified footer text. + data = pd.DataFrame({"x": ["a", "b", "c"], "y": [1, 2, 3]}) + p_inset = ( + ggplot(data, aes("x", "y", fill="x")) + + geom_col(show_legend=False) + + coord_cartesian(expand=False) + + theme_void() + ) + p = self.p + inset_element( + p_inset, + left=0.85, + bottom=0.15, + right=1 - 0.01, + top=0.85, + align_to="footer", + ) + assert p == "footer_plot_right_aligned" + + def test_footer_inset_without_footer_text(self): + # No footer text -> degenerate footer band. The inset is skipped + # and a warning is emitted; the rendered plot is just the host. + # The == comparison draws the plot, which triggers the warning. + p = plot.white + inset_element( + SMILEY_FACE, 0.9, 0.0, 1.0, 1.0, align_to="footer" + ) + with pytest.warns(PlotnineWarning, match="no footer text"): + assert p == "footer_inset_no_footer_text"