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,394 @@
1
+ """Dimension-aware figure and subplot creation.
2
+
3
+ This module provides drop-in replacements for :func:`matplotlib.pyplot.figure`
4
+ and :func:`matplotlib.pyplot.subplots` that automatically size figures to
5
+ match a journal's column-width constraints.
6
+
7
+ ``figure``
8
+ Create a single-axis figure whose dimensions conform to a journal spec.
9
+
10
+ ``subplots``
11
+ Create a multi-panel figure, optionally annotated with spec-accurate
12
+ panel labels (a, b, c, …).
13
+
14
+ Both functions resolve the journal spec from the built-in registry and convert
15
+ physical column widths from millimetres to inches before delegating to
16
+ Matplotlib.
17
+
18
+ Design notes
19
+ ------------
20
+ The golden ratio (φ ≈ 1.618) is used as the default aspect ratio because it
21
+ produces visually balanced figures without requiring explicit height
22
+ specification. Pass *aspect* to override it for any figure where a different
23
+ proportion is preferred (e.g. square plots or wide panoramic layouts).
24
+
25
+ Panel label normalisation
26
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
27
+ :func:`subplots` always returns an ``ndarray`` for the axes argument —
28
+ including the ``nrows=1, ncols=1`` case — so that callers can use ``.flat``
29
+ iteration and ``[i, j]`` indexing uniformly. This diverges slightly from
30
+ vanilla Matplotlib, which returns a bare :class:`~matplotlib.axes.Axes` for
31
+ the single-panel case; the difference is intentional and documented on the
32
+ :func:`subplots` return value.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import TYPE_CHECKING, Final
38
+
39
+ import matplotlib.pyplot as plt
40
+ import numpy as np
41
+
42
+ from plotstyle.specs import registry
43
+ from plotstyle.specs.units import Dimension
44
+
45
+ if TYPE_CHECKING:
46
+ from matplotlib.axes import Axes
47
+ from matplotlib.figure import Figure
48
+
49
+ from plotstyle.specs.schema import JournalSpec
50
+
51
+ __all__: list[str] = [
52
+ "figure",
53
+ "subplots",
54
+ ]
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Module-level constants
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # The golden ratio φ = (1 + √5) / 2 ≈ 1.618. Used as the default
61
+ # width-to-height aspect ratio; it is widely considered the most visually
62
+ # harmonious rectangle proportion and is a common default in scientific figure
63
+ # guidelines.
64
+ _GOLDEN_RATIO: Final[float] = (1 + 5**0.5) / 2
65
+
66
+ # Valid column-span values accepted by the public API. Stored as a frozenset
67
+ # so membership tests are O(1) and the collection is clearly immutable.
68
+ _VALID_COLUMNS: Final[frozenset[int]] = frozenset({1, 2})
69
+
70
+ # Axes-normalised coordinates for panel label placement. Placing labels
71
+ # slightly outside the axes box (negative x, y > 1) is the dominant
72
+ # convention in multi-panel scientific figures and avoids overlap with axis
73
+ # ticks and tick labels.
74
+ _LABEL_X: Final[float] = -0.1
75
+ _LABEL_Y: Final[float] = 1.05
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Internal helpers
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _validate_columns(columns: int) -> None:
83
+ """Raise :exc:`ValueError` if *columns* is not a supported span value.
84
+
85
+ Centralising this check avoids duplicating the error message across
86
+ :func:`_resolve_width` and any future callers that accept a *columns*
87
+ parameter.
88
+
89
+ Args:
90
+ columns: Column-span value to validate.
91
+
92
+ Raises
93
+ ------
94
+ ValueError: If *columns* is not in ``{1, 2}``.
95
+ """
96
+ if columns not in _VALID_COLUMNS:
97
+ raise ValueError(
98
+ f"'columns' must be 1 (single-column) or 2 (double-column), "
99
+ f"got {columns!r}. "
100
+ "Check the journal spec for supported column widths."
101
+ )
102
+
103
+
104
+ def _resolve_width(journal: str, columns: int) -> float:
105
+ """Resolve the figure width in inches for a journal and column span.
106
+
107
+ Fetches the journal spec from the registry, selects the appropriate
108
+ physical column width in millimetres, and converts it to inches using
109
+ :class:`~plotstyle.specs.units.Dimension`.
110
+
111
+ Args:
112
+ journal: Journal preset name (e.g. ``"nature"``).
113
+ columns: Column span: ``1`` for single-column width, ``2`` for
114
+ double-column width.
115
+
116
+ Returns
117
+ -------
118
+ Figure width in inches.
119
+
120
+ Raises
121
+ ------
122
+ ValueError: If *columns* is not ``1`` or ``2``.
123
+ plotstyle.specs.SpecNotFoundError: If *journal* is not registered.
124
+ """
125
+ _validate_columns(columns)
126
+
127
+ spec: JournalSpec = registry.get(journal)
128
+
129
+ # Select the physical width from the spec based on the column span.
130
+ # Double-column figures span the full text width; single-column figures
131
+ # span only the narrower inset width.
132
+ width_mm: float = (
133
+ spec.dimensions.double_column_mm if columns == 2 else spec.dimensions.single_column_mm
134
+ )
135
+
136
+ return Dimension(width_mm, "mm").to_inches()
137
+
138
+
139
+ def _format_panel_label(index: int, spec: JournalSpec) -> str:
140
+ """Format a single panel label string from a zero-based index and spec.
141
+
142
+ The label style (case, parentheses, sentence capitalisation) is driven
143
+ by :attr:`~plotstyle.specs.schema.TypographySpec.panel_label_case` on
144
+ the journal spec.
145
+
146
+ Args:
147
+ index: Zero-based panel index. Index ``0`` maps to the letter
148
+ ``"a"`` (or its styled equivalent), index ``1`` to ``"b"``,
149
+ and so on.
150
+ spec: Journal specification containing panel label formatting rules.
151
+
152
+ Returns
153
+ -------
154
+ Formatted panel label string (e.g. ``"a"``, ``"(B)"``, ``"A"``).
155
+
156
+ Notes
157
+ -----
158
+ Supported ``panel_label_case`` values and their output:
159
+
160
+ ================ =========================================
161
+ Value Output for indices 0, 1, 2
162
+ ================ =========================================
163
+ ``"lower"`` ``a``, ``b``, ``c``
164
+ ``"upper"`` ``A``, ``B``, ``C``
165
+ ``"title"`` ``A``, ``B``, ``C`` (alias for upper)
166
+ ``"parens_lower"`` ``(a)``, ``(b)``, ``(c)``
167
+ ``"parens_upper"`` ``(A)``, ``(B)``, ``(C)``
168
+ ``"sentence"`` ``A``, ``b``, ``c`` (first only capitalised)
169
+ ================ =========================================
170
+
171
+ Any unrecognised value falls back to ``"lower"``.
172
+ """
173
+ # Derive the base lowercase letter(s) from the zero-based index.
174
+ # Indices 0-25 map to a-z; indices 26+ produce two-character labels
175
+ # (aa, ab, ..., az, ba, ...) to support figures with more than 26 panels.
176
+ if index < 26:
177
+ letter: str = chr(ord("a") + index)
178
+ else:
179
+ i = index - 26
180
+ letter = chr(ord("a") + i // 26) + chr(ord("a") + i % 26)
181
+ case: str = spec.typography.panel_label_case
182
+
183
+ match case:
184
+ case "upper" | "title":
185
+ return letter.upper()
186
+ case "parens_lower":
187
+ return f"({letter})"
188
+ case "parens_upper":
189
+ return f"({letter.upper()})"
190
+ case "sentence":
191
+ # Sentence case: capitalise only the first panel, mirroring
192
+ # the convention used in journals such as Cell and eLife.
193
+ return letter.upper() if index == 0 else letter
194
+ case _:
195
+ # "lower" is the explicit default; unknown values fall through
196
+ # here rather than raising so that newer spec fields added in
197
+ # future schema versions degrade gracefully.
198
+ return letter
199
+
200
+
201
+ def _add_panel_labels(axes: np.ndarray, spec: JournalSpec) -> None:
202
+ """Annotate every axes in *axes* with a spec-accurate panel label.
203
+
204
+ Labels are placed at a fixed position just above and to the left of each
205
+ axes bounding box, using the font size and weight prescribed by the
206
+ journal spec. This position (``x=-0.1, y=1.05`` in axes-normalised
207
+ coordinates) is the most common convention across biology and physics
208
+ journals.
209
+
210
+ Args:
211
+ axes: 2-D ``ndarray`` of :class:`~matplotlib.axes.Axes` objects.
212
+ Must support ``.flat`` iteration (i.e. be the output of
213
+ :func:`numpy.atleast_2d` or equivalent).
214
+ spec: Journal specification defining label style, font size, and
215
+ font weight.
216
+
217
+ Notes
218
+ -----
219
+ The function mutates each axes in-place by calling
220
+ :meth:`~matplotlib.axes.Axes.text`. It returns ``None``; callers
221
+ that need to manipulate the label :class:`~matplotlib.text.Text`
222
+ objects after the fact should call :func:`_format_panel_label`
223
+ directly and manage text placement themselves.
224
+ """
225
+ for idx, ax in enumerate(axes.flat):
226
+ label: str = _format_panel_label(idx, spec)
227
+ ax.text(
228
+ _LABEL_X,
229
+ _LABEL_Y,
230
+ label,
231
+ transform=ax.transAxes,
232
+ fontsize=spec.typography.panel_label_pt,
233
+ fontweight=spec.typography.panel_label_weight,
234
+ va="bottom",
235
+ ha="right",
236
+ )
237
+
238
+
239
+ def _compute_figsize(width_in: float, aspect: float | None) -> tuple[float, float]:
240
+ """Compute ``(width, height)`` in inches given a width and optional aspect.
241
+
242
+ Args:
243
+ width_in: Figure width in inches.
244
+ aspect: Width-to-height ratio. When ``None``, the golden ratio is
245
+ used as the default.
246
+
247
+ Returns
248
+ -------
249
+ A ``(width_in, height_in)`` tuple ready to be passed to
250
+ ``plt.subplots(figsize=...)``.
251
+ """
252
+ ratio: float = aspect if aspect is not None else _GOLDEN_RATIO
253
+ return width_in, width_in / ratio
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Public API
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def figure(
262
+ journal: str,
263
+ *,
264
+ columns: int = 1,
265
+ aspect: float | None = None,
266
+ ) -> tuple[Figure, Axes]:
267
+ """Create a single-axis figure sized to a journal's column width.
268
+
269
+ Resolves the journal spec from the registry, converts the physical column
270
+ width from millimetres to inches, and creates a Matplotlib figure with
271
+ ``constrained_layout`` enabled so that labels and titles fit within the
272
+ exported dimensions.
273
+
274
+ Args:
275
+ journal: Journal preset name (e.g. ``"nature"``).
276
+ columns: Column span: ``1`` (default) for single-column width,
277
+ ``2`` for double-column (full-text) width.
278
+ aspect: Width-to-height ratio for the figure. Defaults to the
279
+ golden ratio (≈ 1.618) when ``None``.
280
+
281
+ Returns
282
+ -------
283
+ A ``(fig, ax)`` tuple containing the new
284
+ :class:`~matplotlib.figure.Figure` and its single
285
+ :class:`~matplotlib.axes.Axes`.
286
+
287
+ Raises
288
+ ------
289
+ ValueError: If *columns* is not ``1`` or ``2``.
290
+ plotstyle.specs.SpecNotFoundError: If *journal* is not registered.
291
+
292
+ Example::
293
+
294
+ import plotstyle
295
+
296
+ fig, ax = plotstyle.figure("nature")
297
+ ax.plot([1, 2, 3], [4, 5, 6])
298
+ plotstyle.savefig(fig, "figure.pdf", journal="nature")
299
+ """
300
+ width_in: float = _resolve_width(journal, columns)
301
+ figsize: tuple[float, float] = _compute_figsize(width_in, aspect)
302
+
303
+ fig, ax = plt.subplots(figsize=figsize, constrained_layout=True)
304
+ return fig, ax
305
+
306
+
307
+ def subplots(
308
+ journal: str,
309
+ nrows: int = 1,
310
+ ncols: int = 1,
311
+ *,
312
+ columns: int = 1,
313
+ panels: bool = True,
314
+ aspect: float | None = None,
315
+ ) -> tuple[Figure, np.ndarray]:
316
+ """Create a multi-panel figure sized to a journal's column width.
317
+
318
+ Resolves the journal spec, sizes the figure to the requested column span,
319
+ and optionally annotates each axes with a spec-accurate panel label
320
+ (``a``, ``b``, ``c``, …).
321
+
322
+ Args:
323
+ journal: Journal preset name (e.g. ``"nature"``).
324
+ nrows: Number of subplot rows. Defaults to ``1``.
325
+ ncols: Number of subplot columns. Defaults to ``1``.
326
+ columns: Column span: ``1`` (default) for single-column width,
327
+ ``2`` for double-column (full-text) width.
328
+ panels: When ``True`` (default), annotates each axes with a
329
+ panel label styled according to the journal specification.
330
+ Pass ``False`` to suppress labels entirely.
331
+ aspect: Width-to-height ratio for the whole figure. Defaults to
332
+ the golden ratio (≈ 1.618) when ``None``.
333
+
334
+ Returns
335
+ -------
336
+ A ``(fig, axes)`` tuple where *axes* is always a 2-D
337
+ :class:`numpy.ndarray` of :class:`~matplotlib.axes.Axes` objects
338
+ with shape ``(nrows, ncols)``.
339
+
340
+ Raises
341
+ ------
342
+ ValueError: If *columns* is not ``1`` or ``2``.
343
+ plotstyle.specs.SpecNotFoundError: If *journal* is not registered.
344
+
345
+ Notes
346
+ -----
347
+ **Return-shape divergence from Matplotlib** — unlike
348
+ :func:`matplotlib.pyplot.subplots`, this function *always* returns
349
+ a 2-D ``ndarray``, including the ``nrows=1, ncols=1`` case.
350
+ This guarantees that callers can use ``.flat`` iteration and
351
+ ``axes[i, j]`` indexing without special-casing the single-panel
352
+ path. Access the bare :class:`~matplotlib.axes.Axes` via
353
+ ``axes[0, 0]`` when needed.
354
+
355
+ Example::
356
+
357
+ import plotstyle
358
+
359
+ fig, axes = plotstyle.subplots("nature", nrows=2, ncols=2, columns=2)
360
+ for ax in axes.flat:
361
+ ax.plot([1, 2, 3])
362
+ """
363
+ spec: JournalSpec = registry.get(journal)
364
+ width_in: float = _resolve_width(journal, columns)
365
+ figsize: tuple[float, float] = _compute_figsize(width_in, aspect)
366
+
367
+ fig, axes_raw = plt.subplots(
368
+ nrows,
369
+ ncols,
370
+ figsize=figsize,
371
+ constrained_layout=True,
372
+ )
373
+
374
+ # Normalise the axes return value to a 2-D ndarray so that panel label
375
+ # placement and downstream callers can always rely on a consistent shape.
376
+ #
377
+ # Matplotlib returns:
378
+ # - a bare Axes when nrows=1 and ncols=1
379
+ # - a 1-D ndarray when nrows=1 xor ncols=1
380
+ # - a 2-D ndarray when nrows > 1 and ncols > 1
381
+ #
382
+ # reshape(nrows, ncols) handles the 1-D case correctly for both
383
+ # nrows>1,ncols=1 and nrows=1,ncols>1; the bare-Axes case requires
384
+ # wrapping in a nested list first so that atleast_2d produces (1, 1).
385
+ if isinstance(axes_raw, np.ndarray):
386
+ axes_2d: np.ndarray = axes_raw.reshape(nrows, ncols)
387
+ else:
388
+ # Single bare Axes — wrap in a 2-D array to yield shape (1, 1).
389
+ axes_2d = np.atleast_2d(np.array(axes_raw))
390
+
391
+ if panels:
392
+ _add_panel_labels(axes_2d, spec)
393
+
394
+ return fig, axes_2d