diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index ee49509956..50491d8b36 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -485,6 +485,7 @@ quartodoc: - axis_title_y_left - axis_title_y_right - dpi + - figure_format - figure_size - legend_background - legend_box diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 70b55a205c..ad989af10d 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -7,6 +7,11 @@ title: Changelog ### New +- Set a plot's preferred notebook and save format with + `theme(figure_format="svg")`. An explicit save format takes priority, + followed by the filename extension. For a composition, set the preference + with `plot_annotation(theme=theme(figure_format="svg"))`. + - You can now add a secondary axis to a plot, either as a one-to-one transformation of the primary axis with [](:class:`~plotnine.sec_axis`), or as a mirror of the primary axis with [](:func:`~plotnine.dup_axis`). @@ -171,6 +176,10 @@ title: Changelog ### Bug Fixes +- SVG colourbars now use overlapping rectangles to prevent misplaced + gradients in some SVG viewers. This applies to compositions and insets + without changing the colourbar mode used for other output formats. + - [](:class:`~plotnine.scale_size_datetime`) now honours its `range` argument. Previously it was ignored and mapping data raised an error. diff --git a/plotnine/_utils/__init__.py b/plotnine/_utils/__init__.py index 657a5939a2..a62a07a157 100644 --- a/plotnine/_utils/__init__.py +++ b/plotnine/_utils/__init__.py @@ -24,6 +24,8 @@ from ..mapping import aes if TYPE_CHECKING: + from io import BytesIO + from pathlib import Path from typing import Any, Callable, Literal, TypeVar import numpy.typing as npt @@ -1289,3 +1291,28 @@ def nextafter_range(rng: tuple[float, float]) -> tuple[float, float]: from math import inf, nextafter return (nextafter(rng[0], -inf), nextafter(rng[1], inf)) + + +def get_save_format( + filename: str | Path | BytesIO | None, + format: str | None, + *, + default: str | None, +) -> str | None: + """ + Resolve the output format for a saved figure + + An explicit format takes priority over the filename suffix and the + default. A `.png` suffix retains a `retina` default. + """ + from pathlib import Path + + if format is None and isinstance(filename, (str, Path)): + format = Path(filename).suffix.lstrip(".").lower() or None + if format == "png" and (default or "").lower() == "retina": + format = "retina" + format = format if format is not None else default + if format is None: + return None + format = format.lower() + return "jpeg" if format == "jpg" else format diff --git a/plotnine/composition/_compose.py b/plotnine/composition/_compose.py index ab859444d8..8e33a12a71 100644 --- a/plotnine/composition/_compose.py +++ b/plotnine/composition/_compose.py @@ -8,6 +8,7 @@ from plotnine.themes.theme import theme, theme_get +from .._utils import get_save_format from .._utils.context import assign_figure, plot_composition_context from .._utils.ipython import ( get_ipython, @@ -386,10 +387,14 @@ def _repr_mimebundle_(self, include=None, exclude=None) -> MimeBundle: """ ip = get_ipython() format: FigureFormat = ( - get_option("figure_format") + self.theme.getp("figure_format") + or get_option("figure_format") or (ip and ip.config.InlineBackend.get("figure_format")) or "retina" ) + format = cast("FigureFormat", format.lower()) + if format == "jpg": + format = "jpeg" if format == "retina": self = deepcopy(self) @@ -844,10 +849,13 @@ def save( Parameters ---------- filename : - File name to write the plot to. If not specified, a name + File name or buffer to write the composition to. format : - Image format to use, automatically extract from - file name extension. + Output format. An explicit value takes priority over the filename + extension and the composition theme's `figure_format`. If none is + set, use Matplotlib's default. The `retina` format saves a PNG at + twice the requested DPI. A `.png` extension retains a `retina` + theme preference; pass `format="png"` for ordinary resolution. dpi : DPI for raster graphics. If `None`, use the composition theme's `dpi`. @@ -855,14 +863,26 @@ def save( These are ignored. Here to "softly" match the API of `ggplot.save()`. """ - from plotnine import theme + from pathlib import Path - # Set the composition theme's DPI because the composition owns the - # figure. Inner plots inherit this value. - cmp = self - if dpi: - cmp = deepcopy(self) + append_extension = ( + format is None + and isinstance(filename, (str, Path)) + and not Path(filename).suffix.lstrip(".") + ) + format = get_save_format( + filename, format, default=self.theme.getp("figure_format") + ) + cmp = deepcopy(self) + # Child plots inherit the composition's DPI and output format. + if dpi is not None: cmp.theme = cmp.theme + theme(dpi=dpi) + if format == "retina": + cmp._to_retina() + format = "png" + if append_extension and format is not None: + filename = f"{str(filename).rstrip('.')}.{format}" + cmp.theme = cmp.theme + theme(figure_format=format) figure = cmp.draw() diff --git a/plotnine/composition/_inset_element.py b/plotnine/composition/_inset_element.py index 4aa6f04920..191901b114 100644 --- a/plotnine/composition/_inset_element.py +++ b/plotnine/composition/_inset_element.py @@ -76,10 +76,9 @@ class inset_element: Notes ----- - `figure_size` and `dpi` set on the inset's theme are ignored. The - inset shares the host's figure, so these values come from the host - theme. The canvas size of the inset is determined by the bounding - box and the area it is `align_to`. + The inset shares the host's figure, so the host theme controls + `figure_size`, `dpi`, and `figure_format`. The bounding box and + `align_to` area determine the inset's canvas size. For image insets, `inset_element(...) + theme(...)` draws a sibling rectangle around the image; only `plot_background` is diff --git a/plotnine/composition/_plot_annotation.py b/plotnine/composition/_plot_annotation.py index db3810e92a..c7fec3d470 100644 --- a/plotnine/composition/_plot_annotation.py +++ b/plotnine/composition/_plot_annotation.py @@ -45,9 +45,10 @@ class plot_annotation(ComposeAddable): """ Theme for the plot title, subtitle, caption, footer, margin and background - It also controls the [](`~plotnine.themes.themeables.figure_size`) of the - composition. The default theme is the same as the default one used for the - plots, which you can change with [](`~plotnine.theme_set`). + The theme also sets the composition's + [](`~plotnine.themes.themeable.figure_size`) and preferred + [](`~plotnine.themes.themeable.figure_format`). By default, it uses the + plot theme selected by [](`~plotnine.theme_set`). """ def __radd__(self, cmp: Compose) -> Compose: diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 84cd0796db..9e202a72c2 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -20,6 +20,7 @@ from ._utils import ( from_inches, + get_save_format, is_data_like, order_as_data_mapping, to_inches, @@ -167,9 +168,6 @@ def __init__( self.watermarks: list[watermark] = [] self._insets: Insets = Insets() - # build artefacts - self._build_objs = NS(meta={}) - def __str__(self) -> str: """ Return a wrapped display size (in pixels) of the plot @@ -197,10 +195,14 @@ def _repr_mimebundle_(self, include=None, exclude=None) -> MimeBundle: """ ip = get_ipython() format: FigureFormat = ( - get_option("figure_format") + self.theme.getp("figure_format") + or get_option("figure_format") or (ip and ip.config.InlineBackend.get("figure_format")) or "retina" ) + format = cast("FigureFormat", format.lower()) + if format == "jpg": + format = "jpeg" # While jpegs can be displayed as retina, we restrict the output # of "retina" to png @@ -560,9 +562,13 @@ def _build(self): refreshes the deprecated build-object alias. """ self.built = self.build() - self._build_objs.layers = self.built.layers - self._build_objs.scales = self.built.scales - self._build_objs.layout = self.built.layout + # Preserve the deprecated build-object alias for extensions that + # still depend on it. + self._build_objs = NS( + layers=self.built.layers, + scales=self.built.scales, + layout=self.built.layout, + ) def _draw_panel_borders(self): """ @@ -731,22 +737,32 @@ def save_helper( This method has the same arguments as [](`~plotnine.ggplot.save`). Use it to get access to the figure that will be saved. """ - if format is None and isinstance(filename, (str, Path)): - format = str(filename).split(".")[-1] - - fig_kwargs: Dict[str, Any] = {"format": format, **kwargs} + append_extension = ( + format is None + and isinstance(filename, (str, Path)) + and not Path(filename).suffix.lstrip(".") + ) + format = get_save_format( + filename, format, default=self.theme.getp("figure_format") + ) + retina = format == "retina" + if retina: + format = "png" if limitsize is None: limitsize = cast("bool", get_option("limitsize")) # filename, depends on the object if filename is None: - ext = format if format else "pdf" - filename = self._save_filename(ext) + format = format or "pdf" + filename = self._save_filename(format) + elif append_extension and format is not None: + filename = f"{str(filename).rstrip('.')}.{format}" if path and isinstance(filename, (Path, str)): filename = Path(path) / filename + fig_kwargs: Dict[str, Any] = {"format": format, **kwargs} fig_kwargs["fname"] = filename # Preserve the users object @@ -783,7 +799,9 @@ def save_helper( if dpi is not None: self.theme = self.theme + theme(dpi=dpi) - self._build_objs.meta["figure_format"] = format + if retina: + self.theme = self.theme.to_retina() + self.theme = self.theme + theme(figure_format=format) figure = self.draw(show=False) return mpl_save_view(figure, fig_kwargs) @@ -809,8 +827,12 @@ def save( File name to write the plot to. If not specified, a name like “plotnine-save-.” is used. format : - Image format to use, automatically extract from - file name extension. + Output format. An explicit value takes priority over the filename + extension and the theme's `figure_format`. Without a filename or + preference, use PDF; otherwise use Matplotlib's default. The + `retina` format saves a PNG at twice the requested DPI. A `.png` + extension retains a `retina` theme preference; pass + `format="png"` for ordinary resolution. path : Path to save plot to (if you just want to set path and not filename). @@ -1003,6 +1025,8 @@ def facet_pages(column) with PdfPages(filename) as pdf: # Re-add the first element to the iterator, if it was removed for plot in plots: + plot = deepcopy(plot) + plot.theme = plot.theme + theme(figure_format="pdf") fig = plot.draw() with plot_context(plot).rc_context: # Save as a page in the PDF file diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index b6c155668f..69e913646e 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -142,10 +142,11 @@ def _bind_owner(self, owner: LegendOwner): Whoever renders this guide — its theme and figure drive layout and attachment. """ - # guide theme has priority and its targets are tracked - # independently. + # The guide theme controls styling, but the owner supplies figure + # properties. Keep the guide's targets separate from the owner's. self.figure = owner.figure self.theme = owner.theme + self.theme + self.theme._inherit_figure_props(owner.theme) self.theme._setup(self) self.elements = self._elements_cls(self.theme, self) diff --git a/plotnine/guides/guide_colorbar.py b/plotnine/guides/guide_colorbar.py index 7940367ea5..034d45749d 100644 --- a/plotnine/guides/guide_colorbar.py +++ b/plotnine/guides/guide_colorbar.py @@ -28,7 +28,6 @@ from matplotlib.text import Text from plotnine import theme - from plotnine.guides import guides from plotnine.scales.scale import scale from plotnine.typing import Side @@ -82,12 +81,6 @@ def __post_init__(self): if self.nbin is None: self.nbin = 300 # if self.display == "gradient" else 300 - def setup(self, guides: guides): - super().setup(guides) - # See: add_segmented_colorbar - if guides.plot._build_objs.meta.get("figure_format") == "svg": - self.display = "rectangles" - def train(self, scale: scale, aesthetic=None): self.nbin = cast("int", self.nbin) self.title = cast("str", self.title) @@ -184,7 +177,11 @@ def draw(self): reverse = slice(None, None, -1) nbars = len(self.bar) elements = self.elements - raster = self.display == "raster" + format = self.theme.getp("figure_format") + display = self.display + if format is not None and format.lower() in {"svg", "svgz"}: + display = "rectangles" + raster = display == "raster" alpha = self.alpha colors = self.bar["color"].tolist() @@ -233,7 +230,7 @@ def draw(self): targets.legend_text_colorbar = texts # colorbar - if self.display == "rectangles": + if display == "rectangles": add_segmented_colorbar(auxbox, colors, alpha, elements) else: add_gradient_colorbar(auxbox, colors, alpha, elements, raster) diff --git a/plotnine/options.py b/plotnine/options.py index 09dc062640..effdf40eaf 100644 --- a/plotnine/options.py +++ b/plotnine/options.py @@ -40,14 +40,18 @@ figure_format: Optional[FigureFormat] = None """ -The format for the inline figures outputted by the jupyter kernel. +Default format for figures displayed in Jupyter -If `None`, it is the value of +A plot or composition theme takes priority over this option. When both are +unset, use the IPython setting: %config InlineBackend.figure_format -If that has not been set, the default is "retina". -You can set it explicitly with: +If IPython has no setting, use `"retina"`. Set a plot-specific preference +with `theme(figure_format="svg")`. This option does not set the default +format for saved files. + +Set the IPython fallback with: %config InlineBackend.figure_format = "retina" """ diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 63d2346bd3..6449116cb2 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -248,6 +248,7 @@ def __init__( panel_ontop=None, aspect_ratio=None, dpi=None, + figure_format: str | None = None, figure_size=None, legend_box=None, legend_box_margin=None, @@ -575,16 +576,16 @@ def to_retina(self) -> theme: def _inherit_figure_props(self, other: theme) -> None: """ - Copy themeables that modify the figure + Inherit figure properties from another theme - Used when this theme is attached to a plot that does not own - its figure (an inset, or a member of a composition). Such a plot - has no figure to size or DPI; the values must come from the - figure's owner. + Plots and guides that share a figure use its owner's size, DPI, and + output format. """ + self.themeables.pop("figure_format", None) self += theme( figure_size=other.getp("figure_size"), dpi=other.getp("dpi"), + figure_format=other.getp("figure_format"), ) def _smart_title_and_subtitle_ha( diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index b4dd5e1284..4c1115a5dd 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -2981,6 +2981,32 @@ def rcParams(self): return rcParams +class figure_format(themeable): + """ + Preferred output format for a figure + + Parameters + ---------- + theme_element : + Format such as `png`, `svg`, `pdf`, or `retina`. Notebook display + supports PNG, JPEG, SVG, PDF, and retina PNG. Saved files also support + formats provided by the selected backend. Retina PNG uses twice the + theme DPI. + + Notes + ----- + An explicit save format takes priority over the filename suffix, which + takes priority over this preference. A `.png` suffix retains a `retina` + preference; pass `format="png"` for ordinary resolution. Without a theme + preference, notebook display uses the global option, then IPython's + setting. + + The figure's theme determines the format for all its plots, insets, and + guides. Set a composition's preference through + [](`~plotnine.composition.plot_annotation`) or broadcast it with `&`. + """ + + class figure_size(themeable): """ Figure size in inches diff --git a/tests/test_figure_format.py b/tests/test_figure_format.py new file mode 100644 index 0000000000..adf123e9f3 --- /dev/null +++ b/tests/test_figure_format.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import gzip +import importlib +from base64 import b64decode +from io import BytesIO +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, Literal + +import pytest +from matplotlib.collections import PolyCollection, QuadMesh +from matplotlib.figure import Figure +from PIL import Image + +import plotnine.options as options +from plotnine import ( + aes, + geom_point, + ggplot, + guide_colorbar, + guides, + theme, +) +from plotnine.composition import inset_element, plot_annotation, plot_layout +from plotnine.data import mtcars + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def colour_plot() -> ggplot: + return ( + ggplot(mtcars, aes("wt", "mpg", color="hp")) + + geom_point() + + guides(color=guide_colorbar(display="gradient")) + + theme(figure_size=(3, 2), dpi=72, figure_format="svg") + ) + + +@pytest.fixture +def saved_figures(monkeypatch: pytest.MonkeyPatch) -> list[Figure]: + figures = [] + savefig = Figure.savefig + + def save(figure: Figure, *args: Any, **kwargs: Any) -> None: + figures.append(figure) + savefig(figure, *args, **kwargs) + + monkeypatch.setattr(Figure, "savefig", save) + return figures + + +def test_filename_extension_overrides_theme( + colour_plot: ggplot, tmp_path: Path +) -> None: + filename = tmp_path / "plot.pdf" + + colour_plot.save(filename, verbose=False) + + assert filename.read_bytes().startswith(b"%PDF") + + +def test_theme_format_adds_extension_to_filename_without_one( + colour_plot: ggplot, tmp_path: Path +) -> None: + colour_plot.save(tmp_path / "plot", verbose=False) + + assert b" None: + filename = tmp_path / "plot" + + colour_plot.save(filename, format="pdf", verbose=False) + + assert filename.read_bytes().startswith(b"%PDF") + + +def test_svgz_filename_writes_compressed_svg( + colour_plot: ggplot, tmp_path: Path +) -> None: + filename = tmp_path / "plot.svgz" + + colour_plot.save(filename, verbose=False) + + assert b" None: + composition = (colour_plot | colour_plot) + plot_annotation( + theme=theme(figure_format="pdf") + ) + destination = BytesIO() + + composition.save(destination) + + assert destination.getvalue().startswith(b"%PDF") + + +@pytest.mark.parametrize("display", ["gradient", "raster", "rectangles"]) +def test_pdf_preserves_colourbar_display( + colour_plot: ggplot, + display: Literal["gradient", "raster", "rectangles"], +) -> None: + """ + Preserve each colourbar display mode in PDF output + + SVG-specific rendering must not affect PDF output or change the display + mode stored on the source guide. + """ + p = colour_plot + guides(color=guide_colorbar(display=display)) + view = p.save_helper(BytesIO(), format="pdf", verbose=False) + assert view.kwargs["format"] == "pdf" + meshes = view.figure.findobj(QuadMesh) + if display == "rectangles": + assert view.figure.findobj(PolyCollection) + assert not meshes + else: + assert len(meshes) == 1 + assert meshes[0].get_rasterized() == (display == "raster") + assert p.guides.color.display == display + + +@pytest.mark.parametrize("composed", [False, True]) +@pytest.mark.parametrize( + "filename, format, size", + [ + ("plot.png", None, (432, 288)), + ("plot.png", "png", (216, 144)), + ], +) +def test_retina_save_does_not_change_original_dpi( + colour_plot: ggplot, + tmp_path: Path, + composed: bool, + filename: str | None, + format: str | None, + size: tuple[int, int], +) -> None: + t = theme(figure_format="retina", figure_size=(3, 2), dpi=72) + obj = ( + (colour_plot | colour_plot) + plot_annotation(theme=t) + if composed + else colour_plot + t + ) + for _ in range(2): + destination = tmp_path / filename if filename else BytesIO() + obj.save(destination, format=format, verbose=False) + with Image.open(destination) as image: + assert image.size == size + assert obj.theme.getp("dpi") == 72 + assert obj.theme.getp("figure_format") == "retina" + + +def test_unknown_save_format(colour_plot: ggplot) -> None: + with pytest.raises(ValueError, match="not supported"): + colour_plot.save(BytesIO(), format="unknown", verbose=False) + + +@pytest.mark.parametrize("composed", [False, True]) +@pytest.mark.parametrize( + "preference, option, inline, mimetype", + [ + ("SVG", "png", "jpeg", "image/svg+xml"), + (None, "svg", "png", "image/svg+xml"), + (None, None, "jpeg", "image/jpeg"), + (None, None, None, "image/png"), + ("retina", "svg", None, "image/png"), + ("jpg", "png", None, "image/jpeg"), + ], +) +def test_notebook_format_precedence( + monkeypatch: pytest.MonkeyPatch, + composed: bool, + preference: str | None, + option: str | None, + inline: str | None, + mimetype: str, +) -> None: + """ + Resolve notebook formats without changing the source theme + + Plots and compositions prefer the theme setting, then the global option, + then the IPython setting, and default to retina output. Repeated rendering + must preserve the object's format and DPI settings. + """ + p = ggplot(mtcars, aes("wt", "mpg")) + geom_point() + t = theme(figure_format=preference, figure_size=(3, 2), dpi=72) + obj = (p | p) + plot_annotation(theme=t) if composed else p + t + monkeypatch.setattr(options, "figure_format", option) + module = importlib.import_module( + "plotnine.composition._compose" if composed else "plotnine.ggplot" + ) + ip = SimpleNamespace( + config=SimpleNamespace(InlineBackend={"figure_format": inline}) + ) + monkeypatch.setattr(module, "get_ipython", lambda: ip) + for _ in range(2): + bundle, metadata = obj._repr_mimebundle_() + assert set(bundle) == {mimetype} + if mimetype == "image/svg+xml": + assert " None: + """ + Apply an explicit save format throughout nested compositions + + The requested format controls every guide and inset, whether guides are + collected or kept with their plots. Saving in different formats must + preserve all stored format preferences. + """ + p = colour_plot + guides( + color=guide_colorbar(theme=theme(figure_format="svg")) + ) + inset = p + theme(figure_format="png") + host = p + inset_element(inset, 0.4, 0.4, 1, 1) + inner = (host | p) + plot_annotation(theme=theme(figure_format="png")) + obj = (inner / p) + plot_layout(guides=collection) + obj += plot_annotation(theme=theme(figure_format="svg")) + leaves = list(obj.iter_plots_all()) + preferences = [leaf.theme.getp("figure_format") for leaf in leaves] + for format in ("svg", "pdf"): + obj.save(BytesIO(), format=format) + figure = saved_figures[-1] + if format == "svg": + assert figure.findobj(PolyCollection) + assert not figure.findobj(QuadMesh) + else: + assert len(figure.findobj(QuadMesh)) >= 2 + assert not figure.findobj(PolyCollection) + assert obj.theme.getp("figure_format") == "svg" + assert [leaf.theme.getp("figure_format") for leaf in leaves] == preferences + assert inset.theme.getp("figure_format") == "png" + + +def test_composition_format_overrides_child_preference( + colour_plot: ggplot, +) -> None: + p = colour_plot + guides( + color=guide_colorbar(theme=theme(figure_format="svg")) + ) + obj = p | p + figure = obj.draw() + assert figure.findobj(QuadMesh)