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
plotstyle/cli/main.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""PlotStyle CLI — journal-compliant Matplotlib figure toolkit.
|
|
2
|
+
|
|
3
|
+
Entry point for the ``plotstyle`` console command, installed via the
|
|
4
|
+
``[project.scripts]`` table in ``pyproject.toml``:
|
|
5
|
+
|
|
6
|
+
plotstyle = "plotstyle.cli.main:main"
|
|
7
|
+
|
|
8
|
+
The CLI provides quick access to the most common PlotStyle workflows without
|
|
9
|
+
requiring a Python session:
|
|
10
|
+
|
|
11
|
+
$ plotstyle list
|
|
12
|
+
$ plotstyle info nature
|
|
13
|
+
$ plotstyle diff nature ieee
|
|
14
|
+
$ plotstyle fonts --journal science
|
|
15
|
+
$ plotstyle validate figure1.pdf --journal nature
|
|
16
|
+
$ plotstyle export figure1.png --journal ieee --formats pdf,eps
|
|
17
|
+
|
|
18
|
+
Design decisions
|
|
19
|
+
----------------
|
|
20
|
+
- **Stdlib only** — :mod:`argparse` is the sole CLI dependency so that the
|
|
21
|
+
command is available in minimal environments where optional dependencies
|
|
22
|
+
(e.g., Rich, Click) may not be installed.
|
|
23
|
+
- **Lazy imports inside command handlers** — PlotStyle sub-packages import
|
|
24
|
+
Matplotlib and other heavy dependencies. Deferring those imports to the
|
|
25
|
+
individual ``_cmd_*`` functions means that ``plotstyle --help`` and
|
|
26
|
+
``plotstyle list`` start instantly without loading the full package graph.
|
|
27
|
+
- **Integer exit codes** — every handler returns ``0`` on success and ``1``
|
|
28
|
+
on error, matching the POSIX convention expected by shell scripts and CI
|
|
29
|
+
pipelines. :func:`main` re-raises nothing; all user-visible errors are
|
|
30
|
+
caught and printed to ``stderr``.
|
|
31
|
+
- **Separation of concerns** — the ``_cmd_*`` functions contain only
|
|
32
|
+
display logic; all domain logic lives in the PlotStyle library.
|
|
33
|
+
|
|
34
|
+
Adding a new sub-command
|
|
35
|
+
------------------------
|
|
36
|
+
1. Implement a ``_cmd_<name>`` function with the signature
|
|
37
|
+
``(...) -> int`` that returns ``0`` on success or ``1`` on error.
|
|
38
|
+
2. Add a ``subparsers.add_parser(...)`` block in :func:`_build_parser`.
|
|
39
|
+
3. Add a dispatch branch in the ``try`` block inside :func:`main`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import sys
|
|
46
|
+
from typing import Final
|
|
47
|
+
|
|
48
|
+
from plotstyle.specs import SpecNotFoundError
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Constants
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
# Maps panel_label_case spec values to a human-readable example string shown
|
|
55
|
+
# in ``plotstyle info``. Defined at module level so it is constructed once.
|
|
56
|
+
_PANEL_LABEL_EXAMPLES: Final[dict[str, str]] = {
|
|
57
|
+
"lower": "a, b, c",
|
|
58
|
+
"upper": "A, B, C",
|
|
59
|
+
"parens_lower": "(a), (b), (c)",
|
|
60
|
+
"parens_upper": "(A), (B), (C)",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Width of the journal-name column in ``plotstyle list`` output.
|
|
64
|
+
_LIST_NAME_WIDTH: int = 15
|
|
65
|
+
|
|
66
|
+
# Separator line used in ``plotstyle info`` output.
|
|
67
|
+
_INFO_SEPARATOR: str = "──────────────────────────"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Command handlers
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_list() -> int:
|
|
76
|
+
"""List all journal presets available in the spec registry.
|
|
77
|
+
|
|
78
|
+
Prints one line per journal in the format::
|
|
79
|
+
|
|
80
|
+
nature Springer Nature
|
|
81
|
+
ieee IEEE
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
``0`` always (listing cannot fail if the registry loads).
|
|
86
|
+
"""
|
|
87
|
+
from plotstyle.specs import registry
|
|
88
|
+
|
|
89
|
+
for name in sorted(registry.list_available()):
|
|
90
|
+
spec = registry.get(name)
|
|
91
|
+
print(f" {name:<{_LIST_NAME_WIDTH}} {spec.metadata.publisher}")
|
|
92
|
+
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cmd_info(journal: str) -> int:
|
|
97
|
+
"""Print a detailed human-readable summary of a journal specification.
|
|
98
|
+
|
|
99
|
+
Covers dimensions (mm and inches), typography, export requirements, and
|
|
100
|
+
accessibility constraints.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
journal: Case-insensitive journal identifier (e.g., ``"nature"``).
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
``0`` on success.
|
|
108
|
+
|
|
109
|
+
Raises
|
|
110
|
+
------
|
|
111
|
+
KeyError: Propagated to :func:`main` if *journal* is not registered.
|
|
112
|
+
"""
|
|
113
|
+
from plotstyle.specs import registry
|
|
114
|
+
from plotstyle.specs.units import Dimension
|
|
115
|
+
|
|
116
|
+
spec = registry.get(journal)
|
|
117
|
+
dim = spec.dimensions
|
|
118
|
+
typo = spec.typography
|
|
119
|
+
exp = spec.export
|
|
120
|
+
col = spec.color
|
|
121
|
+
meta = spec.metadata
|
|
122
|
+
|
|
123
|
+
# Convert column widths from mm to inches for readability in mixed-unit labs.
|
|
124
|
+
single_in: float = Dimension(dim.single_column_mm, "mm").to_inches()
|
|
125
|
+
double_in: float = Dimension(dim.double_column_mm, "mm").to_inches()
|
|
126
|
+
|
|
127
|
+
fonts: str = ", ".join(typo.font_family)
|
|
128
|
+
formats: str = ", ".join(exp.preferred_formats)
|
|
129
|
+
|
|
130
|
+
# Resolve the panel label example; fall back to the raw case string if
|
|
131
|
+
# the value is not one of the four canonical variants.
|
|
132
|
+
label_example: str = _PANEL_LABEL_EXAMPLES.get(typo.panel_label_case, typo.panel_label_case)
|
|
133
|
+
|
|
134
|
+
avoid: str = ", ".join("-".join(pair) for pair in col.avoid_combinations) or "none"
|
|
135
|
+
|
|
136
|
+
print(f"Journal: {meta.name}")
|
|
137
|
+
print(f"Publisher: {meta.publisher}")
|
|
138
|
+
print(f"Source: {meta.source_url}")
|
|
139
|
+
print(f"Last Verified: {meta.last_verified}")
|
|
140
|
+
print(_INFO_SEPARATOR)
|
|
141
|
+
print("Dimensions:")
|
|
142
|
+
print(f" Single column: {dim.single_column_mm}mm ({single_in:.2f}in)")
|
|
143
|
+
print(f" Double column: {dim.double_column_mm}mm ({double_in:.2f}in)")
|
|
144
|
+
print(f" Max height: {dim.max_height_mm}mm")
|
|
145
|
+
print("Typography:")
|
|
146
|
+
print(f" Font: {fonts} (fallback: {typo.font_fallback})")
|
|
147
|
+
print(f" Size range: {typo.min_font_pt}-{typo.max_font_pt}pt")
|
|
148
|
+
print(
|
|
149
|
+
f" Panel labels: {typo.panel_label_pt}pt "
|
|
150
|
+
f"{typo.panel_label_weight} {typo.panel_label_case} "
|
|
151
|
+
f"({label_example})"
|
|
152
|
+
)
|
|
153
|
+
print("Export:")
|
|
154
|
+
print(f" Formats: {formats}")
|
|
155
|
+
print(f" Min DPI: {exp.min_dpi}")
|
|
156
|
+
print(f" Color: {exp.color_space}")
|
|
157
|
+
print("Accessibility:")
|
|
158
|
+
print(f" Colorblind safe: {'Required' if col.colorblind_required else 'Not required'}")
|
|
159
|
+
print(f" Grayscale safe: {'Required' if col.grayscale_required else 'Not required'}")
|
|
160
|
+
print(f" Avoid: {avoid}")
|
|
161
|
+
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_diff(journal_a: str, journal_b: str) -> int:
|
|
166
|
+
"""Print a structured comparison of two journal specifications.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
journal_a: Identifier of the first journal.
|
|
170
|
+
journal_b: Identifier of the second journal.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
``0`` on success.
|
|
175
|
+
|
|
176
|
+
Raises
|
|
177
|
+
------
|
|
178
|
+
KeyError: Propagated to :func:`main` if either journal is not
|
|
179
|
+
registered.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
$ plotstyle diff nature ieee
|
|
183
|
+
"""
|
|
184
|
+
from plotstyle.core.migrate import diff
|
|
185
|
+
|
|
186
|
+
# ``diff`` returns a SpecDiff whose __str__ renders the comparison table.
|
|
187
|
+
print(diff(journal_a, journal_b))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _cmd_fonts(journal: str) -> int:
|
|
192
|
+
"""Check which of a journal's required fonts are available on this system.
|
|
193
|
+
|
|
194
|
+
Reports the best available font and whether it is an exact match or an
|
|
195
|
+
acceptable substitute.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
journal: Case-insensitive journal identifier.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
``0`` on success.
|
|
203
|
+
|
|
204
|
+
Raises
|
|
205
|
+
------
|
|
206
|
+
KeyError: Propagated to :func:`main` if *journal* is not registered.
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
$ plotstyle fonts --journal nature
|
|
210
|
+
"""
|
|
211
|
+
from plotstyle.engine.fonts import detect_available, select_best
|
|
212
|
+
from plotstyle.specs import registry
|
|
213
|
+
|
|
214
|
+
spec = registry.get(journal)
|
|
215
|
+
available = detect_available(spec.typography.font_family)
|
|
216
|
+
best, is_exact = select_best(spec)
|
|
217
|
+
|
|
218
|
+
print(f"Font check for: {spec.metadata.name}")
|
|
219
|
+
print(f"Required: {', '.join(spec.typography.font_family)}")
|
|
220
|
+
print(f"Available: {', '.join(available) if available else 'none'}")
|
|
221
|
+
print(f"Selected: {best}")
|
|
222
|
+
print(f"Exact match: {'Yes' if is_exact else 'No (using acceptable substitute)'}")
|
|
223
|
+
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _cmd_validate(file: str, journal: str) -> int:
|
|
228
|
+
"""Validate a saved figure file against a journal's publication spec.
|
|
229
|
+
|
|
230
|
+
CLI validation is intentionally limited to checks that can be performed
|
|
231
|
+
on a saved file (e.g., PDF font embedding). Full validation — which
|
|
232
|
+
inspects live Matplotlib artists — requires a Python session.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
file: Path to the saved figure file (PDF, PNG, SVG, …).
|
|
236
|
+
journal: Case-insensitive journal identifier.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
``0`` on success; ``1`` if the file is not found.
|
|
241
|
+
|
|
242
|
+
Raises
|
|
243
|
+
------
|
|
244
|
+
KeyError: Propagated to :func:`main` if *journal* is not registered.
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
$ plotstyle validate figure1.pdf --journal nature
|
|
248
|
+
"""
|
|
249
|
+
from pathlib import Path
|
|
250
|
+
|
|
251
|
+
from plotstyle.engine.fonts import verify_embedded
|
|
252
|
+
from plotstyle.specs import registry
|
|
253
|
+
|
|
254
|
+
path = Path(file)
|
|
255
|
+
if not path.exists():
|
|
256
|
+
# Use stderr so the error is visible even when stdout is redirected.
|
|
257
|
+
print(f"Error: file not found: {file}", file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
spec = registry.get(journal)
|
|
261
|
+
print(f"Validation against: {spec.metadata.name}")
|
|
262
|
+
print()
|
|
263
|
+
|
|
264
|
+
if path.suffix.lower() == ".pdf":
|
|
265
|
+
hits = verify_embedded(path)
|
|
266
|
+
type3_found = any(h.get("type") == "Type3" for h in hits)
|
|
267
|
+
if type3_found:
|
|
268
|
+
print("✗ FAIL Type 3 fonts detected — submission systems may reject this.")
|
|
269
|
+
else:
|
|
270
|
+
print("✓ PASS No Type 3 fonts detected (TrueType embedding OK).")
|
|
271
|
+
else:
|
|
272
|
+
print(f"File format: {path.suffix}")
|
|
273
|
+
print("Font embedding check is only available for PDF files.")
|
|
274
|
+
|
|
275
|
+
print()
|
|
276
|
+
print(
|
|
277
|
+
"Note: Full validation requires a live Matplotlib Figure object.\n"
|
|
278
|
+
f" Use plotstyle.validate(fig, journal={journal!r}) in Python\n"
|
|
279
|
+
" for complete checks (dimensions, typography, colour, line weights)."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _cmd_export(
|
|
286
|
+
file: str,
|
|
287
|
+
journal: str,
|
|
288
|
+
formats: str | None,
|
|
289
|
+
author: str | None,
|
|
290
|
+
output_dir: str,
|
|
291
|
+
) -> int:
|
|
292
|
+
"""Print guidance for re-exporting a figure in journal-compliant formats.
|
|
293
|
+
|
|
294
|
+
Re-export from the CLI is not supported because it requires the original
|
|
295
|
+
Matplotlib ``Figure`` object. This handler prints an actionable message
|
|
296
|
+
showing the equivalent Python call.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
file: Path to the figure file (used only for display purposes).
|
|
300
|
+
journal: Journal identifier (used for the example snippet).
|
|
301
|
+
formats: Comma-separated output formats, or ``None`` for the journal
|
|
302
|
+
default.
|
|
303
|
+
author: Author surname for IEEE-style file naming, or ``None``.
|
|
304
|
+
output_dir: Target directory for exported files.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
``0`` always (the message is informational, not an error).
|
|
309
|
+
|
|
310
|
+
Notes
|
|
311
|
+
-----
|
|
312
|
+
All parameters are accepted so that the argument parser can validate
|
|
313
|
+
them, even though the handler itself uses only *journal* in its output.
|
|
314
|
+
This preserves forward compatibility if re-export support is added later.
|
|
315
|
+
"""
|
|
316
|
+
# Suppress "unused variable" warnings from linters; the parameters are
|
|
317
|
+
# intentionally accepted for API stability but not yet used in output.
|
|
318
|
+
_ = file, formats, author, output_dir
|
|
319
|
+
|
|
320
|
+
print(
|
|
321
|
+
"Re-export requires the original Matplotlib Figure object.\n"
|
|
322
|
+
"Use plotstyle.export_submission(fig, ...) in Python.\n\n"
|
|
323
|
+
"Example:\n"
|
|
324
|
+
" import plotstyle\n"
|
|
325
|
+
f" plotstyle.export_submission(fig, 'fig1', journal={journal!r})"
|
|
326
|
+
)
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Argument parser factory
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
336
|
+
"""Construct and return the top-level argument parser.
|
|
337
|
+
|
|
338
|
+
Factored out of :func:`main` so that the parser can be instantiated in
|
|
339
|
+
tests without invoking :func:`sys.exit`.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
A fully configured :class:`~argparse.ArgumentParser` with all
|
|
344
|
+
sub-commands registered.
|
|
345
|
+
"""
|
|
346
|
+
parser = argparse.ArgumentParser(
|
|
347
|
+
prog="plotstyle",
|
|
348
|
+
description="PlotStyle — journal-compliant Matplotlib figure toolkit",
|
|
349
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
350
|
+
epilog=(
|
|
351
|
+
"Examples:\n"
|
|
352
|
+
" plotstyle list\n"
|
|
353
|
+
" plotstyle info nature\n"
|
|
354
|
+
" plotstyle diff nature ieee\n"
|
|
355
|
+
" plotstyle fonts --journal science\n"
|
|
356
|
+
" plotstyle validate figure1.pdf --journal nature\n"
|
|
357
|
+
" plotstyle export figure1.png --journal ieee --formats pdf,eps"
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
362
|
+
|
|
363
|
+
# ── plotstyle list ────────────────────────────────────────────────────
|
|
364
|
+
subparsers.add_parser(
|
|
365
|
+
"list",
|
|
366
|
+
help="List all available journal presets",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ── plotstyle info <journal> ──────────────────────────────────────────
|
|
370
|
+
sub_info = subparsers.add_parser(
|
|
371
|
+
"info",
|
|
372
|
+
help="Show detailed specification for a journal",
|
|
373
|
+
)
|
|
374
|
+
sub_info.add_argument(
|
|
375
|
+
"journal",
|
|
376
|
+
type=str,
|
|
377
|
+
help="Journal identifier (e.g., 'nature', 'ieee')",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# ── plotstyle diff <journal_a> <journal_b> ────────────────────────────
|
|
381
|
+
sub_diff = subparsers.add_parser(
|
|
382
|
+
"diff",
|
|
383
|
+
help="Compare two journal specifications side-by-side",
|
|
384
|
+
)
|
|
385
|
+
sub_diff.add_argument("journal_a", type=str, help="First journal identifier")
|
|
386
|
+
sub_diff.add_argument("journal_b", type=str, help="Second journal identifier")
|
|
387
|
+
|
|
388
|
+
# ── plotstyle fonts --journal <journal> ───────────────────────────────
|
|
389
|
+
sub_fonts = subparsers.add_parser(
|
|
390
|
+
"fonts",
|
|
391
|
+
help="Check font availability for a journal on this system",
|
|
392
|
+
)
|
|
393
|
+
sub_fonts.add_argument(
|
|
394
|
+
"--journal",
|
|
395
|
+
type=str,
|
|
396
|
+
required=True,
|
|
397
|
+
metavar="JOURNAL",
|
|
398
|
+
help="Journal identifier",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# ── plotstyle validate <file> --journal <journal> ─────────────────────
|
|
402
|
+
sub_validate = subparsers.add_parser(
|
|
403
|
+
"validate",
|
|
404
|
+
help="Validate a saved figure file against a journal specification",
|
|
405
|
+
)
|
|
406
|
+
sub_validate.add_argument(
|
|
407
|
+
"file",
|
|
408
|
+
type=str,
|
|
409
|
+
help="Path to the figure file (PDF for font-embedding checks; PNG/SVG for format info)",
|
|
410
|
+
)
|
|
411
|
+
sub_validate.add_argument(
|
|
412
|
+
"--journal",
|
|
413
|
+
type=str,
|
|
414
|
+
required=True,
|
|
415
|
+
metavar="JOURNAL",
|
|
416
|
+
help="Journal identifier",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# ── plotstyle export <file> --journal <journal> ───────────────────────
|
|
420
|
+
sub_export = subparsers.add_parser(
|
|
421
|
+
"export",
|
|
422
|
+
help="Re-export a figure in journal-compliant formats (see note)",
|
|
423
|
+
)
|
|
424
|
+
sub_export.add_argument(
|
|
425
|
+
"file",
|
|
426
|
+
type=str,
|
|
427
|
+
help="Path to the figure file",
|
|
428
|
+
)
|
|
429
|
+
sub_export.add_argument(
|
|
430
|
+
"--journal",
|
|
431
|
+
type=str,
|
|
432
|
+
required=True,
|
|
433
|
+
metavar="JOURNAL",
|
|
434
|
+
help="Journal identifier",
|
|
435
|
+
)
|
|
436
|
+
sub_export.add_argument(
|
|
437
|
+
"--formats",
|
|
438
|
+
type=str,
|
|
439
|
+
default=None,
|
|
440
|
+
metavar="FMT,...",
|
|
441
|
+
help="Comma-separated output formats (default: journal preferred formats)",
|
|
442
|
+
)
|
|
443
|
+
sub_export.add_argument(
|
|
444
|
+
"--author",
|
|
445
|
+
type=str,
|
|
446
|
+
default=None,
|
|
447
|
+
metavar="SURNAME",
|
|
448
|
+
help="Author surname for IEEE-style file naming",
|
|
449
|
+
)
|
|
450
|
+
sub_export.add_argument(
|
|
451
|
+
"--output-dir",
|
|
452
|
+
type=str,
|
|
453
|
+
default=".",
|
|
454
|
+
metavar="DIR",
|
|
455
|
+
help="Directory for exported files (default: current directory)",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return parser
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# Entry point
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def main(argv: list[str] | None = None) -> int:
|
|
467
|
+
"""Entry point for the ``plotstyle`` console command.
|
|
468
|
+
|
|
469
|
+
Parses *argv* (or ``sys.argv[1:]`` when *argv* is ``None``), dispatches
|
|
470
|
+
to the appropriate ``_cmd_*`` handler, and returns a POSIX exit code.
|
|
471
|
+
All :exc:`KeyError` exceptions — which indicate an unrecognised journal
|
|
472
|
+
identifier — are caught here and reported to ``stderr`` with an
|
|
473
|
+
actionable suggestion.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
argv: Argument list to parse. Pass ``None`` (the default) to use
|
|
477
|
+
``sys.argv[1:]``, or supply a list explicitly for testing.
|
|
478
|
+
|
|
479
|
+
Returns
|
|
480
|
+
-------
|
|
481
|
+
``0`` on success; ``1`` on any error (unknown journal, file not
|
|
482
|
+
found, or no sub-command given).
|
|
483
|
+
|
|
484
|
+
Example:
|
|
485
|
+
>>> from plotstyle.cli.main import main
|
|
486
|
+
>>> main(["list"])
|
|
487
|
+
0
|
|
488
|
+
>>> main(["info", "nature"])
|
|
489
|
+
0
|
|
490
|
+
>>> main([])
|
|
491
|
+
1
|
|
492
|
+
|
|
493
|
+
Notes
|
|
494
|
+
-----
|
|
495
|
+
- Only :exc:`~plotstyle.specs.SpecNotFoundError` is caught; all other
|
|
496
|
+
exceptions propagate so that unexpected errors produce a full
|
|
497
|
+
traceback rather than a misleading one-line message.
|
|
498
|
+
- :func:`main` is the value of the ``plotstyle`` console-script entry
|
|
499
|
+
point; it must never call :func:`sys.exit` directly — callers
|
|
500
|
+
(including the ``if __name__ == "__main__"`` guard below) are
|
|
501
|
+
responsible for passing the return value to :class:`SystemExit`.
|
|
502
|
+
"""
|
|
503
|
+
parser = _build_parser()
|
|
504
|
+
args = parser.parse_args(argv)
|
|
505
|
+
|
|
506
|
+
if args.command is None:
|
|
507
|
+
parser.print_help()
|
|
508
|
+
return 1
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
if args.command == "list":
|
|
512
|
+
return _cmd_list()
|
|
513
|
+
|
|
514
|
+
if args.command == "info":
|
|
515
|
+
return _cmd_info(args.journal)
|
|
516
|
+
|
|
517
|
+
if args.command == "diff":
|
|
518
|
+
return _cmd_diff(args.journal_a, args.journal_b)
|
|
519
|
+
|
|
520
|
+
if args.command == "fonts":
|
|
521
|
+
return _cmd_fonts(args.journal)
|
|
522
|
+
|
|
523
|
+
if args.command == "validate":
|
|
524
|
+
return _cmd_validate(args.file, args.journal)
|
|
525
|
+
|
|
526
|
+
if args.command == "export":
|
|
527
|
+
return _cmd_export(
|
|
528
|
+
args.file,
|
|
529
|
+
args.journal,
|
|
530
|
+
args.formats,
|
|
531
|
+
args.author,
|
|
532
|
+
args.output_dir,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
except SpecNotFoundError as exc:
|
|
536
|
+
# SpecNotFoundError is raised by registry.get() for unknown journal identifiers.
|
|
537
|
+
# Extract the journal name from the exception for a clearer message.
|
|
538
|
+
print(
|
|
539
|
+
f"Error: unknown journal {exc.name!r}.\n"
|
|
540
|
+
"Run 'plotstyle list' to see all available journal identifiers.",
|
|
541
|
+
file=sys.stderr,
|
|
542
|
+
)
|
|
543
|
+
return 1
|
|
544
|
+
|
|
545
|
+
# Defensive fallthrough: should be unreachable if all sub-commands are
|
|
546
|
+
# dispatched above, but guards against future parser additions where the
|
|
547
|
+
# dispatch block is not updated.
|
|
548
|
+
print(f"Error: unhandled command {args.command!r}.", file=sys.stderr)
|
|
549
|
+
return 1
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
if __name__ == "__main__":
|
|
553
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Color and accessibility tools for PlotStyle.
|
|
2
|
+
|
|
3
|
+
This package provides a unified interface for working with journal-aware color
|
|
4
|
+
palettes, grayscale simulation, and colorblind accessibility previews.
|
|
5
|
+
|
|
6
|
+
Public API
|
|
7
|
+
----------
|
|
8
|
+
- :func:`~plotstyle.color.palettes.palette`
|
|
9
|
+
Retrieve a journal-appropriate color palette, optionally with line style
|
|
10
|
+
and marker annotations.
|
|
11
|
+
|
|
12
|
+
- :func:`~plotstyle.color.accessibility.preview_colorblind`
|
|
13
|
+
Render a side-by-side figure panel simulating common color vision
|
|
14
|
+
deficiencies (CVD) using the Machado et al. (2009) matrices.
|
|
15
|
+
|
|
16
|
+
- :func:`~plotstyle.color.grayscale.preview_grayscale`
|
|
17
|
+
Render a side-by-side Original / Grayscale comparison figure using
|
|
18
|
+
ITU-R BT.709 luminance weights.
|
|
19
|
+
|
|
20
|
+
Example
|
|
21
|
+
-------
|
|
22
|
+
>>> import matplotlib.pyplot as plt
|
|
23
|
+
>>> from plotstyle.color import palette, preview_colorblind, preview_grayscale
|
|
24
|
+
>>> colors = palette("nature", n=4)
|
|
25
|
+
>>> fig, ax = plt.subplots()
|
|
26
|
+
>>> for i, c in enumerate(colors):
|
|
27
|
+
... ax.plot([0, 1], [i, i], color=c)
|
|
28
|
+
>>> preview_colorblind(fig) # returns a new comparison figure
|
|
29
|
+
>>> preview_grayscale(fig) # returns a new comparison figure
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from plotstyle.color.accessibility import preview_colorblind
|
|
35
|
+
from plotstyle.color.grayscale import preview_grayscale
|
|
36
|
+
from plotstyle.color.palettes import palette
|
|
37
|
+
|
|
38
|
+
__all__: list[str] = [
|
|
39
|
+
"palette",
|
|
40
|
+
"preview_colorblind",
|
|
41
|
+
"preview_grayscale",
|
|
42
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shared figure-rasterisation helper for the color package.
|
|
2
|
+
|
|
3
|
+
Both :mod:`plotstyle.color.grayscale` and :mod:`plotstyle.color.accessibility`
|
|
4
|
+
require a pixel-level NumPy representation of a live matplotlib Figure before
|
|
5
|
+
applying channel transformations. Centralising this conversion here ensures:
|
|
6
|
+
|
|
7
|
+
- A single, well-tested code path for rasterisation.
|
|
8
|
+
- No duplication of the Agg-backend buffer-reading logic.
|
|
9
|
+
- A stable internal contract that downstream modules can depend on.
|
|
10
|
+
|
|
11
|
+
Note
|
|
12
|
+
----
|
|
13
|
+
This module is intentionally private (``_rendering``). It is not part of
|
|
14
|
+
the public API and may change without notice. External code should not
|
|
15
|
+
import from it directly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from matplotlib.figure import Figure
|
|
26
|
+
from numpy.typing import NDArray
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fig_to_rgb_array(fig: Figure) -> NDArray[np.uint8]:
|
|
30
|
+
"""Render a matplotlib Figure to a writeable RGB NumPy array.
|
|
31
|
+
|
|
32
|
+
Uses the Agg (raster) backend to draw the figure into an in-memory RGBA
|
|
33
|
+
buffer, then returns a *writeable* ``uint8`` copy with the alpha channel
|
|
34
|
+
discarded. Forcing ``canvas.draw()`` before reading the buffer guarantees
|
|
35
|
+
that deferred artists (e.g., tight-layout adjustments) are fully committed
|
|
36
|
+
to the pixel grid.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
fig: A fully constructed :class:`~matplotlib.figure.Figure` instance.
|
|
40
|
+
The figure must have an Agg canvas attached; interactive backends
|
|
41
|
+
that do not expose ``buffer_rgba`` will raise ``AttributeError``.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
A ``uint8`` array of shape ``(H, W, 3)`` representing the RGB pixels,
|
|
46
|
+
where ``H = fig.get_size_inches()[1] * fig.dpi`` and
|
|
47
|
+
``W = fig.get_size_inches()[0] * fig.dpi``.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
AttributeError: If *fig*'s canvas does not support ``buffer_rgba``
|
|
52
|
+
(e.g., a non-Agg backend such as SVG or PDF).
|
|
53
|
+
ValueError: If the buffer size does not match the expected dimensions,
|
|
54
|
+
which can occur when DPI is not a positive finite number.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> import matplotlib.pyplot as plt
|
|
58
|
+
>>> fig, ax = plt.subplots()
|
|
59
|
+
>>> ax.plot([0, 1], [0, 1])
|
|
60
|
+
>>> rgb = _fig_to_rgb_array(fig)
|
|
61
|
+
>>> rgb.shape # (H, W, 3) — exact values depend on figure size/DPI
|
|
62
|
+
(480, 640, 3)
|
|
63
|
+
|
|
64
|
+
Notes
|
|
65
|
+
-----
|
|
66
|
+
- The returned array is a *copy*, so mutating it does not affect the
|
|
67
|
+
figure's internal canvas buffer.
|
|
68
|
+
- Alpha is stripped because downstream pixel transforms (grayscale,
|
|
69
|
+
CVD simulation) operate exclusively in the RGB colour space.
|
|
70
|
+
"""
|
|
71
|
+
# Commit all pending draw operations so the buffer reflects the final
|
|
72
|
+
# rendered state of the figure, including layout engine adjustments.
|
|
73
|
+
fig.canvas.draw()
|
|
74
|
+
|
|
75
|
+
buf = fig.canvas.buffer_rgba()
|
|
76
|
+
|
|
77
|
+
# Compute the expected pixel dimensions from the figure's logical size.
|
|
78
|
+
# Using int() here truncates any floating-point rounding from size * dpi.
|
|
79
|
+
height = int(fig.get_size_inches()[1] * fig.dpi)
|
|
80
|
+
width = int(fig.get_size_inches()[0] * fig.dpi)
|
|
81
|
+
|
|
82
|
+
rgba: NDArray[np.uint8] = np.frombuffer(buf, dtype=np.uint8).reshape(height, width, 4)
|
|
83
|
+
|
|
84
|
+
# Drop the alpha channel (index 3) and return a writeable copy so that
|
|
85
|
+
# callers can safely modify pixel values in-place.
|
|
86
|
+
return rgba[:, :, :3].copy()
|