Source code for mplotter.sizing

# Copyright (c) 2021 Marco Rigobello, MIT License
"""
Adjusting the size of matplotlib figures.

When preparing complex documents consisting of text and visual elements,
it is often crucial to:

1.  Fit a figure in a specific region of the document.

2.  Avoid any rescaling of the figure upon inclusion, so that its fixed
    size components (e.g. text elements) are preserved.

To this aim,

1.  It might be convenient to use the size of other layout elements of
    the document (line width, slide height, etc.) as length units.

2.  It is necessary to ensure that a figure is sized accurately.

This module provides helper functions to achieve both these goals.
Precisely,

1.  The function :func:`fig_size` allows to use the values (in inches)
    in :rc:`figure.figsize` as custom width and height units, converting
    a length specified in these units to its value in inches (standard
    matplotlib's unit). The latter can then be used as input elsewhere.

    When typesetting a TeX document, the size (in pt) of many layout
    elements can be assessed including the `command
    <https://www.tug.org/utilities/plain/cseq.html#showthe-rp>`_
    ``\showthe\<somelength>`` and inspecting the compiler log;
    ``<somelength>`` is the name of the layout element (for a list of
    common possibilities see `this table
    <https://www.overleaf.com/learn/latex/Lengths_in_LaTeX#Lengths>`_).
    To convert the output to inches, simply divide by ``72.27``.


2.  The function :func:`get_fig_size` retrives the save-time size of a
    figure (for a specific backend). Calling this function iteratively,
    :func:`set_fig_size` attemps to enforce a given save-time size for
    a figure, adjusting its draw-time size. Often only one of th

    Built-in support is available only for a handful of vector formats,
    listed in :data:`SUPPORTED_FORMATS`. For other file formats, extra
    dependencies are required (see :data:`EXTRAS_FORMATS`).
"""

from collections import deque
from importlib import import_module
from logging import getLogger
from tempfile import NamedTemporaryFile

import numpy as np
import matplotlib as mpl

from .saving import save_fig

__all__ = ["fig_size", "get_fig_size", "set_fig_size"]

logger = getLogger(__package__)

SUPPORTED_FORMATS = {"eps", "ps", "svg"}
"""set of str: Supported file formats by :func:`get_fig_size`."""

EXTRAS_FORMATS = {
    "pdf": ("pikepdf", {"pdf"}),
    "raster": ("PIL", {"png", "jpg", "jpeg", "tif", "tiff"}),
}
"""set of str: Optional supported file formats by :func:`get_fig_size`."""

for pkg, fmts in EXTRAS_FORMATS.values():
    try:
        import_module(pkg)
    except ModuleNotFoundError:
        pass
    else:
        SUPPORTED_FORMATS |= fmts

VECTOR_DPI = 72.0

MAX_SIZING_ATTEMPTS = 5
"""int: Maximum number of sizing attempts by :func:`set_fig_size`."""

SIZING_TOLERANCE = 1.0e-3
"""float: Relative tolerance of :func:`set_fig_size`."""


[docs] def fig_size(width=None, height=None, ratio=0.618): """ Converts :rc:`savefig.figsize` units to inches. For figures with axes of fixed aspect ratio, width and height are to be interpreted as maximum values. Parameters ---------- width : float, default :obj:`height / ratio` Figure width as a fraction of its :rc:`figure.figsize` value. height : float, default :obj:`width * ratio` Figure height as a fraction of its :rc:`figure.figsize` value. ratio : float, default golden ratio ``0.618`` Height to width ratio, ignored if both size values are given. Returns ------- tuple[float] Figure width and height, in inches. """ w, h = mpl.rcParams["figure.figsize"] if width: width *= w if height: height *= h width = width or height / ratio height = height or width * ratio return width, height
[docs] def get_fig_size(fig, **savefig_kw): """ Measures the actual saved figure size. Contrary to the fig.get_size_inches method, returns the save-time (rather than draw-time) values. The result depends on the backend. Supported formats: see :data:`SUPPORTED_FORMATS`. Parameters ---------- fig : :class:`~matplotlib.figure.Figure` Figure whose size is to be determined. **savefig_kw : Keyword arguments for :meth:`~matplotlib.figure.Figure.savefig`. Returns ------- :class:`~numpy.ndarray` Actual width and height of the saved figure, in inches. """ dpi = VECTOR_DPI # when no dpi information available (vector files) fmt = savefig_kw.get("format", mpl.rcParams["savefig.format"]).lower() if fmt not in SUPPORTED_FORMATS: raise ValueError("Unsupported format.") with NamedTemporaryFile(suffix=f".{fmt}") as f: save_fig(fig, f, close=False, **savefig_kw) f.seek(0) size = None if fmt == "pdf": import pikepdf with pikepdf.open(f) as doc: box = list(doc.pages[0].trimbox) elif fmt in ("ps", "eps"): for line in f: if line.startswith("%%HiResBoundingBox:"): box = line.split()[-4:] break elif fmt == "svg": from xml.dom import minidom doc = minidom.parse(f).documentElement size = [ doc.getAttribute(key).replace("pt", "") for key in ("width", "height") ] else: from PIL import Image with Image.open(f, formats=[fmt]) as im: size = im.size dpi = savefig_kw.get("dpi", mpl.rcParams["savefig.dpi"]) if dpi == "figure": dpi = fig.dpi if not size: box = np.asarray(box, dtype=float) size = np.diff(box.reshape((2, 2)), axis=0).squeeze() return np.asarray(size, dtype=float) / dpi
[docs] def set_fig_size(fig, size=None, which="both", **savefig_kw): """ Sets the actual saved figure size. Contrary to the :meth:`~matplotlib.figure.Figure.set_size_inches`, this tries to fix the save-time width and height by guessing appropriate draw-time values. The result depends on the backend. Parameters ---------- fig : :class:`~matplotlib.figure.Figure` Figure whose size has to be adjusted. size : float, default ``fig.get_size_inches()`` Desired save-time figure size, in inches. which : {"x", "y", "both"}, default "both" Weather to fix the absolute value of the width, heigth or both. If "x" or "y" is specified, the other dimension will be adjusted in such a way to keep the original proportions of the figure. **savefig_kw : Keyword arguments for :meth:`~matplotlib.figure.Figure.savefig`. Returns ------- tuple[float] Draw-time width and height of the figure, in inches. """ size = np.asarray(size or fig.get_size_inches()) draw = deque([np.zeros(2), size], maxlen=2) show = deque([np.zeros(2)], maxlen=2) def interpolate(): coeff = (draw[1] - draw[0]) / (show[1] - show[0]) return draw[1] + (size - show[1]) * coeff for _ in range(MAX_SIZING_ATTEMPTS): fig.set_size_inches(draw[1]) show.append(get_fig_size(fig, **savefig_kw)) if np.allclose(show[1], size, rtol=SIZING_TOLERANCE): break draw.append(interpolate()) else: logger.warning("Sizing attempts exhausted without convergence") new_size = draw[-1] if which in ("x", "y"): which = int(which == "y") other = int(not which) new_size[other] = new_size[which] * size[other] / size[which] fig.set_size_inches(new_size) return new_size