Add plotstyle: reusable KIT-branded matplotlib styling toolkit

Introduces a new plotstyle package providing consistent, presentation/thesis-ready
matplotlib figures: a KIT corporate-design color palette (categorical, sequential,
diverging, status), a theme applied via use() (KIT-black bottom spine only, left/bottom
ticks, horizontal gridlines, left-aligned titles, LaTeX text in Latin Modern Sans),
new_figure() with size presets and figure-level title/params subtitles, a
same-size colorbar() helper, style_legend() and panel_label() building blocks, and
savefig(). Ships as an optional "plotting" extra so core gallery installs stay
lightweight, with a full test suite and a runnable example notebook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 14:19:30 +02:00
parent 8cae6533d5
commit 36ae18e880
11 changed files with 3164 additions and 5 deletions
+1
View File
@@ -2,6 +2,7 @@
.vscode
*.sif
*.ipynb
!examples/*.ipynb
backups
.pytest_cache
.venv
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
"""Reusable matplotlib styling and building blocks for consistent, modern
scientific plots across presentations and thesis figures.
import plotstyle as ps
ps.use()
fig, ax = ps.new_figure("thesis-single")
ax.plot(x, y, label="A")
ps.style_legend(ax)
ps.savefig(fig, "plots/my_plot", formats=("pdf", "png"))
"""
from . import colors
from .annotations import panel_label, style_legend
from .colors import get_color
from .figures import FIGSIZES, colorbar, new_figure, savefig
from .style import reset, use
__all__ = [
"colors",
"get_color",
"use",
"reset",
"new_figure",
"colorbar",
"savefig",
"FIGSIZES",
"style_legend",
"panel_label",
]
+97
View File
@@ -0,0 +1,97 @@
"""Legend and panel-label helpers for consistent multi-panel figures."""
from __future__ import annotations
import warnings
from matplotlib.axes import Axes
from matplotlib.colors import to_rgba
from .colors import INK
_PANEL_LOCS = {
"upper left": (0.02, 0.98, "left", "top"),
"upper right": (0.98, 0.98, "right", "top"),
"lower left": (0.02, 0.08, "left", "bottom"),
"lower right": (0.98, 0.08, "right", "bottom"),
}
def style_legend(
ax: Axes,
loc: str = "outside right upper",
frameon: bool = False,
title: str = None,
**kwargs,
):
"""Add a figure-level legend, placed outside the axes by default.
Builds on `ax`'s handles/labels (or explicit `handles=`/`labels=` kwargs)
but attaches the legend to `ax`'s figure via `fig.legend(...)`, so it sits
outside the plot area rather than overlapping the data.
A `title` is strongly encouraged: an untitled legend floating outside the
axes loses its visual link to what it's describing, e.g.
`style_legend(ax, title="Series")`. Without one, this emits a warning
rather than failing — the legend still renders.
"""
if title is None:
warnings.warn(
"style_legend() called without a title — an outside legend reads better with one, "
"e.g. style_legend(ax, title='Series').",
stacklevel=2,
)
fig = ax.get_figure()
handles = kwargs.pop("handles", None)
labels = kwargs.pop("labels", None)
if handles is None or labels is None:
handles, labels = ax.get_legend_handles_labels()
legend = fig.legend(handles, labels, loc=loc, frameon=frameon, title=title, **kwargs)
if legend.get_title() is not None:
legend.get_title().set_fontweight("bold")
return legend
def panel_label(
ax: Axes,
label: str,
loc: str = "lower right",
fontweight: str = "bold",
box: bool = True,
**kwargs,
):
"""Add a panel label like "(a)" for multi-panel thesis/paper figures.
Defaults to the bottom-right corner, colored to match the axes' xlabel,
on a light-grey semi-transparent rounded box with a slim solid border.
Pass `box=False` for bare text with no box.
"""
try:
x, y, ha, va = _PANEL_LOCS[loc]
except KeyError as exc:
raise ValueError(f"Unknown panel_label loc {loc!r}. Choose from {sorted(_PANEL_LOCS)}.") from exc
kwargs.setdefault("color", ax.xaxis.label.get_color())
if box:
kwargs.setdefault(
"bbox",
dict(
boxstyle="round,pad=0.3",
facecolor=to_rgba(INK["gridline"], alpha=0.8),
edgecolor=INK["baseline"],
linewidth=0.8,
),
)
return ax.text(
x,
y,
f"({label})",
transform=ax.transAxes,
ha=ha,
va=va,
fontweight=fontweight,
**kwargs,
)
+80
View File
@@ -0,0 +1,80 @@
# Base rcParams for plotstyle. Loaded via plt.style.use() from style.use().
# Colors here mirror plotstyle.colors.INK (KIT black-70% gray family on white) — kept in sync by hand.
figure.facecolor: ffffff
figure.edgecolor: ffffff
figure.constrained_layout.use: True
axes.facecolor: ffffff
# axes.edgecolor/linewidth style the one visible spine (bottom — the rest are
# off below), so this is really "the bottom spine is black and heavier", not
# a general axes outline color.
axes.edgecolor: 000000
axes.linewidth: 1.25
axes.labelcolor: 6a6a6a
axes.titlecolor: 404040
axes.titleweight: bold
axes.titlelocation: left
axes.grid: True
axes.grid.axis: y
axes.axisbelow: True
axes.spines.top: False
axes.spines.right: False
axes.spines.left: False
axes.spines.bottom: True
grid.color: ececec
grid.linewidth: 0.8
grid.alpha: 1.0
xtick.color: 969696
ytick.color: 969696
# Major tick labels read as the primary ("black") ink; minor tick labels are
# muted grey. rcParams only expose one labelcolor per axis (no major/minor
# split) — the per-major/minor distinction is applied in code by
# plotstyle.figures.new_figure() via ax.tick_params(which=...). These values
# are just the fallback/default for axes that bypass new_figure().
xtick.labelcolor: 404040
ytick.labelcolor: 404040
xtick.direction: out
ytick.direction: out
xtick.bottom: True
xtick.top: False
ytick.left: True
ytick.right: False
# Minor ticks: x-axis only. The y-axis already has horizontal gridlines at
# major ticks, so y minor ticks would just add unlabeled clutter.
xtick.minor.visible: True
ytick.minor.visible: False
xtick.major.size: 6.0
xtick.minor.size: 3.0
xtick.major.width: 0.8
xtick.minor.width: 0.6
ytick.major.size: 6.0
ytick.minor.size: 3.0
ytick.major.width: 0.8
ytick.minor.width: 0.6
lines.linewidth: 2.0
lines.markersize: 6.0
lines.solid_capstyle: round
font.family: sans-serif
font.sans-serif: DejaVu Sans, Arial, Helvetica, sans-serif
font.size: 11
axes.titlesize: 13
axes.labelsize: 11
xtick.labelsize: 10
ytick.labelsize: 10
legend.fontsize: 10
legend.frameon: False
legend.handlelength: 1.6
legend.labelspacing: 0.4
legend.title_fontsize: 10
savefig.facecolor: ffffff
savefig.edgecolor: ffffff
savefig.dpi: 300
savefig.bbox: tight
savefig.pad_inches: 0.05
+102
View File
@@ -0,0 +1,102 @@
"""KIT (Karlsruhe Institute of Technology) corporate design color palette.
Hex values for KIT green, KIT blue, black 70%, and the corporate accent
colors are taken verbatim from the KIT corporate design guide
(https://kit-cd.km.kit.edu/english/341.php) — do not hand-edit them without
checking that page.
The categorical *order* below is not arbitrary: it was chosen by running
every hue through the CVD-safety/contrast checks described in the `dataviz`
skill (fixed hue order, OKLab CVD separation under simulated color-vision
deficiency, a normal-vision separation floor, contrast vs. a white surface)
and keeping the ordering that clears the adjacent-pair checks. KIT yellow
(#FCE500) is the one hue that cannot pass on its own (too light on a white
surface, ~1.3:1 contrast) — that is a property of the hex value itself, not
the ordering, so it is placed last and should always be paired with a
visible direct label rather than relied on as a fill alone.
"""
from __future__ import annotations
from matplotlib.colors import LinearSegmentedColormap
# Fixed-order categorical hues (KIT primary + accent colors). Order is the
# CVD-safety mechanism: never reorder, and never cycle past the last slot
# (fold extra series into "Other").
CATEGORICAL = [
"#009682", # 0 KIT green (primary)
"#DF9B1B", # 1 orange
"#4664AA", # 2 KIT blue (primary)
"#A78230", # 3 brown
"#23A1E0", # 4 cyan
"#A3107C", # 5 purple
"#8CB63C", # 6 pea green
"#A22223", # 7 red
"#FCE500", # 8 yellow — low contrast on white; always pair with a direct label
]
# Single-hue (KIT blue) sequential ramp, steps 100..700. Step 700 is the exact
# brand hex (the high/saturated end); lighter steps are tints blended toward
# white in sRGB — there is no darker-than-brand shade.
SEQUENTIAL_STEPS = [
"#e3e8f2", # 100
"#c9d2e6", # 200
"#afbcda", # 300
"#95a6ce", # 400
"#7a90c2", # 500
"#607ab6", # 600
"#4664AA", # 700 (KIT blue)
]
# Diverging KIT blue <-> KIT red, neutral gray midpoint.
DIVERGING = {
"low": "#4664AA",
"mid": "#f7f7f7",
"high": "#A22223",
}
# Fixed, reserved status scale — deliberately NOT re-themed to KIT colors:
# status is a small fixed scale with reserved meaning that must stay visually
# distinct from the categorical slots so it never impersonates a series.
# Never put these in the categorical cycle; always pair with an icon/label.
STATUS = {
"good": "#0ca30c",
"warning": "#fab219",
"serious": "#ec835a",
"critical": "#d03b3b",
}
# Chrome / ink roles, derived from KIT's specified "black 70%" (#404040,
# used by KIT for headings and continuous text) on a white surface.
INK = {
"surface": "#ffffff",
"primary": "#404040", # KIT black 70% — headings, titles, continuous text
"secondary": "#6a6a6a",
"muted": "#969696",
"gridline": "#ececec",
"baseline": "#c2c2c2",
}
def get_color(index: int) -> str:
"""Return the categorical color for series `index` (0-based).
Raises ValueError past the validated slots instead of silently wrapping
back to slot 0, which would collide two series on the same hue.
"""
if not 0 <= index < len(CATEGORICAL):
raise ValueError(
f"get_color({index}) out of range: only {len(CATEGORICAL)} validated categorical "
"colors exist. Fold extra series into an 'Other' bucket or facet instead of cycling."
)
return CATEGORICAL[index]
def sequential_cmap(name: str = "ps_sequential") -> LinearSegmentedColormap:
"""Continuous KIT-blue sequential colormap for magnitude encoding."""
return LinearSegmentedColormap.from_list(name, SEQUENTIAL_STEPS)
def diverging_cmap(name: str = "ps_diverging") -> LinearSegmentedColormap:
"""Continuous KIT blue-gray-red diverging colormap for polarity encoding."""
return LinearSegmentedColormap.from_list(name, [DIVERGING["low"], DIVERGING["mid"], DIVERGING["high"]])
+127
View File
@@ -0,0 +1,127 @@
"""Figure creation, colorbar, and saving helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Mapping, Sequence, Union
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
from matplotlib.colorbar import Colorbar
from matplotlib.figure import Figure
from mpl_toolkits.axes_grid1 import make_axes_locatable
from .colors import INK
FIGSIZES = {
"thesis-single": (6.0, 4.0),
"thesis-wide": (8.0, 4.5),
"slide-16x9": (10.0, 5.625),
"square": (5.0, 5.0),
}
def _style_ticks(ax: Axes) -> None:
"""Major tick labels read as primary ("black") ink; minor as muted grey.
rcParams only expose a single labelcolor per axis (no major/minor split),
so this distinction has to be applied per-Axes in code.
"""
ax.tick_params(axis="both", which="major", labelcolor=INK["primary"])
ax.tick_params(axis="both", which="minor", labelcolor=INK["muted"])
def _format_params(params: Mapping) -> str:
return " | ".join(f"{key}: {value}" for key, value in params.items())
def _set_figure_title(fig: Figure, title: Union[str, None], params: Union[Mapping, None]) -> None:
# A single Text artist gets one color under matplotlib's usetex rendering
# (dvipng rasterizes it as one greyscale mask, tinted uniformly — any
# in-source \color/\textcolor is ignored), so the subtitle is set apart
# from the title by size (\small) only, not color.
lines = []
if title is not None:
lines.append(r"\textbf{" + title + "}")
if params:
lines.append(r"{\small " + _format_params(params) + "}")
if lines:
fig.suptitle("\n".join(lines), x=0.0, ha="left")
def new_figure(
preset: Union[str, tuple] = "thesis-single",
*,
title: str = None,
params: Mapping = None,
**subplots_kwargs,
):
"""Create a figure/axes pair sized for a named preset or an explicit (w, h) tuple.
Presets (inches): thesis-single, thesis-wide, slide-16x9, square.
`title` sets a left-aligned, bold figure-level title (`fig.suptitle`) —
this is preferred over an axes title even when there's a single axes, so
it stays consistent for single- and multi-panel figures alike. `params`
is an optional dict rendered as a smaller, muted subtitle line below the
title, formatted as "key1: value1 | key2: value2 | ...".
"""
if isinstance(preset, str):
try:
figsize = FIGSIZES[preset]
except KeyError as exc:
raise ValueError(
f"Unknown figure preset {preset!r}. Choose from {sorted(FIGSIZES)} or pass an (w, h) tuple."
) from exc
else:
figsize = preset
subplots_kwargs.setdefault("figsize", figsize)
fig, axes = plt.subplots(**subplots_kwargs)
for ax in [axes] if isinstance(axes, Axes) else np.ravel(axes):
_style_ticks(ax)
_set_figure_title(fig, title, params)
return fig, axes
def colorbar(mappable, ax: Axes, size: str = "5%", pad: float = 0.05, **kwargs) -> Colorbar:
"""Add a colorbar matched to `ax`'s actual on-screen size.
`fig.colorbar(mappable, ax=ax)` sizes the colorbar to the axes' nominal
bounding box, which is taller than the axes once things like
`ax.set_aspect("equal")` have visually shrunk it (e.g. a non-square
`imshow`). This appends a same-size axes via `make_axes_locatable`
instead, so the colorbar always matches what's actually drawn.
"""
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size=size, pad=pad)
cb = ax.get_figure().colorbar(mappable, cax=cax, **kwargs)
# Colorbar draws its own border (a dedicated "outline" spine) that isn't
# covered by axes.spines.{left,right,top} — without this it'd pick up
# the bold black bottom-spine color/width from the main theme as a box
# around the whole colorbar, which reads as a stray, heavier-than-intended
# edge rather than the plain baseline it's styled to be elsewhere.
cb.outline.set_visible(False)
return cb
def savefig(fig: Figure, path: Union[str, Path], formats: Sequence[str] = ("pdf",), dpi: int = 300) -> list[Path]:
"""Save `fig` to `path` once per format, creating parent directories as needed.
`path` should have no extension — it's appended per format, e.g.
savefig(fig, "plots/my_plot", formats=("pdf", "png")) writes
plots/my_plot.pdf and plots/my_plot.png.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
written = []
for fmt in formats:
out_path = path.with_suffix(f".{fmt}")
fig.savefig(out_path, format=fmt, dpi=dpi)
written.append(out_path)
return written
+64
View File
@@ -0,0 +1,64 @@
"""Apply the plotstyle rcParams theme.
Text (titles, axis labels, legends, tick labels, annotations — the entirety
of every string) is always rendered through a real LaTeX toolchain, using
Latin Modern Sans as a modern sans-serif LaTeX font (including math mode, via
`sfmath` — otherwise tick numbers fall back to a serif math font even with
`\\familydefault` set to sans). `fontenc`'s T1 encoding is loaded too — without
it, some plain ASCII characters (e.g. "|") render as the wrong glyph under
OT1, LaTeX's default. This requires a working local `latex`/`dvipng` install;
there is no mathtext fallback.
Note: matplotlib's usetex rendering rasterizes each Text artist as a single
greyscale glyph mask via dvipng and then tints the *whole* thing with that
artist's one `color` — any in-source `\\color`/`\\textcolor` command is
ignored. So two differently-colored spans (e.g. a black title next to a grey
subtitle) can't live in one Text object; `figures.new_figure()`'s title/params
handling only varies font size (`\\small`) between lines, not color, for
exactly this reason.
"""
from __future__ import annotations
from cycler import cycler
from importlib import resources
import matplotlib.pyplot as plt
from .colors import CATEGORICAL
_LINESTYLES = ["-", "--", "-.", ":"]
_LATEX_PREAMBLE = (
r"\usepackage[T1]{fontenc}\usepackage{amsmath}\usepackage{lmodern}\usepackage{sfmath}"
r"\renewcommand{\familydefault}{\sfdefault}"
)
def use(cycle_linestyles: bool = False) -> None:
"""Apply the plotstyle theme to matplotlib's global rcParams.
Call once at the top of a plotting script, before creating any figures.
By default `axes.prop_cycle` only cycles color (all lines solid) — pass
`cycle_linestyles=True` to also cycle through a repeating linestyle
sequence, so series stay distinguishable even if color is lost
(grayscale printing, projector glare, color-vision deficiency).
"""
style_path = resources.files("plotstyle").joinpath("assets", "plotstyle.mplstyle")
plt.style.use(str(style_path))
if cycle_linestyles:
n = len(CATEGORICAL)
linestyles = (_LINESTYLES * (n // len(_LINESTYLES) + 1))[:n]
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL) + cycler(linestyle=linestyles)
else:
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL)
plt.rcParams["text.usetex"] = True
plt.rcParams["text.latex.preamble"] = _LATEX_PREAMBLE
def reset() -> None:
"""Restore matplotlib defaults (useful between tests/notebook cells)."""
plt.rcdefaults()
+5 -2
View File
@@ -50,13 +50,16 @@ dev = [
"pylint>=2.0",
"mypy>=0.900",
]
plotting = [
"matplotlib>=3.7",
]
[project.scripts]
gallery = "gallery.cli:main"
[tool.setuptools]
packages = ["gallery", "gallery.utils", "gallery.config"]
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"]}
packages = ["gallery", "gallery.utils", "gallery.config", "plotstyle"]
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"], plotstyle = ["assets/*.mplstyle"]}
include-package-data = true
[tool.black]
+310
View File
@@ -0,0 +1,310 @@
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pytest
from matplotlib.colors import Colormap
import plotstyle as ps
from plotstyle import colors
def test_categorical_palette_matches_kit_hex():
assert colors.CATEGORICAL == [
"#009682",
"#DF9B1B",
"#4664AA",
"#A78230",
"#23A1E0",
"#A3107C",
"#8CB63C",
"#A22223",
"#FCE500",
]
def test_diverging_palette_matches_kit_hex():
assert colors.DIVERGING == {"low": "#4664AA", "mid": "#f7f7f7", "high": "#A22223"}
def test_status_palette_matches_validated_hex():
assert colors.STATUS == {
"good": "#0ca30c",
"warning": "#fab219",
"serious": "#ec835a",
"critical": "#d03b3b",
}
def test_get_color_returns_categorical_slot():
assert colors.get_color(0) == colors.CATEGORICAL[0]
assert colors.get_color(len(colors.CATEGORICAL) - 1) == colors.CATEGORICAL[-1]
def test_get_color_out_of_range_raises():
with pytest.raises(ValueError):
colors.get_color(len(colors.CATEGORICAL))
def test_sequential_steps_are_kit_blue_variations():
assert colors.SEQUENTIAL_STEPS[-1].lower() == "#4664aa"
# Lightest step should be a lighter (higher-lightness) tint than the brand color.
import colorsys
def lightness(hex_color):
r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
return colorsys.rgb_to_hls(r, g, b)[1]
assert lightness(colors.SEQUENTIAL_STEPS[0]) > lightness(colors.SEQUENTIAL_STEPS[-1])
def test_sequential_cmap_is_usable_colormap():
cmap = colors.sequential_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_diverging_cmap_is_usable_colormap():
cmap = colors.diverging_cmap()
assert isinstance(cmap, Colormap)
assert cmap(0.0) != cmap(1.0)
def test_use_sets_expected_rcparams():
ps.use()
try:
assert plt.rcParams["axes.facecolor"] == "#ffffff"
assert plt.rcParams["text.usetex"] is True
assert "lmodern" in plt.rcParams["text.latex.preamble"]
assert "sfmath" in plt.rcParams["text.latex.preamble"]
assert "fontenc" in plt.rcParams["text.latex.preamble"]
assert r"\sfdefault" in plt.rcParams["text.latex.preamble"]
cycle_colors = [entry["color"] for entry in plt.rcParams["axes.prop_cycle"]]
assert cycle_colors == colors.CATEGORICAL
assert plt.rcParams["axes.spines.top"] is False
assert plt.rcParams["axes.spines.right"] is False
assert plt.rcParams["axes.spines.left"] is False
assert plt.rcParams["axes.spines.bottom"] is True
assert plt.rcParams["xtick.bottom"] is True
assert plt.rcParams["ytick.left"] is True
assert plt.rcParams["axes.edgecolor"] == "#000000"
assert plt.rcParams["axes.linewidth"] > 0.8
assert plt.rcParams["axes.grid"] is True
assert plt.rcParams["axes.grid.axis"] == "y"
assert plt.rcParams["axes.titlelocation"] == "left"
assert plt.rcParams["xtick.minor.visible"] is True
assert plt.rcParams["ytick.minor.visible"] is False
assert plt.rcParams["xtick.major.size"] > plt.rcParams["xtick.minor.size"]
finally:
ps.reset()
def test_use_default_does_not_cycle_linestyles():
ps.use()
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color"}
finally:
ps.reset()
def test_use_cycle_linestyles_opt_in():
ps.use(cycle_linestyles=True)
try:
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
assert cycle_keys == {"color", "linestyle"}
linestyles = {entry["linestyle"] for entry in plt.rcParams["axes.prop_cycle"]}
assert len(linestyles) > 1
finally:
ps.reset()
def test_new_figure_styles_major_and_minor_tick_labels_differently():
fig, ax = ps.new_figure("square")
try:
assert ax.xaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.xaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
assert ax.yaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
assert ax.yaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
finally:
plt.close(fig)
def test_new_figure_preset_sizes():
fig, ax = ps.new_figure("thesis-single")
try:
assert tuple(fig.get_size_inches()) == ps.FIGSIZES["thesis-single"]
finally:
plt.close(fig)
def test_new_figure_explicit_tuple():
fig, ax = ps.new_figure((3.0, 2.0))
try:
assert tuple(fig.get_size_inches()) == (3.0, 2.0)
finally:
plt.close(fig)
def test_new_figure_unknown_preset_raises():
with pytest.raises(ValueError):
ps.new_figure("not-a-real-preset")
def test_new_figure_title_sets_left_aligned_figure_suptitle():
fig, ax = ps.new_figure("square", title="My Title")
try:
assert fig._suptitle is not None
assert "My Title" in fig._suptitle.get_text()
assert fig._suptitle.get_ha() == "left"
assert ax.get_title() == ""
finally:
plt.close(fig)
def test_new_figure_params_adds_subtitle_line():
fig, ax = ps.new_figure("square", title="My Title", params={"N": 100, "seed": 42})
try:
text = fig._suptitle.get_text()
assert "My Title" in text
assert "N: 100" in text
assert "seed: 42" in text
assert "|" in text
finally:
plt.close(fig)
def test_new_figure_without_title_or_params_has_no_suptitle():
fig, ax = ps.new_figure("square")
try:
assert fig._suptitle is None
finally:
plt.close(fig)
def test_colorbar_matches_axes_height():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
fig.canvas.draw()
ax_bbox = ax.get_position()
cax_bbox = cb.ax.get_position()
assert ax_bbox.y0 == pytest.approx(cax_bbox.y0, abs=1e-6)
assert ax_bbox.y1 == pytest.approx(cax_bbox.y1, abs=1e-6)
finally:
plt.close(fig)
def test_colorbar_has_no_outline():
fig, ax = ps.new_figure("square")
try:
im = ax.imshow([[0, 1], [2, 3]])
cb = ps.colorbar(im, ax)
assert cb.outline.get_visible() is False
finally:
plt.close(fig)
def test_savefig_writes_requested_formats(tmp_path):
fig, ax = ps.new_figure("square")
ax.plot([0, 1], [0, 1])
try:
out_dir = tmp_path / "nested" / "plots"
written = ps.savefig(fig, out_dir / "my_plot", formats=("pdf", "png"))
finally:
plt.close(fig)
assert [p.name for p in written] == ["my_plot.pdf", "my_plot.png"]
for p in written:
assert p.exists()
assert p.stat().st_size > 0
def test_style_legend_places_legend_on_figure_outside_axes():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
legend = ps.style_legend(ax, title="Series")
assert legend in fig.legends
assert ax.get_legend() is None
assert legend.get_title().get_text() == "Series"
finally:
plt.close(fig)
def test_style_legend_without_title_warns():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
with pytest.warns(UserWarning):
ps.style_legend(ax)
finally:
plt.close(fig)
def test_panel_label_does_not_raise():
fig, ax = ps.new_figure("square")
try:
ax.plot([0, 1], [0, 1], label="series")
ps.panel_label(ax, "a")
finally:
plt.close(fig)
def test_panel_label_unknown_loc_raises():
fig, ax = ps.new_figure("square")
try:
with pytest.raises(ValueError):
ps.panel_label(ax, "a", loc="middle")
finally:
plt.close(fig)
def test_panel_label_defaults_to_lower_right():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
assert text.get_ha() == "right"
assert text.get_va() == "bottom"
x, y = text.get_position()
assert x > 0.5
assert y < 0.5
finally:
plt.close(fig)
def test_panel_label_color_matches_xlabel():
fig, ax = ps.new_figure("square")
try:
ax.set_xlabel("Time (s)")
text = ps.panel_label(ax, "a")
assert text.get_color() == ax.xaxis.label.get_color()
finally:
plt.close(fig)
def test_panel_label_has_rounded_semi_transparent_box_by_default():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a")
patch = text.get_bbox_patch()
assert patch is not None
assert "round" in patch.get_boxstyle().__class__.__name__.lower()
assert patch.get_facecolor()[3] < 1.0
assert patch.get_edgecolor()[3] == 1.0
finally:
plt.close(fig)
def test_panel_label_box_can_be_disabled():
fig, ax = ps.new_figure("square")
try:
text = ps.panel_label(ax, "a", box=False)
assert text.get_bbox_patch() is None
finally:
plt.close(fig)
Generated
+1812 -3
View File
File diff suppressed because it is too large Load Diff