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
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()