From 936b05728bf4a2beb7e5b432b867d90c0bd945bf Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 21 Sep 2026 15:54:17 +0300 Subject: [PATCH] feat(theme): add a figure format preference Let plot and composition themes select notebook and default save formats while preserving explicit format and filename precedence. Propagate the selected format to nested plots, guides, and insets, and render SVG colourbars as overlapping rectangles without mutating their configured display mode. --- doc/_quartodoc.yml | 1 + doc/changelog.qmd | 9 + plotnine/_utils/__init__.py | 27 +++ plotnine/composition/_compose.py | 40 +++- plotnine/composition/_inset_element.py | 7 +- plotnine/composition/_plot_annotation.py | 7 +- plotnine/ggplot.py | 56 +++-- plotnine/guides/guide.py | 5 +- plotnine/guides/guide_colorbar.py | 15 +- plotnine/options.py | 12 +- plotnine/themes/theme.py | 11 +- plotnine/themes/themeable.py | 26 +++ tests/test_figure_format.py | 264 +++++++++++++++++++++++ 13 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 tests/test_figure_format.py 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)