Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/_quartodoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ quartodoc:
- axis_title_y_left
- axis_title_y_right
- dpi
- figure_format
- figure_size
- legend_background
- legend_box
Expand Down
9 changes: 9 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions plotnine/_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
40 changes: 30 additions & 10 deletions plotnine/composition/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -844,25 +849,40 @@ 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`.
**kwargs :
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()

Expand Down
7 changes: 3 additions & 4 deletions plotnine/composition/_inset_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions plotnine/composition/_plot_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 40 additions & 16 deletions plotnine/ggplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from ._utils import (
from_inches,
get_save_format,
is_data_like,
order_as_data_mapping,
to_inches,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -809,8 +827,12 @@ def save(
File name to write the plot to. If not specified, a name
like “plotnine-save-<hash>.<format>” 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).
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions plotnine/guides/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 6 additions & 9 deletions plotnine/guides/guide_colorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions plotnine/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"""
Expand Down
Loading
Loading