"""Shared figure-style values and rendering conventions.
Figure-specific style dataclasses inherit :class:`FigureStyle` so common
controls have the same name and can be copied between plots. Renderers use
the signature ``render(data, style, *, figure=None, save_path=None)`` and
return ``(figure, axes)``. Passing ``figure`` redraws an existing canvas;
passing ``save_path`` also writes the result with spaCR's export settings.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import Any, ClassVar, Dict, List, Optional, Tuple
from .figure_font import FAMILY as FONT_FAMILY
from .figure_font import use_open_sans_for_figures
#: Axis scales any figure may use.
SCALES: Tuple[str, ...] = ("linear", "log", "symlog", "logit")
#: Where a grid may be drawn.
GRID_AXES: Tuple[str, ...] = ("x", "y", "both", "none")
#: Families matplotlib resolves through its own rcParam lists rather than by
#: name, so they are passed through untouched.
GENERIC_FAMILIES: Tuple[str, ...] = ("serif", "monospace", "cursive",
"fantasy")
@dataclass
#: The closed sets every figure shares. A subclass extends rather than
#: replaces it -- `dict(SHARED_CHOICES, **{...})` -- so a figure type cannot
#: silently lose the general ones.
SHARED_CHOICES: Dict[str, Tuple[str, ...]] = {
"x_scale": SCALES,
"y_scale": SCALES,
"grid_axis": GRID_AXES,
"font_weight": ("normal", "bold", "light"),
"legend_location": ("best", "upper right", "upper left", "lower left",
"lower right", "right", "center left",
"center right", "lower center", "upper center",
"center"),
}
FigureStyle.CHOICES = dict(SHARED_CHOICES)
[docs]
def style_kind(style: Any) -> str:
"""Return a stable figure kind derived from a style class name.
:param style: style instance whose class name identifies the figure kind.
For example, ``VolcanoStyle`` becomes ``"volcano"``. Deriving the value
prevents independently declared names from colliding and keeps this
headless helper independent of the Qt plotting widgets.
"""
name = type(style).__name__
if name.endswith("Style"):
name = name[:-len("Style")]
return name.lower() or "figure"
[docs]
def font_names(style: FigureStyle) -> List[str]:
"""The families to ask matplotlib for, best first, face REGISTERED.
:param style: figure style whose ``font_family`` is the first choice.
:returns: a family list suitable for ``font.family``, a ``FontProperties``
or :meth:`matplotlib.text.Text.set_fontfamily`. A generic family is
returned alone, because matplotlib resolves it through its own list.
Naming a family is not the same as having it. Matplotlib answers a name it
cannot find by falling back -- silently, bar a ``findfont`` warning -- to
DejaVu Sans, so a style saying "Open Sans" on a machine where Open Sans
was never installed drew in DejaVu Sans and looked nothing like the
interface around it. This REGISTERS the faces spaCR ships before naming
them, which is what makes the name resolve on that machine, and puts
:data:`FONT_FAMILY` last so a chosen family the machine lacks lands on the
house face instead of DejaVu Sans.
Nothing is listed after :data:`FONT_FAMILY`: it is registered from a file
in the package, so it always resolves, and an unreachable name in the list
would only make matplotlib warn once per drawn string about a font it was
never going to use.
"""
use_open_sans_for_figures()
requested = str(getattr(style, "font_family", "") or "").strip()
if requested in GENERIC_FAMILIES:
return [requested]
names = [requested] if requested and requested != "sans-serif" else []
if FONT_FAMILY not in names:
names.append(FONT_FAMILY)
return names
[docs]
def font_rc(style: FigureStyle) -> Dict[str, Any]:
"""The Matplotlib font parameters a style asks for.
:param style: figure style supplying family, size and weight.
:returns: ``rcParams`` entries to draw inside, as
``matplotlib.rc_context`` takes them.
Hand this to ``rc_context`` rather than assembling ``font.*`` by hand: the
family arrives as the resolvable list :func:`font_names` builds, with the
bundled face already registered.
"""
names = font_names(style)
params: Dict[str, Any] = {
"font.family": list(names),
"font.size": float(style.font_size),
"font.weight": str(style.font_weight),
}
if names and names[0] not in GENERIC_FAMILIES:
params["font.sans-serif"] = list(names)
return params
[docs]
def apply_page(figure, axes, style: FigureStyle) -> None:
"""Apply shared axes, typography, grid, spine, and page settings.
:param figure: Matplotlib figure whose page appearance is updated.
:param axes: Matplotlib axes whose presentation is updated.
:param style: shared figure-style settings to apply.
Call this after drawing plot-specific marks. It changes figure and axes
presentation only; it does not add or remove data marks. The title, the
axis labels and the tick labels are put into the style's family --
:func:`font_names` -- so a renderer that draws outside a font
``rc_context`` still gets the face spaCR ships rather than DejaVu Sans.
"""
figure.set_size_inches(float(style.figure_width),
float(style.figure_height))
if style.title:
axes.set_title(style.title, fontsize=style.title_font_size,
fontweight=style.font_weight)
if style.x_label:
axes.set_xlabel(style.x_label, fontsize=style.label_font_size)
if style.y_label:
axes.set_ylabel(style.y_label, fontsize=style.label_font_size)
for name, scale in (("x", style.x_scale), ("y", style.y_scale)):
if scale and scale != "linear":
try:
getattr(axes, f"set_{name}scale")(scale)
except Exception: # noqa: BLE001
continue
if style.x_lim:
axes.set_xlim(*style.x_lim)
if style.y_lim:
axes.set_ylim(*style.y_lim)
if style.invert_x:
axes.invert_xaxis()
if style.invert_y:
axes.invert_yaxis()
axes.tick_params(labelsize=style.tick_font_size)
names = font_names(style)
for text in (axes.title, axes.xaxis.label, axes.yaxis.label,
*axes.get_xticklabels(), *axes.get_yticklabels()):
text.set_fontfamily(list(names))
wanted = bool(style.grid) and str(style.grid_axis) != "none"
if wanted:
axes.grid(True, axis=str(style.grid_axis or "y"),
color=style.grid_color, linewidth=style.grid_width)
axes.set_axisbelow(True)
else:
axes.grid(False)
for side in ("top", "right"):
axes.spines[side].set_visible(not style.hide_top_right_spines)
if str(style.background_color or "none") != "none":
figure.patch.set_facecolor(style.background_color)
axes.set_facecolor(style.background_color)
[docs]
def write(figure, save_path, style: FigureStyle) -> str:
"""Write a styled figure with spaCR's standard export pipeline.
:param figure: drawn Matplotlib figure to export.
:param save_path: destination path; its suffix selects the format.
:param style: figure-style export settings, including DPI and
transparency.
The extension in ``save_path`` selects the format. Raster outputs use the
style's DPI; font embedding, paper repainting, transparency, and bounding
box behavior are delegated to :func:`spacr.plot.save_figure`.
"""
import os
from .plot import save_figure
path = os.fspath(save_path)
parent = os.path.dirname(os.path.abspath(path))
os.makedirs(parent, exist_ok=True)
suffix = os.path.splitext(path)[1].lstrip(".").lower() or None
raster = suffix in ("png", "jpg", "jpeg", "tif", "tiff")
return save_figure(figure, path, fmt=suffix,
dpi=style.dpi if raster else None,
transparent=style.transparent, bbox_inches="tight")