11from __future__ import annotations
22
33from copy import deepcopy
4- from typing import TYPE_CHECKING
4+ from typing import TYPE_CHECKING , Literal
55
66import numpy as np
77from PIL .Image import Image as PILImage
1818 from ..ggplot import ggplot
1919 from ..themes .theme import theme as theme_type
2020
21+ AnchorName = Literal [
22+ "center" ,
23+ "top" ,
24+ "right" ,
25+ "bottom" ,
26+ "left" ,
27+ "top-left" ,
28+ "top-right" ,
29+ "bottom-left" ,
30+ "bottom-right" ,
31+ ]
32+ Anchor = AnchorName | tuple [float , float ]
33+
2134
2235class _InsetImage :
2336 """
2437 A raster image rendered inside an `inset_element`'s bounding box
2538
2639 The image keeps its intrinsic aspect ratio — when the bbox does
2740 not match, the image is letterboxed with transparent padding on
28- the two opposing edges so it stays centred.
41+ the two opposing edges. The `anchor` parameter chooses where the
42+ image sits inside the bbox (centered by default).
2943
3044 Theming the inset (`inset_element(...) + theme(...)`) draws a
3145 background rectangle around the image; only `plot_background`
@@ -44,12 +58,22 @@ class _InsetImage:
4458 # this bbox sets where on the figure it lands and how big it is.
4559 _frac_bbox : Bbox
4660
47- def __init__ (self , image : PILImage | np .ndarray ):
61+ # Where the image sits inside the user's bbox when its aspect
62+ # ratio doesn't match.
63+ _anchor : tuple [float , float ]
64+
65+ def __init__ (
66+ self ,
67+ image : PILImage | np .ndarray ,
68+ * ,
69+ anchor : Anchor = "center" ,
70+ ):
4871 from matplotlib .transforms import Bbox
4972
5073 self ._image = image
5174 self ._image_size = _image_size (image ) # (W, H) px
5275 self ._frac_bbox = Bbox .unit ()
76+ self ._anchor = _resolve_anchor (anchor )
5377 self .theme = theme ()
5478
5579 def __add__ (self , other : object ) -> _InsetImage :
@@ -69,10 +93,10 @@ def _arrange_in_box(
6993 """
7094 Place the image inside the given box, preserving aspect ratio
7195
72- The image is letterboxed — centered inside the box with
73- transparent padding on the two opposing edges — so its
74- intrinsic aspect ratio survives . The background rectangle
75- tracks the same fitted box.
96+ The image is letterboxed inside the box with transparent
97+ padding on the two opposing edges, positioned by the
98+ configured `anchor` . The background rectangle tracks the
99+ same fitted box.
76100
77101 Parameters
78102 ----------
@@ -81,7 +105,13 @@ def _arrange_in_box(
81105 inset by `inset_element.align_to`.
82106 """
83107 l , b , r , t = _fit_aspect (
84- left , bottom , right , top , self ._image_size , self .figure
108+ left ,
109+ bottom ,
110+ right ,
111+ top ,
112+ self ._image_size ,
113+ self .figure ,
114+ anchor = self ._anchor ,
85115 )
86116 w , h = r - l , t - b
87117 self ._frac_bbox .bounds = (l , b , w , h ) # pyright: ignore[reportAttributeAccessIssue]
@@ -128,17 +158,71 @@ def _image_size(obj: PILImage | np.ndarray) -> tuple[int, int]:
128158 return w , h
129159
130160
161+ # Named anchors → (h, v) fractions in [0, 1]², where `h = 0`
162+ # aligns the image to the bbox's left edge / `h = 1` to the right,
163+ # and `v = 0` aligns to the bottom / `v = 1` to the top.
164+ _ANCHOR_FRACTIONS : dict [str , tuple [float , float ]] = {
165+ "center" : (0.5 , 0.5 ),
166+ "top" : (0.5 , 1.0 ),
167+ "top-right" : (1.0 , 1.0 ),
168+ "right" : (1.0 , 0.5 ),
169+ "bottom-right" : (1.0 , 0.0 ),
170+ "bottom" : (0.5 , 0.0 ),
171+ "bottom-left" : (0.0 , 0.0 ),
172+ "left" : (0.0 , 0.5 ),
173+ "top-left" : (0.0 , 1.0 ),
174+ }
175+
176+
177+ def _resolve_anchor (anchor : Anchor ) -> tuple [float , float ]:
178+ """
179+ Normalise an anchor spec to a (h, v) tuple in [0, 1]²
180+
181+ Accepts a named anchor (e.g. `"top-right"`) or a numeric
182+ `(h, v)` tuple. Raises `ValueError` on unknown names or
183+ out-of-range tuple values.
184+ """
185+ if isinstance (anchor , str ):
186+ try :
187+ return _ANCHOR_FRACTIONS [anchor ]
188+ except KeyError :
189+ names = ", " .join (repr (k ) for k in _ANCHOR_FRACTIONS )
190+ raise ValueError (
191+ f"Unknown anchor { anchor !r} . Expected one of: "
192+ f"{ names } , or a (h, v) tuple in [0, 1]²."
193+ ) from None
194+ try :
195+ h , v = anchor
196+ except (TypeError , ValueError ):
197+ raise ValueError (
198+ f"Anchor must be a name or (h, v) tuple, got { anchor !r} ."
199+ ) from None
200+ if not (0.0 <= h <= 1.0 and 0.0 <= v <= 1.0 ):
201+ raise ValueError (
202+ f"Anchor tuple values must lie in [0, 1], got ({ h } , { v } )."
203+ )
204+ return float (h ), float (v )
205+
206+
131207def _fit_aspect (
132208 left : float ,
133209 bottom : float ,
134210 right : float ,
135211 top : float ,
136212 image_size : tuple [int , int ],
137213 fig : Figure ,
214+ anchor : tuple [float , float ] = (0.5 , 0.5 ),
138215) -> tuple [float , float , float , float ]:
139216 """
140- Shrink the user's bbox to the largest centered sub-bbox with the
141- image's intrinsic aspect ratio
217+ Shrink the user's bbox to the largest sub-bbox with the image's
218+ intrinsic aspect ratio, positioned by `anchor`
219+
220+ `anchor` defaults to `"center"` (image centered, padding split
221+ 50/50 on the short axis). Named anchors map to corners and edges
222+ of the bbox; a `(h, v)` tuple in [0, 1]² sets the anchor
223+ point directly, where `h = 0` aligns the image to the bbox's
224+ left edge / `h = 1` to the right, and `v = 0` to the bottom /
225+ `v = 1` to the top.
142226 """
143227 # figure size in px
144228 W , H = fig .bbox .size
@@ -151,13 +235,15 @@ def _fit_aspect(
151235 img_aspect = img_w / img_h
152236 box_aspect = box_w / box_h
153237
238+ h , v = anchor
239+
154240 if img_aspect > box_aspect :
155241 # Wider than the box: fit width, letterbox vertically
156242 new_box_h = box_w / img_aspect
157- pad_frac = (box_h - new_box_h ) / 2 / H
158- return left , bottom + pad_frac , right , top - pad_frac
243+ pad = (box_h - new_box_h ) / H
244+ return left , bottom + v * pad , right , top - ( 1 - v ) * pad
159245
160246 # Taller (or equal): fit height, letterbox horizontally
161247 new_box_w = box_h * img_aspect
162- pad_frac = (box_w - new_box_w ) / 2 / W
163- return left + pad_frac , bottom , right - pad_frac , top
248+ pad = (box_w - new_box_w ) / W
249+ return left + h * pad , bottom , right - ( 1 - h ) * pad , top
0 commit comments