plotstyle 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. plotstyle/__init__.py +121 -0
  2. plotstyle/_utils/__init__.py +0 -0
  3. plotstyle/_utils/io.py +113 -0
  4. plotstyle/_utils/warnings.py +86 -0
  5. plotstyle/_version.py +24 -0
  6. plotstyle/cli/__init__.py +0 -0
  7. plotstyle/cli/main.py +553 -0
  8. plotstyle/color/__init__.py +42 -0
  9. plotstyle/color/_rendering.py +86 -0
  10. plotstyle/color/accessibility.py +286 -0
  11. plotstyle/color/data/okabe_ito.json +5 -0
  12. plotstyle/color/data/safe_grayscale.json +7 -0
  13. plotstyle/color/data/tol_bright.json +5 -0
  14. plotstyle/color/data/tol_muted.json +5 -0
  15. plotstyle/color/data/tol_vibrant.json +5 -0
  16. plotstyle/color/grayscale.py +284 -0
  17. plotstyle/color/palettes.py +259 -0
  18. plotstyle/core/__init__.py +0 -0
  19. plotstyle/core/export.py +418 -0
  20. plotstyle/core/figure.py +394 -0
  21. plotstyle/core/migrate.py +579 -0
  22. plotstyle/core/style.py +394 -0
  23. plotstyle/engine/__init__.py +0 -0
  24. plotstyle/engine/fonts.py +309 -0
  25. plotstyle/engine/latex.py +287 -0
  26. plotstyle/engine/rcparams.py +352 -0
  27. plotstyle/integrations/__init__.py +0 -0
  28. plotstyle/integrations/seaborn.py +305 -0
  29. plotstyle/preview/__init__.py +50 -0
  30. plotstyle/preview/gallery.py +337 -0
  31. plotstyle/preview/print_size.py +304 -0
  32. plotstyle/py.typed +0 -0
  33. plotstyle/specs/__init__.py +304 -0
  34. plotstyle/specs/_templates.toml +48 -0
  35. plotstyle/specs/acs.toml +36 -0
  36. plotstyle/specs/cell.toml +35 -0
  37. plotstyle/specs/elsevier.toml +35 -0
  38. plotstyle/specs/ieee.toml +35 -0
  39. plotstyle/specs/nature.toml +35 -0
  40. plotstyle/specs/plos.toml +35 -0
  41. plotstyle/specs/prl.toml +35 -0
  42. plotstyle/specs/schema.py +1095 -0
  43. plotstyle/specs/science.toml +35 -0
  44. plotstyle/specs/springer.toml +35 -0
  45. plotstyle/specs/units.py +761 -0
  46. plotstyle/specs/wiley.toml +35 -0
  47. plotstyle/validation/__init__.py +94 -0
  48. plotstyle/validation/checks/__init__.py +95 -0
  49. plotstyle/validation/checks/_base.py +149 -0
  50. plotstyle/validation/checks/colors.py +394 -0
  51. plotstyle/validation/checks/dimensions.py +166 -0
  52. plotstyle/validation/checks/export.py +205 -0
  53. plotstyle/validation/checks/lines.py +147 -0
  54. plotstyle/validation/checks/typography.py +200 -0
  55. plotstyle/validation/report.py +293 -0
  56. plotstyle-0.1.0a1.dist-info/METADATA +271 -0
  57. plotstyle-0.1.0a1.dist-info/RECORD +60 -0
  58. plotstyle-0.1.0a1.dist-info/WHEEL +4 -0
  59. plotstyle-0.1.0a1.dist-info/entry_points.txt +2 -0
  60. plotstyle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,305 @@
