Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions plotnine/_mpl/layout_manager/_plot_side_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion plotnine/composition/_inset_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
60 changes: 48 additions & 12 deletions plotnine/composition/_inset_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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`
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/baseline_images/test_inset_element/image_standalone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
70 changes: 70 additions & 0 deletions tests/test_inset_element.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"
Loading