Skills › Scientific Agent Skills › scientific-visualization › style_presets.py
style_presets.py
503 lines · skills/scientific-visualization/scripts/style_presets.py at 5f2bc03
1#!/usr/bin/env python3
2"""Scoped Matplotlib style presets for scientific figures.
4Presets are visual starting points, not journal-compliance profiles. Matplotlib
5is imported lazily so listing and help work in a standard-library environment.
6"""
8from __future__ import annotations
10import argparse
11import importlib.util
12import json
13import sys
14from contextlib import contextmanager
15from pathlib import Path
16from types import ModuleType
17from typing import Any, Iterator
19from _common import CliError, atomic_write_bytes, checked_output_file
21ASSET_ROOT = Path(__file__).resolve().parents[1] / "assets"
22PROFILE_PATH = ASSET_ROOT / "publisher_profiles.json"
23SCHEMA_VERSION = "1.0"
25BASE_STYLE: dict[str, Any] = {
26 "figure.dpi": 100,
27 "figure.facecolor": "white",
28 "figure.autolayout": False,
29 "figure.constrained_layout.use": False,
30 "font.size": 8,
31 "font.family": "sans-serif",
32 "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
33 "axes.linewidth": 0.6,
34 "axes.labelsize": 8,
35 "axes.titlesize": 8,
36 "axes.labelweight": "normal",
37 "axes.spines.top": False,
38 "axes.spines.right": False,
39 "axes.edgecolor": "black",
40 "axes.labelcolor": "black",
41 "axes.axisbelow": True,
42 "axes.grid": False,
43 "xtick.major.size": 3,
44 "xtick.minor.size": 2,
45 "xtick.major.width": 0.6,
46 "xtick.minor.width": 0.5,
47 "xtick.labelsize": 7,
48 "xtick.direction": "out",
49 "ytick.major.size": 3,
50 "ytick.minor.size": 2,
51 "ytick.major.width": 0.6,
52 "ytick.minor.width": 0.5,
53 "ytick.labelsize": 7,
54 "ytick.direction": "out",
55 "lines.linewidth": 1.4,
56 "lines.markersize": 4,
57 "lines.markeredgewidth": 0.6,
58 "patch.linewidth": 0.6,
59 "legend.fontsize": 7,
60 "legend.frameon": False,
61 "savefig.dpi": 300,
62 "savefig.format": "pdf",
63 # "tight" changes the exported physical page dimensions. Opt in explicitly.
64 "savefig.bbox": "standard",
65 "savefig.pad_inches": 0.05,
66 "savefig.transparent": False,
67 "savefig.facecolor": "white",
68 "savefig.edgecolor": "white",
69 "image.cmap": "viridis",
70 # Type 42 keeps TrueType fonts editable/embedded in PDF/PS.
71 "pdf.fonttype": 42,
72 "ps.fonttype": 42,
73 # SVG text remains text but depends on font availability at render time.
74 "svg.fonttype": "none",
75}
77STYLE_OVERRIDES: dict[str, dict[str, Any]] = {
78 "default": {},
79 "nature": {
80 "font.size": 7,
81 "axes.labelsize": 7,
82 "axes.titlesize": 7,
83 "xtick.labelsize": 6,
84 "ytick.labelsize": 6,
85 "legend.fontsize": 6,
86 "lines.linewidth": 1.0,
87 "savefig.dpi": 450,
88 },
89 "science": {
90 "font.size": 7,
91 "axes.labelsize": 7,
92 "axes.titlesize": 7,
93 "xtick.labelsize": 6,
94 "ytick.labelsize": 6,
95 "legend.fontsize": 6,
96 "savefig.dpi": 300,
97 },
98 "cell": {
99 "font.size": 7,
100 "axes.labelsize": 8,
101 "axes.titlesize": 8,
102 "xtick.labelsize": 7,
103 "ytick.labelsize": 7,
104 "legend.fontsize": 7,
105 "savefig.dpi": 300,
106 },
107 "minimal": {
108 "axes.linewidth": 0.8,
109 "xtick.major.width": 0.8,
110 "ytick.major.width": 0.8,
111 "lines.linewidth": 1.8,
112 },
113 "presentation": {
114 "font.size": 14,
115 "axes.labelsize": 16,
116 "axes.titlesize": 18,
117 "xtick.labelsize": 12,
118 "ytick.labelsize": 12,
119 "legend.fontsize": 12,
120 "axes.linewidth": 1.4,
121 "lines.linewidth": 2.4,
122 "lines.markersize": 8,
123 "savefig.format": "png",
124 },
125}
127STYLE_NOTICES = {
128 "nature": (
129 "Starting point based on flagship Nature final-artwork guidance "
130 "accessed 2026-10-01; verify article stage and current instructions."
131 ),
132 "science": (
133 "Historical Science revised-manuscript guidance accessed 2026-07-23; "
134 "live access failed on 2026-10-01. Verify current journal instructions."
135 ),
136 "cell": (
137 "Starting point based on general Cell Press production guidance accessed "
138 "2026-10-01; journal and article-type exceptions exist."
139 ),
140}
143def _load_palette_asset() -> ModuleType:
144 asset = ASSET_ROOT / "color_palettes.py"
145 spec = importlib.util.spec_from_file_location(
146 "_scientific_visualization_color_palettes", asset
147 )
148 if spec is None or spec.loader is None:
149 raise CliError(f"cannot load bundled palette asset: {asset}")
150 module = importlib.util.module_from_spec(spec)
151 spec.loader.exec_module(module)
152 return module
155def available_palettes() -> dict[str, list[str]]:
156 palettes = getattr(_load_palette_asset(), "PALETTES", None)
157 if not isinstance(palettes, dict):
158 raise CliError("bundled palette asset does not define PALETTES")
159 return {name: list(colors) for name, colors in palettes.items()}
162def get_style(
163 style_name: str = "default",
164 *,
165 palette_name: str = "okabe_ito_on_white",
166) -> dict[str, Any]:
167 """Return a validated plain rcParams dictionary without global mutation."""
168 if style_name not in STYLE_OVERRIDES:
169 raise CliError(
170 f"unknown style {style_name!r}; "
171 f"available: {', '.join(sorted(STYLE_OVERRIDES))}"
172 )
173 palettes = available_palettes()
174 if palette_name not in palettes:
175 raise CliError(
176 f"unknown palette {palette_name!r}; "
177 f"available: {', '.join(sorted(palettes))}"
178 )
179 style = dict(BASE_STYLE)
180 style.update(STYLE_OVERRIDES[style_name])
181 # Stored separately because a Matplotlib Cycler is not JSON serializable.
182 style["_palette_colors"] = palettes[palette_name]
183 return style
186def _matplotlib_style(style: dict[str, Any]) -> dict[str, Any]:
187 try:
188 import matplotlib as mpl
189 except ImportError as exc:
190 raise CliError(
191 "Matplotlib is required to apply styles; "
192 "run with --with 'matplotlib==3.11.2'"
193 ) from exc
194 prepared = dict(style)
195 colors = prepared.pop("_palette_colors")
196 prepared["axes.prop_cycle"] = mpl.cycler(color=colors)
197 # rcParams validation catches stale or misspelled keys.
198 validated = mpl.RcParams()
199 validated.update(prepared)
200 return dict(validated)
203def apply_publication_style(
204 style_name: str = "default",
205 *,
206 palette_name: str = "okabe_ito_on_white",
207 reset: bool = False,
208) -> dict[str, Any]:
209 """Apply a validated style globally and return the settings used."""
210 try:
211 import matplotlib as mpl
212 except ImportError as exc:
213 raise CliError(
214 "Matplotlib is required to apply styles; "
215 "run with --with 'matplotlib==3.11.2'"
216 ) from exc
217 if reset:
218 mpl.rcdefaults()
219 style = _matplotlib_style(get_style(style_name, palette_name=palette_name))
220 mpl.rcParams.update(style)
221 return {
222 "style": style_name,
223 "palette": palette_name,
224 "notice": STYLE_NOTICES.get(
225 style_name,
226 "General-purpose visual preset; review the rendered figure in context.",
227 ),
228 }
231@contextmanager
232def style_context(
233 style_name: str = "default",
234 *,
235 palette_name: str = "okabe_ito_on_white",
236) -> Iterator[dict[str, Any]]:
237 """Temporarily apply a preset without leaking global rcParams changes."""
238 try:
239 import matplotlib as mpl
240 except ImportError as exc:
241 raise CliError(
242 "Matplotlib is required to use a style context; "
243 "run with --with 'matplotlib==3.11.2'"
244 ) from exc
245 style = _matplotlib_style(get_style(style_name, palette_name=palette_name))
246 with mpl.rc_context(style):
247 yield {
248 "style": style_name,
249 "palette": palette_name,
250 "notice": STYLE_NOTICES.get(style_name),
251 }
254def set_color_palette(palette_name: str = "okabe_ito_on_white") -> list[str]:
255 """Set only the Matplotlib color cycle, preserving other rcParams."""
256 try:
257 import matplotlib as mpl
258 except ImportError as exc:
259 raise CliError(
260 "Matplotlib is required to set a palette; "
261 "run with --with 'matplotlib==3.11.2'"
262 ) from exc
263 palettes = available_palettes()
264 if palette_name not in palettes:
265 raise CliError(
266 f"unknown palette {palette_name!r}; "
267 f"available: {', '.join(sorted(palettes))}"
268 )
269 colors = palettes[palette_name]
270 mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=colors)
271 return colors
274def load_publisher_profiles() -> dict[str, Any]:
275 """Load the bundled dated publisher snapshots."""
276 try:
277 document = json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
278 except (OSError, json.JSONDecodeError) as exc:
279 raise CliError(f"cannot load publisher profiles: {exc}") from exc
280 profiles = document.get("profiles")
281 if not isinstance(profiles, dict):
282 raise CliError("publisher profile asset is malformed")
283 return document
286def figure_size_for_profile(
287 publisher: str,
288 width: str,
289 *,
290 aspect: float = 0.75,
291) -> tuple[float, float]:
292 """Return width/height in inches from a dated profile snapshot."""
293 if not (math_is_finite_positive(aspect)):
294 raise CliError("aspect must be finite and greater than zero")
295 document = load_publisher_profiles()
296 try:
297 profile = document["profiles"][publisher]
298 except KeyError as exc:
299 raise CliError(
300 f"unknown publisher {publisher!r}; "
301 f"available: {', '.join(sorted(document['profiles']))}"
302 ) from exc
303 widths = profile.get("widths_mm") or {}
304 if width not in widths:
305 raise CliError(
306 f"width {width!r} unavailable for {publisher}; "
307 f"available: {', '.join(sorted(widths))}"
308 )
309 width_inches = float(widths[width]) / 25.4
310 return width_inches, width_inches * aspect
313def math_is_finite_positive(value: float) -> bool:
314 return value > 0 and value < float("inf")
317def configure_for_journal(
318 journal: str,
319 figure_width: str = "single",
320 *,
321 aspect: float = 0.75,
322 palette_name: str = "okabe_ito_on_white",
323) -> dict[str, Any]:
324 """Compatibility helper using a dated profile, without claiming compliance."""
325 aliases = {
326 "plos": "default",
327 "acs": "default",
328 "ieee": "default",
329 "bmc": "default",
330 "elsevier": "default",
331 }
332 style_name = journal if journal in {"nature", "science", "cell"} else aliases.get(journal)
333 if style_name is None:
334 raise CliError(f"unknown journal/publisher profile: {journal!r}")
335 notice = apply_publication_style(style_name, palette_name=palette_name)
336 width_aliases = {
337 "nature": {"double": "full"},
338 "cell": {"double": "full"},
339 "plos": {"single": "text-column", "double": "full"},
340 "acs": {"double": "full"},
341 "ieee": {"double": "full"},
342 "bmc": {"single": "half", "double": "full"},
343 "elsevier": {"double": "full"},
344 }
345 profile_width = width_aliases.get(journal, {}).get(
346 figure_width, figure_width
347 )
348 width_inches, height_inches = figure_size_for_profile(
349 journal, profile_width, aspect=aspect
350 )
351 try:
352 import matplotlib as mpl
353 except ImportError as exc:
354 raise CliError("Matplotlib is required to configure figure size") from exc
355 mpl.rcParams["figure.figsize"] = (width_inches, height_inches)
356 return {
357 **notice,
358 "publisher_profile": journal,
359 "profile_width": profile_width,
360 "figsize_inches": [width_inches, height_inches],
361 "profile_accessed": load_publisher_profiles()["profiles"][journal].get(
362 "accessed", load_publisher_profiles()["accessed"]
363 ),
364 "notice": (
365 "Configured from a dated planning snapshot. This does not establish "
366 "journal compliance; verify current target-journal instructions."
367 ),
368 }
371def _mplstyle_value(key: str, value: Any) -> str:
372 if isinstance(value, bool):
373 return "True" if value else "False"
374 if isinstance(value, (list, tuple)):
375 return ", ".join(str(item) for item in value)
376 return str(value)
379def create_style_template(
380 output_file: str | Path,
381 *,
382 style_name: str = "default",
383 palette_name: str = "okabe_ito_on_white",
384 force: bool = False,
385) -> Path:
386 """Write a parseable mplstyle file, refusing implicit overwrite."""
387 output = checked_output_file(output_file, force=force)
388 style = get_style(style_name, palette_name=palette_name)
389 colors = style.pop("_palette_colors")
390 lines = [
391 "# Scientific visualization style preset",
392 f"# style: {style_name}; palette: {palette_name}",
393 "# Verify current target-journal rules before submission.",
394 "",
395 ]
396 for key, value in style.items():
397 lines.append(f"{key}: {_mplstyle_value(key, value)}")
398 lines.append(
399 "axes.prop_cycle: cycler('color', "
400 + json.dumps(colors)
401 + ")"
402 )
403 payload = ("\n".join(lines) + "\n").encode("utf-8")
404 atomic_write_bytes(output, payload, force=force)
405 return output
408def reset_to_default() -> None:
409 """Reset Matplotlib's global rcParams."""
410 try:
411 import matplotlib as mpl
412 except ImportError as exc:
413 raise CliError("Matplotlib is required to reset rcParams") from exc
414 mpl.rcdefaults()
417def _serializable_style(style_name: str, palette_name: str) -> dict[str, Any]:
418 style = get_style(style_name, palette_name=palette_name)
419 colors = style.pop("_palette_colors")
420 return {
421 "schema_version": SCHEMA_VERSION,
422 "style": style_name,
423 "palette": palette_name,
424 "palette_colors": colors,
425 "rcparams": style,
426 "notice": STYLE_NOTICES.get(
427 style_name,
428 "General-purpose visual preset; verify the rendered output.",
429 ),
430 }
433def build_parser() -> argparse.ArgumentParser:
434 parser = argparse.ArgumentParser(
435 description=(
436 "Inspect or write deterministic Matplotlib style presets. "
437 "Presets are not journal-compliance certifications."
438 )
439 )
440 parser.add_argument(
441 "--list", action="store_true", help="list available styles and palettes"
442 )
443 parser.add_argument(
444 "--show", metavar="STYLE", help="print one style as JSON"
445 )
446 parser.add_argument(
447 "--write", nargs=2, metavar=("STYLE", "PATH"), help="write an mplstyle file"
448 )
449 parser.add_argument(
450 "--palette", default="okabe_ito_on_white", help="palette name"
451 )
452 parser.add_argument(
453 "--force", action="store_true", help="overwrite an existing style file"
454 )
455 return parser
458def main(argv: list[str] | None = None) -> int:
459 parser = build_parser()
460 try:
461 args = parser.parse_args(argv)
462 if args.list:
463 print(
464 json.dumps(
465 {
466 "schema_version": SCHEMA_VERSION,
467 "styles": sorted(STYLE_OVERRIDES),
468 "palettes": {
469 name: colors
470 for name, colors in sorted(available_palettes().items())
471 },
472 },
473 indent=2,
474 sort_keys=True,
475 )
476 )
477 return 0
478 if args.show:
479 print(
480 json.dumps(
481 _serializable_style(args.show, args.palette),
482 indent=2,
483 sort_keys=True,
484 )
485 )
486 return 0
487 if args.write:
488 style_name, output = args.write
489 written = create_style_template(
490 output,
491 style_name=style_name,
492 palette_name=args.palette,
493 force=args.force,
494 )
495 print(written)
496 return 0
497 parser.error("choose --list, --show STYLE, or --write STYLE PATH")
498 except CliError as exc:
499 parser.exit(2, f"error: {exc}\n")
502if __name__ == "__main__":
503 sys.exit(main())