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.
- plotstyle/__init__.py +121 -0
- plotstyle/_utils/__init__.py +0 -0
- plotstyle/_utils/io.py +113 -0
- plotstyle/_utils/warnings.py +86 -0
- plotstyle/_version.py +24 -0
- plotstyle/cli/__init__.py +0 -0
- plotstyle/cli/main.py +553 -0
- plotstyle/color/__init__.py +42 -0
- plotstyle/color/_rendering.py +86 -0
- plotstyle/color/accessibility.py +286 -0
- plotstyle/color/data/okabe_ito.json +5 -0
- plotstyle/color/data/safe_grayscale.json +7 -0
- plotstyle/color/data/tol_bright.json +5 -0
- plotstyle/color/data/tol_muted.json +5 -0
- plotstyle/color/data/tol_vibrant.json +5 -0
- plotstyle/color/grayscale.py +284 -0
- plotstyle/color/palettes.py +259 -0
- plotstyle/core/__init__.py +0 -0
- plotstyle/core/export.py +418 -0
- plotstyle/core/figure.py +394 -0
- plotstyle/core/migrate.py +579 -0
- plotstyle/core/style.py +394 -0
- plotstyle/engine/__init__.py +0 -0
- plotstyle/engine/fonts.py +309 -0
- plotstyle/engine/latex.py +287 -0
- plotstyle/engine/rcparams.py +352 -0
- plotstyle/integrations/__init__.py +0 -0
- plotstyle/integrations/seaborn.py +305 -0
- plotstyle/preview/__init__.py +50 -0
- plotstyle/preview/gallery.py +337 -0
- plotstyle/preview/print_size.py +304 -0
- plotstyle/py.typed +0 -0
- plotstyle/specs/__init__.py +304 -0
- plotstyle/specs/_templates.toml +48 -0
- plotstyle/specs/acs.toml +36 -0
- plotstyle/specs/cell.toml +35 -0
- plotstyle/specs/elsevier.toml +35 -0
- plotstyle/specs/ieee.toml +35 -0
- plotstyle/specs/nature.toml +35 -0
- plotstyle/specs/plos.toml +35 -0
- plotstyle/specs/prl.toml +35 -0
- plotstyle/specs/schema.py +1095 -0
- plotstyle/specs/science.toml +35 -0
- plotstyle/specs/springer.toml +35 -0
- plotstyle/specs/units.py +761 -0
- plotstyle/specs/wiley.toml +35 -0
- plotstyle/validation/__init__.py +94 -0
- plotstyle/validation/checks/__init__.py +95 -0
- plotstyle/validation/checks/_base.py +149 -0
- plotstyle/validation/checks/colors.py +394 -0
- plotstyle/validation/checks/dimensions.py +166 -0
- plotstyle/validation/checks/export.py +205 -0
- plotstyle/validation/checks/lines.py +147 -0
- plotstyle/validation/checks/typography.py +200 -0
- plotstyle/validation/report.py +293 -0
- plotstyle-0.1.0a1.dist-info/METADATA +271 -0
- plotstyle-0.1.0a1.dist-info/RECORD +60 -0
- plotstyle-0.1.0a1.dist-info/WHEEL +4 -0
- plotstyle-0.1.0a1.dist-info/entry_points.txt +2 -0
- plotstyle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Export-readiness validation checks.
|
|
2
|
+
|
|
3
|
+
This module registers :func:`check_export_settings`, which verifies that
|
|
4
|
+
Matplotlib's global ``rcParams`` are configured for publication-quality output.
|
|
5
|
+
|
|
6
|
+
Checks performed
|
|
7
|
+
----------------
|
|
8
|
+
1. **PDF font embedding** (``pdf.fonttype``) — must be ``42`` (TrueType
|
|
9
|
+
embedding). Setting ``3`` embeds fonts as Type 3 outlines, which many
|
|
10
|
+
journal submission systems reject.
|
|
11
|
+
2. **PostScript font embedding** (``ps.fonttype``) — same requirement as PDF.
|
|
12
|
+
3. **Save DPI** (``savefig.dpi``) — must be at least the minimum DPI
|
|
13
|
+
specified in the journal's export spec. A value of ``"figure"`` (the
|
|
14
|
+
Matplotlib default) is treated as ``WARN`` because the resolution depends
|
|
15
|
+
on the figure's screen DPI setting, which is typically 72-100 DPI — below
|
|
16
|
+
the 300+ DPI required for print.
|
|
17
|
+
|
|
18
|
+
Why rcParams and not Figure properties?
|
|
19
|
+
----------------------------------------
|
|
20
|
+
Font embedding and output DPI are controlled globally via ``rcParams`` in
|
|
21
|
+
Matplotlib, not per-figure. Checking them here ensures that the entire
|
|
22
|
+
export pipeline is configured correctly, not just the figure layout.
|
|
23
|
+
|
|
24
|
+
Example
|
|
25
|
+
-------
|
|
26
|
+
>>> from plotstyle.validation.checks.export import check_export_settings
|
|
27
|
+
>>> results = check_export_settings(fig, spec)
|
|
28
|
+
>>> [r.check_name for r in results]
|
|
29
|
+
['export.pdf_fonttype', 'export.ps_fonttype', 'export.dpi']
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
import matplotlib as mpl
|
|
37
|
+
|
|
38
|
+
from plotstyle.validation.checks._base import check
|
|
39
|
+
from plotstyle.validation.report import CheckResult, CheckStatus
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from matplotlib.figure import Figure
|
|
43
|
+
|
|
44
|
+
from plotstyle.specs.schema import JournalSpec
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Constants
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# TrueType font embedding value for pdf.fonttype and ps.fonttype.
|
|
51
|
+
# Type 42 embeds complete TrueType outlines; Type 3 embeds bitmaps/outlines
|
|
52
|
+
# that many PDF pre-flight tools and journal submission systems reject.
|
|
53
|
+
_TRUETYPE_FONTTYPE: int = 42
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Registered check
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@check
|
|
62
|
+
def check_export_settings(fig: Figure, spec: JournalSpec) -> list[CheckResult]:
|
|
63
|
+
"""Validate Matplotlib export rcParams against journal requirements.
|
|
64
|
+
|
|
65
|
+
Inspects three global ``rcParams`` that control the quality and
|
|
66
|
+
compatibility of saved figures:
|
|
67
|
+
|
|
68
|
+
- ``pdf.fonttype`` — must be :data:`_TRUETYPE_FONTTYPE` (42).
|
|
69
|
+
- ``ps.fonttype`` — must be :data:`_TRUETYPE_FONTTYPE` (42).
|
|
70
|
+
- ``savefig.dpi`` — must be a numeric value ≥ ``spec.export.min_dpi``.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
fig: The :class:`~matplotlib.figure.Figure` being validated. Not used
|
|
74
|
+
by this check (export settings are global), but required by the
|
|
75
|
+
:data:`~plotstyle.validation.checks._base.CheckFunc` interface.
|
|
76
|
+
spec: Journal specification providing ``export.min_dpi`` and
|
|
77
|
+
``metadata.name`` for error messages.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
A list of exactly three :class:`~plotstyle.validation.report.CheckResult`
|
|
82
|
+
objects in the order: ``pdf_fonttype``, ``ps_fonttype``, ``dpi``.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> import matplotlib as mpl
|
|
86
|
+
>>> mpl.rcParams["pdf.fonttype"] = 42
|
|
87
|
+
>>> mpl.rcParams["ps.fonttype"] = 42
|
|
88
|
+
>>> mpl.rcParams["savefig.dpi"] = 300
|
|
89
|
+
>>> results = check_export_settings(fig, spec)
|
|
90
|
+
>>> all(r.status.value == "PASS" for r in results)
|
|
91
|
+
True
|
|
92
|
+
|
|
93
|
+
Notes
|
|
94
|
+
-----
|
|
95
|
+
- ``fig`` is explicitly discarded (``_ = fig``) to make it clear this
|
|
96
|
+
is intentional rather than an oversight. The variable binding is
|
|
97
|
+
kept to satisfy the :data:`~plotstyle.validation.checks._base.CheckFunc`
|
|
98
|
+
interface contract.
|
|
99
|
+
- A ``savefig.dpi`` value of ``"figure"`` — Matplotlib's default —
|
|
100
|
+
produces a ``WARN`` rather than a ``FAIL`` because the actual DPI
|
|
101
|
+
is not deterministic at validation time; it depends on the screen or
|
|
102
|
+
backend DPI when :meth:`~matplotlib.figure.Figure.savefig` is called.
|
|
103
|
+
"""
|
|
104
|
+
# `fig` is intentionally unused; this check inspects global rcParams only.
|
|
105
|
+
_ = fig
|
|
106
|
+
|
|
107
|
+
results: list[CheckResult] = []
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# 1. PDF font embedding
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
pdf_fonttype: Any = mpl.rcParams.get("pdf.fonttype")
|
|
113
|
+
|
|
114
|
+
if pdf_fonttype == _TRUETYPE_FONTTYPE:
|
|
115
|
+
results.append(
|
|
116
|
+
CheckResult(
|
|
117
|
+
status=CheckStatus.PASS,
|
|
118
|
+
check_name="export.pdf_fonttype",
|
|
119
|
+
message=f"pdf.fonttype = {_TRUETYPE_FONTTYPE} (TrueType fonts will be embedded in PDF output).",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
results.append(
|
|
124
|
+
CheckResult(
|
|
125
|
+
status=CheckStatus.FAIL,
|
|
126
|
+
check_name="export.pdf_fonttype",
|
|
127
|
+
message=(
|
|
128
|
+
f"pdf.fonttype = {pdf_fonttype!r}; must be {_TRUETYPE_FONTTYPE} "
|
|
129
|
+
"for TrueType font embedding. Type 3 fonts are rejected by "
|
|
130
|
+
"many journal submission systems."
|
|
131
|
+
),
|
|
132
|
+
fix_suggestion=(
|
|
133
|
+
f"Call plotstyle.use() to apply all required rcParams, or set "
|
|
134
|
+
f"mpl.rcParams['pdf.fonttype'] = {_TRUETYPE_FONTTYPE} manually."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# 2. PostScript font embedding
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
ps_fonttype: Any = mpl.rcParams.get("ps.fonttype")
|
|
143
|
+
|
|
144
|
+
if ps_fonttype == _TRUETYPE_FONTTYPE:
|
|
145
|
+
results.append(
|
|
146
|
+
CheckResult(
|
|
147
|
+
status=CheckStatus.PASS,
|
|
148
|
+
check_name="export.ps_fonttype",
|
|
149
|
+
message=f"ps.fonttype = {_TRUETYPE_FONTTYPE} (TrueType fonts will be embedded in PostScript output).",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
results.append(
|
|
154
|
+
CheckResult(
|
|
155
|
+
status=CheckStatus.FAIL,
|
|
156
|
+
check_name="export.ps_fonttype",
|
|
157
|
+
message=(
|
|
158
|
+
f"ps.fonttype = {ps_fonttype!r}; must be {_TRUETYPE_FONTTYPE} "
|
|
159
|
+
"for TrueType font embedding in PostScript/EPS output."
|
|
160
|
+
),
|
|
161
|
+
fix_suggestion=(
|
|
162
|
+
f"Call plotstyle.use() to apply all required rcParams, or set "
|
|
163
|
+
f"mpl.rcParams['ps.fonttype'] = {_TRUETYPE_FONTTYPE} manually."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# 3. Save DPI
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
required_dpi: float = spec.export.min_dpi
|
|
172
|
+
savefig_dpi: Any = mpl.rcParams.get("savefig.dpi", "figure")
|
|
173
|
+
|
|
174
|
+
if isinstance(savefig_dpi, (int, float)) and savefig_dpi >= required_dpi:
|
|
175
|
+
results.append(
|
|
176
|
+
CheckResult(
|
|
177
|
+
status=CheckStatus.PASS,
|
|
178
|
+
check_name="export.dpi",
|
|
179
|
+
message=(
|
|
180
|
+
f"savefig.dpi = {savefig_dpi} meets the "
|
|
181
|
+
f"{spec.metadata.name} minimum of {required_dpi} DPI."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
# A non-numeric value (e.g., "figure") is a warning rather than a
|
|
187
|
+
# hard failure because the actual DPI is resolved at save time and
|
|
188
|
+
# may be overridden by an explicit dpi= kwarg in savefig().
|
|
189
|
+
results.append(
|
|
190
|
+
CheckResult(
|
|
191
|
+
status=CheckStatus.WARN,
|
|
192
|
+
check_name="export.dpi",
|
|
193
|
+
message=(
|
|
194
|
+
f"savefig.dpi = {savefig_dpi!r}; "
|
|
195
|
+
f"{spec.metadata.name} requires ≥ {required_dpi} DPI. "
|
|
196
|
+
"The figure may be saved at insufficient resolution."
|
|
197
|
+
),
|
|
198
|
+
fix_suggestion=(
|
|
199
|
+
f"Use plotstyle.savefig() which enforces {required_dpi} DPI, "
|
|
200
|
+
f"or set mpl.rcParams['savefig.dpi'] = {int(required_dpi)}."
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return results
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Line weight validation check.
|
|
2
|
+
|
|
3
|
+
This module registers :func:`check_line_weights`, which verifies that every
|
|
4
|
+
:class:`~matplotlib.lines.Line2D` object and every axis spine in a figure
|
|
5
|
+
meets the journal's minimum line weight specification.
|
|
6
|
+
|
|
7
|
+
Why line weights matter
|
|
8
|
+
-----------------------
|
|
9
|
+
Many journals (particularly IEEE and physical-sciences publishers) specify a
|
|
10
|
+
minimum line weight — typically 0.5 pt — to ensure that figure elements remain
|
|
11
|
+
visible after the half-tone or rasterisation processes applied during printing.
|
|
12
|
+
Lines below this threshold can disappear or appear as artefacts in the final
|
|
13
|
+
publication.
|
|
14
|
+
|
|
15
|
+
Scope
|
|
16
|
+
-----
|
|
17
|
+
The check covers:
|
|
18
|
+
|
|
19
|
+
- **Line2D objects** — from ``ax.plot``, ``ax.step``, ``ax.axhline``, etc.
|
|
20
|
+
- **Axis spines** — the box borders drawn around each axes (``top``,
|
|
21
|
+
``bottom``, ``left``, ``right``).
|
|
22
|
+
|
|
23
|
+
Intentionally excluded: tick marks, grid lines, legend borders, and
|
|
24
|
+
collection/patch edges, because these are controlled separately and their
|
|
25
|
+
weight requirements vary more across journals.
|
|
26
|
+
|
|
27
|
+
Example
|
|
28
|
+
-------
|
|
29
|
+
>>> from plotstyle.validation.checks.lines import check_line_weights
|
|
30
|
+
>>> results = check_line_weights(fig, spec)
|
|
31
|
+
>>> results[0].check_name
|
|
32
|
+
'lines.min_weight'
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from plotstyle.validation.checks._base import check
|
|
40
|
+
from plotstyle.validation.report import CheckResult, CheckStatus
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from matplotlib.figure import Figure
|
|
44
|
+
|
|
45
|
+
from plotstyle.specs.schema import JournalSpec
|
|
46
|
+
|
|
47
|
+
# Maximum number of violation examples to include in the result message
|
|
48
|
+
# before truncating with an implicit "and N more…" (not shown; the user is
|
|
49
|
+
# expected to inspect the figure systematically once they know a problem exists).
|
|
50
|
+
_MAX_VIOLATION_EXAMPLES: int = 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@check
|
|
54
|
+
def check_line_weights(fig: Figure, spec: JournalSpec) -> list[CheckResult]:
|
|
55
|
+
"""Validate line and spine widths against the journal's minimum requirement.
|
|
56
|
+
|
|
57
|
+
Iterates over every :class:`~matplotlib.lines.Line2D` and axis spine in
|
|
58
|
+
*fig* and records any whose ``linewidth`` falls below
|
|
59
|
+
``spec.line.min_weight_pt``.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
fig: The :class:`~matplotlib.figure.Figure` to inspect.
|
|
63
|
+
spec: Journal specification providing ``line.min_weight_pt`` and
|
|
64
|
+
``metadata.name`` for error messages.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
A list containing exactly one :class:`~plotstyle.validation.report.CheckResult`
|
|
69
|
+
with check name ``"lines.min_weight"``. The status is:
|
|
70
|
+
|
|
71
|
+
- ``PASS`` — all inspected elements meet the minimum line weight.
|
|
72
|
+
- ``FAIL`` — one or more elements are below the minimum, with up to
|
|
73
|
+
:data:`_MAX_VIOLATION_EXAMPLES` offending names and widths listed.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> import matplotlib.pyplot as plt
|
|
77
|
+
>>> fig, ax = plt.subplots()
|
|
78
|
+
>>> ax.plot([0, 1], [0, 1], lw=0.3) # below most journal minimums
|
|
79
|
+
>>> results = check_line_weights(fig, spec)
|
|
80
|
+
>>> results[0].is_failure
|
|
81
|
+
True
|
|
82
|
+
|
|
83
|
+
Notes
|
|
84
|
+
-----
|
|
85
|
+
- Line labels are read from
|
|
86
|
+
:meth:`~matplotlib.lines.Line2D.get_label`. Auto-generated legend
|
|
87
|
+
labels (``"_line0"``, ``"_collection0"``, etc.) start with
|
|
88
|
+
``"_"``, but the check reports them as ``"(unlabeled)"`` only when
|
|
89
|
+
the label is empty or ``None`` — the underscore-prefixed auto-labels
|
|
90
|
+
are included as-is so the user can identify the artist.
|
|
91
|
+
- The tolerance applied to width comparisons is strict (``<`` rather
|
|
92
|
+
than ``<=``) so that a line *exactly* at the minimum passes.
|
|
93
|
+
"""
|
|
94
|
+
min_linewidth: float = spec.line.min_weight_pt
|
|
95
|
+
violations: list[str] = []
|
|
96
|
+
|
|
97
|
+
for ax in fig.get_axes():
|
|
98
|
+
# --- Plotted Line2D objects ---
|
|
99
|
+
for line in ax.get_lines():
|
|
100
|
+
lw = line.get_linewidth()
|
|
101
|
+
if lw < min_linewidth:
|
|
102
|
+
# Prefer the user-set label; fall back to a generic descriptor.
|
|
103
|
+
label = line.get_label() or "(unlabeled line)"
|
|
104
|
+
violations.append(f"{label!r}: {lw:.2f}pt")
|
|
105
|
+
|
|
106
|
+
# --- Axis spines (frame borders) ---
|
|
107
|
+
for spine_name, spine in ax.spines.items():
|
|
108
|
+
lw = spine.get_linewidth()
|
|
109
|
+
if lw < min_linewidth:
|
|
110
|
+
violations.append(f"spine '{spine_name}': {lw:.2f}pt")
|
|
111
|
+
|
|
112
|
+
if violations:
|
|
113
|
+
# Show only the first few violations to keep the message readable.
|
|
114
|
+
truncated = violations[:_MAX_VIOLATION_EXAMPLES]
|
|
115
|
+
suffix = (
|
|
116
|
+
f" … and {len(violations) - _MAX_VIOLATION_EXAMPLES} more."
|
|
117
|
+
if len(violations) > _MAX_VIOLATION_EXAMPLES
|
|
118
|
+
else ""
|
|
119
|
+
)
|
|
120
|
+
return [
|
|
121
|
+
CheckResult(
|
|
122
|
+
status=CheckStatus.FAIL,
|
|
123
|
+
check_name="lines.min_weight",
|
|
124
|
+
message=(
|
|
125
|
+
f"{len(violations)} element(s) below the "
|
|
126
|
+
f"{spec.metadata.name} minimum of {min_linewidth}pt: "
|
|
127
|
+
f"{'; '.join(truncated)}{suffix}"
|
|
128
|
+
),
|
|
129
|
+
fix_suggestion=(
|
|
130
|
+
f"Set linewidth ≥ {min_linewidth}pt on all plotted lines. "
|
|
131
|
+
"Apply globally with "
|
|
132
|
+
f"mpl.rcParams['lines.linewidth'] = {min_linewidth}, or "
|
|
133
|
+
"per-line with the lw= keyword argument."
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
return [
|
|
139
|
+
CheckResult(
|
|
140
|
+
status=CheckStatus.PASS,
|
|
141
|
+
check_name="lines.min_weight",
|
|
142
|
+
message=(
|
|
143
|
+
f"All plotted lines and spines meet the "
|
|
144
|
+
f"{spec.metadata.name} minimum line weight of {min_linewidth}pt."
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Typography validation check — font size compliance.
|
|
2
|
+
|
|
3
|
+
This module registers :func:`check_typography`, which verifies that every
|
|
4
|
+
visible text element in a figure falls within the minimum and maximum font
|
|
5
|
+
sizes specified by the target journal.
|
|
6
|
+
|
|
7
|
+
Text elements inspected
|
|
8
|
+
-----------------------
|
|
9
|
+
- Figure-level text (``fig.texts`` — suptitle, annotations added directly to
|
|
10
|
+
the figure).
|
|
11
|
+
- Axes titles (``ax.title``).
|
|
12
|
+
- Axis labels (``ax.xaxis.label``, ``ax.yaxis.label``).
|
|
13
|
+
- Tick labels (``ax.get_xticklabels()``, ``ax.get_yticklabels()``).
|
|
14
|
+
- Legend text entries (from every legend attached to an axes).
|
|
15
|
+
|
|
16
|
+
Empty and whitespace-only text artists are skipped because they contribute no
|
|
17
|
+
visible content and should not be penalised for having a default font size that
|
|
18
|
+
might fall outside the permitted range.
|
|
19
|
+
|
|
20
|
+
Why font size matters
|
|
21
|
+
---------------------
|
|
22
|
+
Journals enforce minimum font sizes (typically 6-7 pt) to ensure legibility
|
|
23
|
+
after reduction to column width during typesetting. Maximum sizes prevent
|
|
24
|
+
labels from dominating the figure area and clashing with the journal's body
|
|
25
|
+
text.
|
|
26
|
+
|
|
27
|
+
Example
|
|
28
|
+
-------
|
|
29
|
+
>>> from plotstyle.validation.checks.typography import check_typography
|
|
30
|
+
>>> results = check_typography(fig, spec)
|
|
31
|
+
>>> results[0].check_name
|
|
32
|
+
'typography.font_size'
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from plotstyle.validation.checks._base import check
|
|
40
|
+
from plotstyle.validation.report import CheckResult, CheckStatus
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from matplotlib.figure import Figure
|
|
44
|
+
from matplotlib.text import Text
|
|
45
|
+
|
|
46
|
+
from plotstyle.specs.schema import JournalSpec
|
|
47
|
+
|
|
48
|
+
# Maximum number of violation examples to include in the result message.
|
|
49
|
+
_MAX_VIOLATION_EXAMPLES: int = 5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Internal helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _gather_text_artists(fig: Figure) -> list[Text]:
|
|
58
|
+
"""Collect all relevant :class:`~matplotlib.text.Text` artists from *fig*.
|
|
59
|
+
|
|
60
|
+
Includes figure-level text and all per-axes text elements that carry
|
|
61
|
+
meaningful content labels: titles, axis labels, tick labels, and legend
|
|
62
|
+
entries.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
fig: The figure to inspect.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Flat list of :class:`~matplotlib.text.Text` instances in the order:
|
|
70
|
+
figure texts → (for each axes) title, x-label, y-label, x-tick
|
|
71
|
+
labels, y-tick labels, legend texts.
|
|
72
|
+
|
|
73
|
+
Notes
|
|
74
|
+
-----
|
|
75
|
+
- Axes with empty titles or labels are included; the caller
|
|
76
|
+
(:func:`check_typography`) is responsible for skipping
|
|
77
|
+
whitespace-only entries.
|
|
78
|
+
- The list may contain duplicates if an artist is somehow shared
|
|
79
|
+
across axes; in practice this does not occur in normal figure
|
|
80
|
+
construction.
|
|
81
|
+
"""
|
|
82
|
+
texts: list[Text] = list(fig.texts) # copy to avoid mutating fig.texts
|
|
83
|
+
|
|
84
|
+
for ax in fig.get_axes():
|
|
85
|
+
texts.append(ax.title)
|
|
86
|
+
texts.append(ax.xaxis.label)
|
|
87
|
+
texts.append(ax.yaxis.label)
|
|
88
|
+
texts.extend(ax.get_xticklabels())
|
|
89
|
+
texts.extend(ax.get_yticklabels())
|
|
90
|
+
|
|
91
|
+
legend = ax.get_legend()
|
|
92
|
+
if legend is not None:
|
|
93
|
+
texts.extend(legend.get_texts())
|
|
94
|
+
|
|
95
|
+
return texts
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Registered check
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@check
|
|
104
|
+
def check_typography(fig: Figure, spec: JournalSpec) -> list[CheckResult]:
|
|
105
|
+
"""Validate all visible text elements against journal font size limits.
|
|
106
|
+
|
|
107
|
+
Gathers every :class:`~matplotlib.text.Text` artist in *fig* (via
|
|
108
|
+
:func:`_gather_text_artists`), skips empty/whitespace-only entries, and
|
|
109
|
+
checks whether each artist's font size is within
|
|
110
|
+
``[spec.typography.min_font_pt, spec.typography.max_font_pt]``.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
fig: The :class:`~matplotlib.figure.Figure` to inspect. Should be
|
|
114
|
+
fully composed before calling (tick labels may not be finalised
|
|
115
|
+
until the figure is rendered or the layout engine has run).
|
|
116
|
+
spec: Journal specification providing ``typography.min_font_pt``,
|
|
117
|
+
``typography.max_font_pt``, and ``metadata.name``.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
A list containing exactly one :class:`~plotstyle.validation.report.CheckResult`
|
|
122
|
+
with check name ``"typography.font_size"``. Status is:
|
|
123
|
+
|
|
124
|
+
- ``PASS`` — all non-empty text elements are within the allowed range.
|
|
125
|
+
- ``FAIL`` — one or more text elements fall outside the range, with
|
|
126
|
+
up to :data:`_MAX_VIOLATION_EXAMPLES` specific violations reported.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
>>> import matplotlib.pyplot as plt
|
|
130
|
+
>>> fig, ax = plt.subplots()
|
|
131
|
+
>>> ax.set_xlabel("Time (s)", fontsize=4) # below most minimums
|
|
132
|
+
>>> results = check_typography(fig, spec)
|
|
133
|
+
>>> results[0].is_failure
|
|
134
|
+
True
|
|
135
|
+
|
|
136
|
+
Notes
|
|
137
|
+
-----
|
|
138
|
+
- Font size is read via :meth:`~matplotlib.text.Text.get_fontsize`,
|
|
139
|
+
which returns the *effective* size in points after resolving any
|
|
140
|
+
relative size strings (``"small"``, ``"large"``, etc.).
|
|
141
|
+
- Tick labels are computed from the axes' locator/formatter when the
|
|
142
|
+
figure is drawn; calling this check before ``fig.canvas.draw()`` may
|
|
143
|
+
return empty tick-label strings, causing them to be skipped. For
|
|
144
|
+
accurate tick-label validation, call ``fig.canvas.draw()`` or use
|
|
145
|
+
``constrained_layout=True`` before validation.
|
|
146
|
+
"""
|
|
147
|
+
min_pt: float = spec.typography.min_font_pt
|
|
148
|
+
max_pt: float = spec.typography.max_font_pt
|
|
149
|
+
|
|
150
|
+
violations: list[str] = []
|
|
151
|
+
|
|
152
|
+
for text in _gather_text_artists(fig):
|
|
153
|
+
content = text.get_text()
|
|
154
|
+
|
|
155
|
+
# Skip artists that carry no visible content.
|
|
156
|
+
if not content or not content.strip():
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
font_size: float = text.get_fontsize()
|
|
160
|
+
|
|
161
|
+
if font_size < min_pt:
|
|
162
|
+
violations.append(f"{content!r}: {font_size:.1f}pt (< {min_pt}pt min)")
|
|
163
|
+
elif font_size > max_pt:
|
|
164
|
+
violations.append(f"{content!r}: {font_size:.1f}pt (> {max_pt}pt max)")
|
|
165
|
+
|
|
166
|
+
if violations:
|
|
167
|
+
truncated = violations[:_MAX_VIOLATION_EXAMPLES]
|
|
168
|
+
suffix = (
|
|
169
|
+
f" … and {len(violations) - _MAX_VIOLATION_EXAMPLES} more."
|
|
170
|
+
if len(violations) > _MAX_VIOLATION_EXAMPLES
|
|
171
|
+
else ""
|
|
172
|
+
)
|
|
173
|
+
return [
|
|
174
|
+
CheckResult(
|
|
175
|
+
status=CheckStatus.FAIL,
|
|
176
|
+
check_name="typography.font_size",
|
|
177
|
+
message=(
|
|
178
|
+
f"{len(violations)} text element(s) outside the "
|
|
179
|
+
f"{spec.metadata.name} range of {min_pt}-{max_pt}pt: "
|
|
180
|
+
f"{'; '.join(truncated)}{suffix}"
|
|
181
|
+
),
|
|
182
|
+
fix_suggestion=(
|
|
183
|
+
f"Set all font sizes to {min_pt}-{max_pt}pt for "
|
|
184
|
+
f"{spec.metadata.name} compliance. Apply globally with "
|
|
185
|
+
f"mpl.rcParams['font.size'] = {min_pt}, or per-element "
|
|
186
|
+
"via the fontsize= keyword argument."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
CheckResult(
|
|
193
|
+
status=CheckStatus.PASS,
|
|
194
|
+
check_name="typography.font_size",
|
|
195
|
+
message=(
|
|
196
|
+
f"All text elements are within the "
|
|
197
|
+
f"{spec.metadata.name} range of {min_pt}-{max_pt}pt."
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
]
|