From d443e9d90081b7f9748e72680b76689ad2779059 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:27:47 +0100 Subject: [PATCH] ENH: Support units when specifying the figsize Reviving the spirit of #12402 and #12415, because both had significant user votes on GitHub. This PR is intentionally minimal to only expand the `figsize` parameter when creating a figure. This should be the most relevant use case. Later changing the figure size or reading it is probably less necessary. The minimal approach removes the need to track and return sizes. It is just an enhanced specification capability which directly parses to the internally used inch unit. --- doc/users/next_whats_new/figsize_unit.rst | 9 ++++ lib/matplotlib/figure.py | 54 ++++++++++++++++++++++- lib/matplotlib/figure.pyi | 9 +++- lib/matplotlib/pyplot.py | 12 +++-- lib/matplotlib/tests/test_figure.py | 16 +++++++ 5 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 doc/users/next_whats_new/figsize_unit.rst diff --git a/doc/users/next_whats_new/figsize_unit.rst b/doc/users/next_whats_new/figsize_unit.rst new file mode 100644 index 000000000000..ded95d9930a5 --- /dev/null +++ b/doc/users/next_whats_new/figsize_unit.rst @@ -0,0 +1,9 @@ +Figure size units +----------------- + +When creating figures, it is now possible to define figure sizes in cm or pixel. + +Up to now the figure size is specified via ``plt.figure(..., figsize=(6, 4))``, +and the given numbers are interpreted as inches. It is now possible to add a +unit string to the tuple, i.e. ``plt.figure(..., figsize=(600, 400, "px"))``. +Supported unit strings are "in", "cm", "px". diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index bf24193f380a..e1d26a9b58d8 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2475,8 +2475,13 @@ def __init__(self, """ Parameters ---------- - figsize : 2-tuple of floats, default: :rc:`figure.figsize` - Figure dimension ``(width, height)`` in inches. + figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize` + The figure dimensions. This can be + + - a tuple ``(width, height, unit)``, where *unit* is one of "in" (inch), + "cm" (centimenter), "px" (pixel). + - a tuple ``(width, height)``, which is interpreted in inches, i.e. as + ``(width, height, "in")``. dpi : float, default: :rc:`figure.dpi` Dots per inch. @@ -2612,6 +2617,8 @@ def __init__(self, edgecolor = mpl._val_or_rc(edgecolor, 'figure.edgecolor') frameon = mpl._val_or_rc(frameon, 'figure.frameon') + figsize = _parse_figsize(figsize, dpi) + if not np.isfinite(figsize).all() or (np.array(figsize) < 0).any(): raise ValueError('figure size must be positive finite not ' f'{figsize}') @@ -3713,3 +3720,46 @@ def figaspect(arg): # the min/max dimensions (we don't want figures 10 feet tall!) newsize = np.clip(newsize, figsize_min, figsize_max) return newsize + + +def _parse_figsize(figsize, dpi): + """ + Convert a figsize expression to (width, height) in inches. + + Parameters + ---------- + figsize : (float, float) or (float, float, str) + This can be + + - a tuple ``(width, height, unit)``, where *unit* is one of "in" (inch), + "cm" (centimenter), "px" (pixel). + - a tuple ``(width, height)``, which is interpreted in inches, i.e. as + ``(width, height, "in")``. + + dpi : float + The dots-per-inch; used for converting 'px' to 'in'. + """ + num_parts = len(figsize) + if num_parts == 2: + return figsize + elif num_parts == 3: + x, y, unit = figsize + if unit == 'in': + pass + elif unit == 'cm': + x /= 2.54 + y /= 2.54 + elif unit == 'px': + x /= dpi + y /= dpi + else: + raise ValueError( + f"Invalid unit {unit!r} in 'figsize'; " + "supported units are 'in', 'cm', 'px'" + ) + return x, y + else: + raise ValueError( + "Invalid figsize format, expected (x, y) or (x, y, unit) but got " + f"{figsize!r}" + ) diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index 064503c91384..c048ad556036 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -318,7 +318,9 @@ class Figure(FigureBase): subplotpars: SubplotParams def __init__( self, - figsize: tuple[float, float] | None = ..., + figsize: tuple[float, float] + | tuple[float, float, Literal["in", "cm", "px"]] + | None = ..., dpi: float | None = ..., *, facecolor: ColorType | None = ..., @@ -421,3 +423,8 @@ class Figure(FigureBase): def figaspect( arg: float | ArrayLike, ) -> np.ndarray[tuple[Literal[2]], np.dtype[np.float64]]: ... + +def _parse_figsize( + figsize: tuple[float, float] | tuple[float, float, Literal["in", "cm", "px"]], + dpi: float +) -> tuple[float, float]: ... diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index ea48fa2b180e..999b8e42120e 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -875,7 +875,9 @@ def figure( # autoincrement if None, else integer from 1-N num: int | str | Figure | SubFigure | None = None, # defaults to rc figure.figsize - figsize: ArrayLike | None = None, + figsize: ArrayLike # a 2-element ndarray is accepted as well + | tuple[float, float, Literal["in", "cm", "px"]] + | None = None, # defaults to rc figure.dpi dpi: float | None = None, *, @@ -908,8 +910,12 @@ def figure( window title is set to this value. If num is a ``SubFigure``, its parent ``Figure`` is activated. - figsize : (float, float), default: :rc:`figure.figsize` - Width, height in inches. + figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize` + The figure dimensions. This can be + + - a tuple ``(width, height, unit)``, where *unit* is one of "inch", "cm", + "px". + - a tuple ``(x, y)``, which is interpreted as ``(x, y, "inch")``. dpi : float, default: :rc:`figure.dpi` The resolution of the figure in dots-per-inch. diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 0c873934ebcb..014eb2cf23d0 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1819,3 +1819,19 @@ def test_subfigure_stale_propagation(): sfig2.stale = True assert sfig1.stale assert fig.stale + + +@pytest.mark.parametrize("figsize, figsize_inches", [ + ((6, 4), (6, 4)), + ((6, 4, "in"), (6, 4)), + ((5.08, 2.54, "cm"), (2, 1)), + ((600, 400, "px"), (6, 4)), +]) +def test_figsize(figsize, figsize_inches): + fig = plt.figure(figsize=figsize, dpi=100) + assert tuple(fig.get_size_inches()) == figsize_inches + + +def test_figsize_invalid_unit(): + with pytest.raises(ValueError, match="Invalid unit 'um'"): + plt.figure(figsize=(6, 4, "um"))