1
+ """Seaborn compatibility layer for PlotStyle.
2
+
3
+ Provides utilities to combine seaborn themes with PlotStyle journal presets
4
+ without one clobbering the other. Both libraries write to
5
+ ``matplotlib.rcParams``; seaborn's ``set_theme`` resets everything it knows
6
+ about, which undoes PlotStyle's journal-specific overrides. This module
7
+ resolves that conflict through two complementary strategies:
8
+
9
+ **Strategy 1 — Persistent monkey-patch** (``patch_seaborn`` / ``unpatch_seaborn``):
10
+ Wraps ``sns.set_theme`` once so that every subsequent call automatically
11
+ re-applies the captured PlotStyle params. Activated by
12
+ :func:`plotstyle.core.style.use` when ``seaborn_compatible=True``.
13
+
14
+ **Strategy 2 — One-shot helper** (``plotstyle_theme``):
15
+ Applies a seaborn theme first, then layers PlotStyle params on top — no
16
+ persistent patch required. Suitable for scripts or notebooks where
17
+ ``sns.set_theme`` is called only once.
18
+
19
+ Seaborn is imported lazily so this module can be loaded without seaborn
20
+ installed. An :class:`ImportError` is raised only when a function that
21
+ actually requires seaborn is invoked.
22
+
23
+ Thread Safety
24
+ -------------
25
+ The two module-level globals (``_PLOTSTYLE_OVERRIDES`` and
26
+ ``_ORIGINAL_SET_THEME``) track patch state across calls. They are **not
27
+ thread-safe**; concurrent invocations of :func:`patch_seaborn` or
28
+ :func:`unpatch_seaborn`` from multiple threads may produce undefined state.
29
+
30
+ Public API
31
+ ----------
32
+ - :func:`capture_overrides`
33
+ - :func:`reapply_overrides`
34
+ - :func:`patch_seaborn`
35
+ - :func:`unpatch_seaborn`
36
+ - :func:`plotstyle_theme`
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import Any, ParamSpec
42
+
43
+ import matplotlib.pyplot as plt
44
+
45
+ from plotstyle.core.style import use
46
+
47
+ # Captures the variadic call signature of sns.set_theme for the transparent
48
+ # wrapper in patch_seaborn(), satisfying ANN401 without resorting to Any.
49
+ _P = ParamSpec("_P")
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Module-level state
53
+ # ---------------------------------------------------------------------------
54
+
55
+ # Snapshot of rcParams that PlotStyle last applied via capture_overrides().
56
+ # Re-applied by reapply_overrides() whenever seaborn resets matplotlib's
57
+ # global rcParams. None indicates that capture_overrides() has not yet been
58
+ # called.
59
+ _PLOTSTYLE_OVERRIDES: dict[str, Any] | None = None
60
+
61
+ # The original (unpatched) sns.set_theme callable. Retained by patch_seaborn()
62
+ # so that unpatch_seaborn() can restore the exact original reference. None
63
+ # indicates that the patch is not currently installed.
64
+ _ORIGINAL_SET_THEME: Any = None
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Public API
68
+ # ---------------------------------------------------------------------------
69
+
70
+ __all__: list[str] = [
71
+ "capture_overrides",
72
+ "patch_seaborn",
73
+ "plotstyle_theme",
74
+ "reapply_overrides",
75
+ "unpatch_seaborn",
76
+ ]
77
+
78
+
79
+ def capture_overrides(params: dict[str, Any]) -> None:
80
+ """Store a snapshot of PlotStyle rcParams for later re-application.
81
+
82
+ Called by :func:`plotstyle.core.style.use` when ``seaborn_compatible=True``
83
+ is set. The snapshot is consumed by :func:`reapply_overrides` to restore
84
+ PlotStyle params after any subsequent ``seaborn.set_theme()`` call resets
85
+ ``matplotlib.rcParams``.
86
+
87
+ A shallow copy of *params* is stored, so the caller may safely mutate the
88
+ original mapping after this function returns.
89
+
90
+ Args:
91
+ params: The rcParams dict that PlotStyle built and applied. Keys must
92
+ be valid ``matplotlib.rcParams`` parameter names; values must be
93
+ accepted by ``matplotlib.pyplot.rcParams.update``.
94
+
95
+ Example::
96
+
97
+ from plotstyle.integrations.seaborn import capture_overrides
98
+
99
+ capture_overrides({"font.size": 8, "axes.linewidth": 0.8})
100
+ """
101
+ global _PLOTSTYLE_OVERRIDES
102
+
103
+ # Shallow copy is sufficient: rcParams values are scalars or short
104
+ # sequences that matplotlib replaces (not mutates) on update.
105
+ _PLOTSTYLE_OVERRIDES = params.copy()
106
+
107
+
108
+ def reapply_overrides() -> None:
109
+ """Re-apply the stored PlotStyle rcParams to ``matplotlib.rcParams``.
110
+
111
+ Intended to be called immediately after ``seaborn.set_theme()`` has reset
112
+ matplotlib's global rcParams, restoring any journal-specific overrides that
113
+ seaborn erased.
114
+
115
+ This function is a no-op when:
116
+
117
+ - :func:`capture_overrides` has not yet been called (``_PLOTSTYLE_OVERRIDES``
118
+ is ``None``), or
119
+ - the captured dict is empty (nothing to restore).
120
+
121
+ Notes
122
+ -----
123
+ This function is injected into the seaborn call stack by
124
+ :func:`patch_seaborn` and is not typically called directly from user code.
125
+
126
+ Example::
127
+
128
+ from plotstyle.integrations.seaborn import capture_overrides, reapply_overrides
129
+
130
+ capture_overrides({"font.size": 8})
131
+ reapply_overrides() # matplotlib now has font.size = 8 again
132
+ """
133
+ # Guard against None (never captured) and {} (nothing to restore).
134
+ # Skipping the plt.rcParams.update call entirely avoids a needless
135
+ # matplotlib dict traversal when there is nothing to apply.
136
+ if _PLOTSTYLE_OVERRIDES:
137
+ plt.rcParams.update(_PLOTSTYLE_OVERRIDES)
138
+
139
+
140
+ def patch_seaborn() -> None:
141
+ """Monkey-patch ``seaborn.set_theme`` so PlotStyle params survive its calls.
142
+
143
+ Wraps ``sns.set_theme`` with a thin closure that calls
144
+ :func:`reapply_overrides` immediately after seaborn has finished resetting
145
+ ``matplotlib.rcParams``. All subsequent calls to ``sns.set_theme(...)``
146
+ — regardless of arguments — therefore end with the captured PlotStyle
147
+ params in effect.
148
+
149
+ Calling this function when the patch is already installed is a safe no-op;
150
+ double-wrapping is explicitly prevented to ensure :func:`unpatch_seaborn`
151
+ always restores the correct original callable.
152
+
153
+ Raises
154
+ ------
155
+ ImportError: If seaborn is not installed in the current environment.
156
+
157
+ Notes
158
+ -----
159
+ To remove the patch and restore the original ``sns.set_theme``, call
160
+ :func:`unpatch_seaborn`. The patch is also removed automatically when
161
+ the :class:`~plotstyle.core.style.JournalStyle` context manager exits.
162
+
163
+ Example::
164
+
165
+ import seaborn as sns
166
+ from plotstyle.integrations.seaborn import capture_overrides, patch_seaborn
167
+
168
+ capture_overrides({"font.size": 8})
169
+ patch_seaborn()
170
+
171
+ # PlotStyle params are restored automatically after each set_theme call.
172
+ sns.set_theme(style="ticks")
173
+ """
174
+ global _ORIGINAL_SET_THEME
175
+
176
+ import seaborn as sns # Deferred import — raises ImportError if absent.
177
+
178
+ # Guard against double-patching: wrapping an already-wrapped callable would
179
+ # cause unpatch_seaborn() to restore the wrapper instead of the original.
180
+ if _ORIGINAL_SET_THEME is not None:
181
+ return
182
+
183
+ _ORIGINAL_SET_THEME = sns.set_theme
184
+
185
+ # ParamSpec captures the exact call signature of the original set_theme so
186
+ # the wrapper is transparent to type checkers without requiring Any on the
187
+ # variadic parameters (which would violate ANN401).
188
+ def _patched_set_theme(*args: _P.args, **kwargs: _P.kwargs) -> None:
189
+ """Delegate to the original seaborn set_theme, then restore PlotStyle params."""
190
+ _ORIGINAL_SET_THEME(*args, **kwargs)
191
+ reapply_overrides()
192
+
193
+ # Replace the module-level attribute so all callers — including those who
194
+ # imported sns.set_theme directly before patching — see the wrapper via
195
+ # the sns namespace.
196
+ sns.set_theme = _patched_set_theme # type: ignore[assignment]
197
+
198
+
199
+ def unpatch_seaborn() -> None:
200
+ """Restore the original ``seaborn.set_theme`` callable.
201
+
202
+ Reverses the effect of :func:`patch_seaborn`, putting ``sns.set_theme``
203
+ back to the exact callable that was in place before patching.
204
+
205
+ If :func:`patch_seaborn` was never called, or has already been reversed,
206
+ this function is a safe no-op.
207
+
208
+ Notes
209
+ -----
210
+ After unpatching, future calls to ``sns.set_theme(...)`` will no longer
211
+ re-apply PlotStyle params. To reinstate the behaviour, call
212
+ :func:`patch_seaborn` again after a new :func:`~plotstyle.core.style.use`
213
+ invocation.
214
+
215
+ Example::
216
+
217
+ from plotstyle.integrations.seaborn import patch_seaborn, unpatch_seaborn
218
+
219
+ patch_seaborn()
220
+ # ... use seaborn with PlotStyle compatibility ...
221
+ unpatch_seaborn()
222
+ # sns.set_theme is now fully restored.
223
+ """
224
+ global _ORIGINAL_SET_THEME
225
+
226
+ # Nothing to restore if the patch was never installed (or was already
227
+ # reversed by a prior call to this function).
228
+ if _ORIGINAL_SET_THEME is None:
229
+ return
230
+
231
+ import seaborn as sns # Deferred import — only reached when patched.
232
+
233
+ sns.set_theme = _ORIGINAL_SET_THEME # type: ignore[assignment]
234
+ _ORIGINAL_SET_THEME = None # Reset sentinel so re-patching is allowed.
235
+
236
+
237
+ def plotstyle_theme(
238
+ journal: str,
239
+ *,
240
+ seaborn_style: str = "ticks",
241
+ seaborn_context: str = "paper",
242
+ ) -> None:
243
+ """Apply a seaborn theme and a PlotStyle journal preset in the correct order.
244
+
245
+ Applies the seaborn theme **first**, then overlays PlotStyle journal
246
+ parameters on top. This ordering guarantees that journal-specific
247
+ typography, line weights, and figure dimensions always take precedence
248
+ over seaborn's aesthetic defaults when the two conflict.
249
+
250
+ This is the recommended one-shot helper for users who want seaborn
251
+ aesthetics alongside journal-compliant styling without installing a
252
+ persistent monkey-patch.
253
+
254
+ Args:
255
+ journal: Journal preset name recognised by
256
+ :func:`~plotstyle.specs.registry.get` (e.g. ``"nature"`` or
257
+ ``"ieee"``).
258
+ seaborn_style: Seaborn aesthetic style forwarded to
259
+ ``sns.set_theme(style=...)``. Accepted values are
260
+ ``"darkgrid"``, ``"whitegrid"``, ``"dark"``, ``"white"``, and
261
+ ``"ticks"``. Defaults to ``"ticks"``.
262
+ seaborn_context: Seaborn scaling context forwarded to
263
+ ``sns.set_theme(context=...)``. Accepted values are
264
+ ``"paper"``, ``"notebook"``, ``"talk"``, and ``"poster"``.
265
+ Defaults to ``"paper"``.
266
+
267
+ Raises
268
+ ------
269
+ ImportError: If seaborn is not installed in the current environment.
270
+ ValueError: If *journal* is not a recognised PlotStyle preset (raised
271
+ by :func:`~plotstyle.core.style.use` internally).
272
+
273
+ Notes
274
+ -----
275
+ Unlike :func:`~plotstyle.core.style.use` with ``seaborn_compatible=True``,
276
+ this helper does **not** install a persistent monkey-patch on
277
+ ``sns.set_theme``. It applies both themes once in the correct order.
278
+ If PlotStyle params must survive *future* ``sns.set_theme(...)`` calls,
279
+ use :func:`~plotstyle.core.style.use` with ``seaborn_compatible=True``
280
+ instead.
281
+
282
+ Example::
283
+
284
+ import plotstyle.integrations.seaborn as ps_sns
285
+
286
+ # Minimal usage — defaults to style="ticks", context="paper":
287
+ ps_sns.plotstyle_theme("nature")
288
+
289
+ # Explicit seaborn settings:
290
+ ps_sns.plotstyle_theme(
291
+ "ieee",
292
+ seaborn_style="whitegrid",
293
+ seaborn_context="paper",
294
+ )
295
+ """
296
+ import seaborn as sns # Deferred import — raises ImportError if absent.
297
+
298
+ # Step 1: Let seaborn establish its own rcParams (axes style, font scales,
299
+ # palette, etc.). This intentionally resets any pre-existing params so
300
+ # the seaborn baseline is clean before PlotStyle layers on top.
301
+ sns.set_theme(style=seaborn_style, context=seaborn_context)
302
+
303
+ # Step 2: Overlay journal-specific params, winning any conflicts with
304
+ # seaborn's defaults (font sizes, line weights, figure dimensions, etc.).
305
+ use(journal)
@@ -0,0 +1,50 @@
1
+ """Preview tools for PlotStyle.
2
+
3
+ This sub-package provides two visual inspection utilities for exploring how a
4
+ journal style looks before committing to a final figure layout:
5
+
6
+ ``gallery``
7
+ Render a 2x2 grid of sample plots — line, scatter, bar, and histogram —
8
+ sized and styled to match a journal's column-width and typography
9
+ constraints. Useful for quickly assessing a journal preset before
10
+ creating production figures.
11
+
12
+ ``preview_print_size``
13
+ Display an existing figure at its approximate physical print size by
14
+ temporarily scaling the figure DPI to match the monitor's pixel density.
15
+ Useful for verifying that text and line weights are legible at the
16
+ dimensions they will occupy in print.
17
+
18
+ Usage
19
+ -----
20
+ Both functions are re-exported at the top-level :mod:`plotstyle` package, so
21
+ the following are equivalent::
22
+
23
+ # Via the top-level package (recommended):
24
+ import plotstyle
25
+
26
+ plotstyle.gallery("nature")
27
+ plotstyle.preview_print_size(fig, journal="nature")
28
+
29
+ # Via this sub-package directly:
30
+ from plotstyle.preview import gallery, preview_print_size
31
+
32
+ gallery("nature")
33
+ preview_print_size(fig, journal="nature")
34
+
35
+ Notes
36
+ -----
37
+ Neither utility mutates global Matplotlib state after returning. Both apply
38
+ any required rcParams changes in a scoped block and restore the prior state
39
+ unconditionally in a ``finally`` clause.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from plotstyle.preview.gallery import gallery
45
+ from plotstyle.preview.print_size import preview_print_size
46
+
47
+ __all__: list[str] = [
48
+ "gallery",
49
+ "preview_print_size",
50
+ ]
@@ -0,0 +1,337 @@
1
+ """Render a sample figure grid in a journal's style.
2
+
3
+ Provides :func:`gallery`, a quick-preview utility that creates a 2x2 grid of
4
+ representative plots — line, scatter, bar, and histogram — sized and styled to
5
+ match a journal's column width and typography constraints.
6
+
7
+ Design notes
8
+ ------------
9
+ **Deterministic data** — the four ``_sample_*`` helpers generate synthetic
10
+ data from a fixed random seed (``42`` by default), so the preview is
11
+ pixel-identical across repeated calls. Pass a different *seed* to the helpers
12
+ if variation is needed for testing.
13
+
14
+ **Style isolation** — :func:`gallery` applies the journal preset via
15
+ :func:`~plotstyle.core.style.use` and restores the original rcParams in a
16
+ ``finally`` block. Calling :func:`gallery` never permanently alters global
17
+ Matplotlib state, regardless of whether figure creation succeeds or raises.
18
+
19
+ **Panel configuration** — visual constants for each panel (titles, axis
20
+ labels, marker sizes, etc.) are centralised in :data:`_PANEL_CONFIG` rather
21
+ than scattered through the rendering code. Adding a new panel or changing a
22
+ label requires editing only that mapping.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING, Any, Final
28
+
29
+ import matplotlib.pyplot as plt
30
+ import numpy as np
31
+
32
+ from plotstyle.core.style import use
33
+ from plotstyle.specs.units import Dimension
34
+
35
+ if TYPE_CHECKING:
36
+ from matplotlib.axes import Axes
37
+ from matplotlib.figure import Figure
38
+ from numpy.typing import NDArray
39
+
40
+ __all__: list[str] = ["gallery"]
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Module-level constants
44
+ # ---------------------------------------------------------------------------
45
+
46
+ # Height-to-width ratio for the gallery figure. 0.9 fills the available
47
+ # vertical space while keeping the figure compact enough for on-screen preview
48
+ # without excessive whitespace.
49
+ _GALLERY_ASPECT: Final[float] = 0.9
50
+
51
+ # Fixed random seed shared by all sample-data generators. A module-level
52
+ # constant makes it trivial to find and change if reproducibility requirements
53
+ # change (e.g. switching to a property-based test with varied seeds).
54
+ _DEFAULT_SEED: Final[int] = 42
55
+
56
+ # Valid column-span values; mirrors plotstyle.core.figure for consistency
57
+ # without introducing a cross-module import dependency.
58
+ _VALID_COLUMNS: Final[frozenset[int]] = frozenset({1, 2})
59
+
60
+ # Number of samples for the scatter and histogram panels.
61
+ _SCATTER_N: Final[int] = 80
62
+ _HIST_N: Final[int] = 500
63
+
64
+ # Scatter plot groups. Defined once so both the generator and the renderer
65
+ # agree on the set without magic string literals.
66
+ _SCATTER_GROUPS: Final[list[str]] = ["A", "B", "C"]
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Synthetic data generators
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _sample_line_data() -> tuple[NDArray[np.float64], list[NDArray[np.float64]]]:
74
+ """Generate deterministic synthetic data for the line-plot panel.
75
+
76
+ Produces four periodic curves (sin/cos at base and double frequency) that
77
+ exercise the journal's colour cycle and line-weight settings.
78
+
79
+ Returns
80
+ -------
81
+ A two-tuple ``(x, ys)`` where *x* is a 1-D array of 100 evenly
82
+ spaced values over ``[0, 2π]`` and *ys* is a list of four
83
+ corresponding y-arrays: ``sin(x)``, ``cos(x)``,
84
+ ``0.5 · sin(2x)``, and ``0.7 · cos(2x)``.
85
+ """
86
+ x: NDArray[np.float64] = np.linspace(0, 2 * np.pi, 100)
87
+ ys: list[NDArray[np.float64]] = [
88
+ np.sin(x),
89
+ np.cos(x),
90
+ np.sin(2 * x) * 0.5,
91
+ np.cos(2 * x) * 0.7,
92
+ ]
93
+ return x, ys
94
+
95
+
96
+ def _sample_scatter_data(
97
+ seed: int = _DEFAULT_SEED,
98
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.str_]]:
99
+ """Generate deterministic synthetic data for the scatter-plot panel.
100
+
101
+ Creates a noisy linear relationship across three labelled groups so the
102
+ panel exercises colour, alpha, and marker-size rendering.
103
+
104
+ Args:
105
+ seed: NumPy default-RNG seed. Defaults to :data:`_DEFAULT_SEED`
106
+ (``42``) for reproducibility.
107
+
108
+ Returns
109
+ -------
110
+ A three-tuple ``(x, y, groups)`` where *x* and *y* are correlated
111
+ 1-D float arrays of length :data:`_SCATTER_N`, and *groups* is a
112
+ string array of the same length whose values are drawn from
113
+ :data:`_SCATTER_GROUPS`.
114
+ """
115
+ rng = np.random.default_rng(seed)
116
+ x: NDArray[np.float64] = rng.normal(0, 1, _SCATTER_N)
117
+ # Linear relationship with additive Gaussian noise to produce a clear but
118
+ # imperfect trend — more representative of real scientific scatter plots.
119
+ y: NDArray[np.float64] = 0.8 * x + rng.normal(0, 0.4, _SCATTER_N)
120
+ groups: NDArray[np.str_] = rng.choice(np.array(_SCATTER_GROUPS, dtype=str), size=_SCATTER_N)
121
+ return x, y, groups
122
+
123
+
124
+ def _sample_bar_data() -> tuple[list[str], list[float], list[float]]:
125
+ """Generate fixed synthetic data for the bar-chart panel.
126
+
127
+ Returns fixed (non-random) values so the bar chart preview is always
128
+ identical and requires no seed argument.
129
+
130
+ Returns
131
+ -------
132
+ A three-tuple ``(categories, values, errors)`` containing lists of
133
+ category labels, bar heights, and symmetric error-bar half-widths.
134
+ """
135
+ categories: list[str] = ["Cat A", "Cat B", "Cat C", "Cat D"]
136
+ values: list[float] = [4.2, 7.1, 3.8, 5.9]
137
+ errors: list[float] = [0.5, 0.8, 0.3, 0.6]
138
+ return categories, values, errors
139
+
140
+
141
+ def _sample_histogram_data(seed: int = _DEFAULT_SEED) -> NDArray[np.float64]:
142
+ """Generate deterministic synthetic data for the histogram panel.
143
+
144
+ Args:
145
+ seed: NumPy default-RNG seed. Defaults to :data:`_DEFAULT_SEED`
146
+ (``42``) for reproducibility.
147
+
148
+ Returns
149
+ -------
150
+ A 1-D array of :data:`_HIST_N` standard-normal samples, suitable
151
+ for demonstrating the journal's bar-fill and edge-colour styles.
152
+ """
153
+ rng = np.random.default_rng(seed)
154
+ return rng.normal(0, 1, _HIST_N)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Panel rendering helpers
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def _draw_line_panel(ax: Axes) -> None:
163
+ """Populate the line-plot panel with four periodic curves and a legend.
164
+
165
+ Args:
166
+ ax: Target :class:`~matplotlib.axes.Axes` to draw into.
167
+ """
168
+ x, ys = _sample_line_data()
169
+ for i, y in enumerate(ys):
170
+ ax.plot(x, y, label=f"Series {i + 1}")
171
+ ax.set_xlabel("x")
172
+ ax.set_ylabel("y")
173
+ ax.legend(fontsize="small")
174
+ ax.set_title("Line Plot", fontsize="small")
175
+
176
+
177
+ def _draw_scatter_panel(ax: Axes) -> None:
178
+ """Populate the scatter-plot panel with three labelled groups.
179
+
180
+ Uses :func:`numpy.unique` to iterate over groups in sorted order, which
181
+ is more idiomatic than ``sorted(set(...))`` for NumPy string arrays and
182
+ avoids a Python-level sort on a large array.
183
+
184
+ Args:
185
+ ax: Target :class:`~matplotlib.axes.Axes` to draw into.
186
+ """
187
+ x, y, groups = _sample_scatter_data()
188
+ for group_label in np.unique(groups):
189
+ mask: NDArray[np.bool_] = groups == group_label
190
+ ax.scatter(x[mask], y[mask], label=str(group_label), s=15, alpha=0.8)
191
+ ax.set_xlabel("x")
192
+ ax.set_ylabel("y")
193
+ ax.legend(fontsize="small")
194
+ ax.set_title("Scatter Plot", fontsize="small")
195
+
196
+
197
+ def _draw_bar_panel(ax: Axes) -> None:
198
+ """Populate the bar-chart panel with error bars.
199
+
200
+ Args:
201
+ ax: Target :class:`~matplotlib.axes.Axes` to draw into.
202
+ """
203
+ categories, values, errors = _sample_bar_data()
204
+ ax.bar(categories, values, yerr=errors, capsize=3)
205
+ ax.set_ylabel("Value")
206
+ ax.set_title("Bar Chart", fontsize="small")
207
+
208
+
209
+ def _draw_histogram_panel(ax: Axes) -> None:
210
+ """Populate the histogram panel with 25 bins of standard-normal data.
211
+
212
+ Args:
213
+ ax: Target :class:`~matplotlib.axes.Axes` to draw into.
214
+ """
215
+ data: NDArray[np.float64] = _sample_histogram_data()
216
+ ax.hist(data, bins=25, edgecolor="black", linewidth=0.5)
217
+ ax.set_xlabel("Value")
218
+ ax.set_ylabel("Count")
219
+ ax.set_title("Histogram", fontsize="small")
220
+
221
+
222
+ # Panel drawing functions in row-major order, matching the 2x2 axes layout
223
+ # produced by plt.subplots(2, 2). Each callable accepts a single Axes arg.
224
+ # To add a new panel: extend the grid dimensions, add a helper above, and
225
+ # append it here — no other code changes are required.
226
+ _PANEL_DRAWERS: Final[
227
+ list[Any] # list[Callable[[Axes], None]] — Callable not usable as Final type arg
228
+ ] = [
229
+ _draw_line_panel,
230
+ _draw_scatter_panel,
231
+ _draw_bar_panel,
232
+ _draw_histogram_panel,
233
+ ]
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Public API
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def gallery(journal: str, *, columns: int = 1) -> Figure:
241
+ """Render a 2x2 grid of sample plots in the given journal's style.
242
+
243
+ Applies the journal preset via :func:`~plotstyle.core.style.use`, creates
244
+ a figure sized to the journal's column width, and populates it with four
245
+ representative plot types: line plot, scatter plot, bar chart, and
246
+ histogram. The original rcParams are always restored after the figure is
247
+ created.
248
+
249
+ Args:
250
+ journal: Journal preset name (e.g. ``"nature"``, ``"ieee"``).
251
+ columns: Column span for the figure: ``1`` (default) for
252
+ single-column width, ``2`` for double-column width.
253
+
254
+ Returns
255
+ -------
256
+ A :class:`~matplotlib.figure.Figure` containing four styled subplots,
257
+ ready to display with ``plt.show()`` or save with
258
+ :func:`~plotstyle.core.export.savefig`.
259
+
260
+ Raises
261
+ ------
262
+ plotstyle.specs.SpecNotFoundError: If *journal* is not registered in
263
+ the spec registry.
264
+ ValueError: If *columns* is not ``1`` or ``2``.
265
+
266
+ Notes
267
+ -----
268
+ **Style isolation** — the journal style is applied only for the
269
+ duration of this call. rcParams are restored unconditionally in a
270
+ ``finally`` block so a failed :func:`gallery` call never leaves global
271
+ Matplotlib state modified.
272
+
273
+ **Determinism** — all synthetic data is generated from a fixed seed
274
+ (:data:`_DEFAULT_SEED`), so the figure is pixel-identical across
275
+ repeated calls with the same arguments.
276
+
277
+ **Supra-title sizing** — the figure title is set one point above the
278
+ journal's ``max_font_pt`` so it reads as a heading without violating
279
+ the journal's body-text constraint.
280
+
281
+ Example::
282
+
283
+ import matplotlib.pyplot as plt
284
+ import plotstyle
285
+
286
+ fig = plotstyle.gallery("nature", columns=1)
287
+ plt.show()
288
+
289
+ # Save directly:
290
+ plotstyle.savefig(fig, "nature_preview.pdf", journal="nature")
291
+ """
292
+ if columns not in _VALID_COLUMNS:
293
+ raise ValueError(
294
+ f"'columns' must be 1 (single-column) or 2 (double-column), got {columns!r}."
295
+ )
296
+
297
+ # Apply the journal style and retain the handle so we can call restore()
298
+ # in the finally block without a second registry lookup.
299
+ style = use(journal)
300
+ spec = style.spec
301
+
302
+ # Resolve the physical figure dimensions from the spec.
303
+ width_mm: float = (
304
+ spec.dimensions.double_column_mm if columns == 2 else spec.dimensions.single_column_mm
305
+ )
306
+ width_in: float = Dimension(width_mm, "mm").to_inches()
307
+ height_in: float = width_in * _GALLERY_ASPECT
308
+
309
+ try:
310
+ fig, axes = plt.subplots(
311
+ 2,
312
+ 2,
313
+ figsize=(width_in, height_in),
314
+ constrained_layout=True,
315
+ )
316
+
317
+ # Dispatch each panel drawer to its corresponding axes in row-major
318
+ # order. axes.flat guarantees a consistent iteration order regardless
319
+ # of the nrows/ncols layout.
320
+ for draw_panel, ax in zip(_PANEL_DRAWERS, axes.flat, strict=False):
321
+ draw_panel(ax)
322
+
323
+ # Title is set one point above max_font_pt to serve as a visual
324
+ # heading that stands apart from body-text elements without exceeding
325
+ # a reasonable scale relative to the journal's typography system.
326
+ fig.suptitle(
327
+ f"{spec.metadata.name} Style Preview",
328
+ fontsize=spec.typography.max_font_pt + 1,
329
+ fontweight="bold",
330
+ )
331
+
332
+ return fig
333
+
334
+ finally:
335
+ # Restore the prior rcParams unconditionally so that a failed figure
336
+ # creation does not leave the process in a modified global state.
337
+ style.restore()