Source code for vane.viz.publication

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""Publication figure sizes, export and layout helpers.

Journal figures are drawn at the width they are printed: a single column is about
8.5 cm wide and a double column (full page width) about 17 cm. :func:`figure_size`
turns a size request (``"single"``, ``"double"`` or a width in centimetres) into a
:class:`FigureSize` with the figure size and the matching font sizes (axis labels 9 pt,
ticks and legends 8 pt), so that a figure is readable without scaling. Every static
plot function of :mod:`vane.viz` takes such a ``size=`` argument.

:func:`save_figure` writes a figure at a size preset: vector PDF and SVG, and PNG at
600 dpi, for Matplotlib figures; static images for Plotly figures when the optional
``kaleido`` package is installed. :func:`add_panel_labels` puts ``(a)``, ``(b)``, ...
outside the top-left corner of the panels of a multi-panel figure.
"""

from __future__ import annotations

import importlib.util
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any

import plotly.graph_objects as go
from matplotlib.figure import Figure
from matplotlib.transforms import Bbox

if TYPE_CHECKING:
    from collections.abc import Sequence

    from matplotlib.axes import Axes
    from matplotlib.text import Text

__all__ = [
    "SIZE_PRESETS",
    "FigureSize",
    "add_panel_labels",
    "figure_size",
    "save_figure",
]

_CM_PER_INCH = 2.54
# Plotly lays out in CSS pixels (96 per inch); fonts are given in pixels.
_PLOTLY_PX_PER_INCH = 96.0
_PX_PER_PT = _PLOTLY_PX_PER_INCH / 72.0
# Widths (cm) below which the fonts step down by 1 pt, and the default aspect ratios.
_NARROW_CM = 8.0
_WIDE_CM = 12.0
_ASPECT_NARROW = 0.75
_ASPECT_WIDE = 0.47

_FORMATS = ("pdf", "svg", "png", "eps")


[docs] @dataclass(frozen=True) class FigureSize: """A printed figure size with the matching font sizes. Parameters ---------- width_cm : float Figure width in centimetres. height_cm : float Figure height in centimetres. label_pt : float, optional Font size of axis labels and titles, in points. tick_pt : float, optional Font size of tick labels, in points. legend_pt : float, optional Font size of legends and direct labels, in points. """ width_cm: float height_cm: float label_pt: float = 9.0 tick_pt: float = 8.0 legend_pt: float = 8.0 @property def width_in(self) -> float: """Figure width in inches.""" return self.width_cm / _CM_PER_INCH @property def height_in(self) -> float: """Figure height in inches.""" return self.height_cm / _CM_PER_INCH
[docs] def rc(self) -> dict[str, Any]: """Return the Matplotlib ``rcParams`` of this size (figure size and fonts). Returns ------- dict[str, Any] ``figure.figsize`` and the font sizes of labels, titles, ticks and legends. """ return { "figure.figsize": (self.width_in, self.height_in), "font.size": self.label_pt, "axes.labelsize": self.label_pt, "axes.titlesize": self.label_pt, "figure.titlesize": self.label_pt, "xtick.labelsize": self.tick_pt, "ytick.labelsize": self.tick_pt, "legend.fontsize": self.legend_pt, "legend.title_fontsize": self.legend_pt, }
#: The named size presets: one journal column (8.5 cm) and the full page width (17 cm). SIZE_PRESETS: dict[str, FigureSize] = { "single": FigureSize(8.5, 8.5 * _ASPECT_NARROW), "double": FigureSize(17.0, 17.0 * _ASPECT_WIDE), }
[docs] def figure_size( size: str | float | FigureSize, *, aspect: float | None = None ) -> FigureSize: """Resolve a size request into a :class:`FigureSize`. Parameters ---------- size : str, float or FigureSize ``"single"`` (8.5 cm wide), ``"double"`` (17 cm wide), a width in centimetres, or a :class:`FigureSize`, returned unchanged unless ``aspect`` is given. aspect : float or None, optional Height-to-width ratio. ``None`` uses 0.75 below 12 cm width and 0.47 above (the presets' ratios). Returns ------- FigureSize Width, height and font sizes. Below 8 cm width the fonts are 1 pt smaller (8 pt labels, 7 pt ticks and legends). Raises ------ ValueError If ``size`` is an unknown name or a non-positive width, or ``aspect`` is not positive. """ if aspect is not None and not aspect > 0.0: msg = f"aspect must be positive, got {aspect}" raise ValueError(msg) if isinstance(size, FigureSize): resolved = size elif isinstance(size, str): if size not in SIZE_PRESETS: names = ", ".join(repr(name) for name in SIZE_PRESETS) msg = f"unknown figure size {size!r}; use {names} or a width in cm" raise ValueError(msg) resolved = SIZE_PRESETS[size] else: width = float(size) if not width > 0.0: msg = f"figure width must be positive, got {size} cm" raise ValueError(msg) ratio = _ASPECT_NARROW if width < _WIDE_CM else _ASPECT_WIDE if width < _NARROW_CM: resolved = FigureSize(width, width * ratio, 8.0, 7.0, 7.0) else: resolved = FigureSize(width, width * ratio) if aspect is not None: resolved = replace(resolved, height_cm=resolved.width_cm * aspect) return resolved
[docs] def add_panel_labels( axes: Sequence[Axes], labels: Sequence[str] | None = None ) -> list[Text]: """Label the panels of a figure ``(a)``, ``(b)``, ... outside their top-left corner. Parameters ---------- axes : Sequence[matplotlib.axes.Axes] The panels, in reading order. labels : Sequence[str] or None, optional The labels; ``None`` gives ``(a)``, ``(b)``, ``(c)``, ... Returns ------- list[matplotlib.text.Text] The label texts. Raises ------ ValueError If ``labels`` does not have one entry per panel. """ names = ( [f"({chr(ord('a') + index)})" for index in range(len(axes))] if labels is None else list(labels) ) if len(names) != len(axes): msg = f"got {len(names)} panel labels for {len(axes)} panels" raise ValueError(msg) return [ panel.annotate( name, xy=(0.0, 1.0), xycoords="axes fraction", xytext=(-3.0, 4.0), textcoords="offset points", ha="right", va="bottom", fontweight="bold", ) for panel, name in zip(axes, names, strict=True) ]
def _kaleido_available() -> bool: """Return whether the optional ``kaleido`` package (Plotly export) is installed.""" return importlib.util.find_spec("kaleido") is not None def _output_paths(path: Path, formats: Sequence[str]) -> list[Path]: """Return the files to write: the path's own suffix, or one per format.""" if path.suffix: suffix = path.suffix.lower().lstrip(".") if suffix not in _FORMATS: msg = f"unsupported figure format {path.suffix!r}; use one of {_FORMATS}" raise ValueError(msg) return [path] unknown = [fmt for fmt in formats if fmt.lower() not in _FORMATS] if unknown: msg = f"unsupported figure format(s) {unknown}; use one of {_FORMATS}" raise ValueError(msg) return [path.with_suffix(f".{fmt.lower()}") for fmt in formats] def _plotly_sized(figure: go.Figure, size: FigureSize) -> go.Figure: """Return a copy of a Plotly figure laid out at a printed size.""" sized = go.Figure(figure) tick = {"tickfont": {"size": size.tick_pt * _PX_PER_PT}} sized.update_layout( width=round(size.width_in * _PLOTLY_PX_PER_INCH), height=round(size.height_in * _PLOTLY_PX_PER_INCH), font={"size": size.label_pt * _PX_PER_PT}, legend={"font": {"size": size.legend_pt * _PX_PER_PT}}, margin={"l": 50, "r": 10, "t": 30, "b": 45}, ) sized.update_xaxes(**tick) sized.update_yaxes(**tick) return sized
[docs] def save_figure( figure: Figure | go.Figure, path: str | Path, *, size: str | float | FigureSize | None = None, dpi: int = 600, formats: Sequence[str] = ("pdf", "svg", "png"), ) -> list[Path]: """Write a figure for publication at a size preset. Parameters ---------- figure : matplotlib.figure.Figure or plotly.graph_objects.Figure The figure to write. path : str or pathlib.Path Destination. With a suffix (``.pdf``, ``.svg``, ``.png`` or ``.eps``) only that file is written; without one, one file per entry of ``formats``. Parent directories are created. size : str, float, FigureSize or None, optional Size preset (see :func:`figure_size`). ``None`` keeps the figure's own size. The file has exactly this size (the page is not trimmed to the content). A Matplotlib figure is resized; its fonts keep the sizes it was drawn with, so pass the same ``size=`` to the plot function to get matching fonts. A Plotly figure is laid out at the preset size and fonts. dpi : int, optional Resolution of raster (PNG) output. Default 600. formats : Sequence[str], optional Formats written when ``path`` has no suffix. Default PDF, SVG and PNG. Returns ------- list[pathlib.Path] The files written. Raises ------ ValueError If a format is not supported or the size is invalid. ImportError If ``figure`` is a Plotly figure and the ``kaleido`` package, which Plotly needs for static images, is not installed. TypeError If ``figure`` is neither a Matplotlib nor a Plotly figure. """ targets = _output_paths(Path(path), formats) resolved = None if size is None else figure_size(size) if isinstance(figure, Figure): if resolved is not None: figure.set_size_inches(resolved.width_in, resolved.height_in) # The whole figure, at exactly its size: a "tight" bounding box (the style's # default for savefig) would change the printed size. width, height = figure.get_size_inches() page = Bbox.from_bounds(0.0, 0.0, float(width), float(height)) for target in targets: target.parent.mkdir(parents=True, exist_ok=True) figure.savefig( target, dpi=dpi, format=target.suffix.lstrip(".").lower(), bbox_inches=page, ) return targets if isinstance(figure, go.Figure): if not _kaleido_available(): msg = ( "static export of Plotly figures needs the optional 'kaleido' " "package; install it with: pip install kaleido" ) raise ImportError(msg) sized = figure if resolved is None else _plotly_sized(figure, resolved) for target in targets: target.parent.mkdir(parents=True, exist_ok=True) fmt = target.suffix.lstrip(".").lower() scale = dpi / _PLOTLY_PX_PER_INCH if fmt == "png" else 1.0 sized.write_image(str(target), format=fmt, scale=scale) return targets msg = ( "save_figure expects a Matplotlib or Plotly figure, got " f"{type(figure).__name__}" ) raise TypeError(msg)