like-sci-figures 0.0.0-stage → 1.0.0

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 (83) hide show
  1. package/LICENSE +56 -0
  2. package/README.md +93 -2
  3. package/cli.js +79 -0
  4. package/examples/area.json +9 -0
  5. package/examples/bar.json +15 -0
  6. package/examples/bland_altman.json +128 -0
  7. package/examples/bode.json +8 -0
  8. package/examples/box.json +8 -0
  9. package/examples/bubble.json +7 -0
  10. package/examples/contour.json +10 -0
  11. package/examples/corr_matrix.json +99 -0
  12. package/examples/dendrogram.json +5 -0
  13. package/examples/density.json +8 -0
  14. package/examples/dot_plot.json +8 -0
  15. package/examples/ecdf.json +377 -0
  16. package/examples/errorbar.json +7 -0
  17. package/examples/forest.json +12 -0
  18. package/examples/heatmap.json +10 -0
  19. package/examples/hexbin.json +6009 -0
  20. package/examples/histogram.json +6 -0
  21. package/examples/kaplan_meier.json +6 -0
  22. package/examples/line.json +11 -0
  23. package/examples/manhattan.json +7 -0
  24. package/examples/panels.json +28 -0
  25. package/examples/pca.json +12 -0
  26. package/examples/periodogram.json +7 -0
  27. package/examples/pie.json +4 -0
  28. package/examples/radar.json +7 -0
  29. package/examples/ridge.json +11 -0
  30. package/examples/roc.json +6 -0
  31. package/examples/sankey.json +9 -0
  32. package/examples/scatter3d.json +6 -0
  33. package/examples/slope.json +6 -0
  34. package/examples/stacked_bar.json +12 -0
  35. package/examples/surface.json +11 -0
  36. package/examples/taylor.json +9 -0
  37. package/examples/ternary.json +18 -0
  38. package/examples/venn.json +12 -0
  39. package/examples/violin.json +9 -0
  40. package/examples/volcano.json +7 -0
  41. package/examples/waterfall.json +5 -0
  42. package/examples/windrose.json +407 -0
  43. package/package.json +33 -4
  44. package/requirements.txt +9 -0
  45. package/scripts/add_chart.py +201 -0
  46. package/scripts/build_gallery.py +360 -0
  47. package/scripts/chart_plugin.py +153 -0
  48. package/scripts/data_loader.py +260 -0
  49. package/scripts/fig_3d.py +103 -0
  50. package/scripts/fig_area.py +57 -0
  51. package/scripts/fig_bar.py +117 -0
  52. package/scripts/fig_biomed.py +289 -0
  53. package/scripts/fig_box.py +60 -0
  54. package/scripts/fig_bubble.py +65 -0
  55. package/scripts/fig_core.py +195 -0
  56. package/scripts/fig_datasci.py +276 -0
  57. package/scripts/fig_distribution.py +59 -0
  58. package/scripts/fig_engineering.py +224 -0
  59. package/scripts/fig_environ.py +239 -0
  60. package/scripts/fig_errorbar.py +49 -0
  61. package/scripts/fig_forest.py +54 -0
  62. package/scripts/fig_heatmap.py +51 -0
  63. package/scripts/fig_line.py +39 -0
  64. package/scripts/fig_pca.py +101 -0
  65. package/scripts/fig_pie.py +52 -0
  66. package/scripts/fig_radar.py +32 -0
  67. package/scripts/fig_sankey.py +196 -0
  68. package/scripts/fig_scatter.py +44 -0
  69. package/scripts/fig_subplots.py +124 -0
  70. package/scripts/fig_taylor.py +80 -0
  71. package/scripts/fig_venn.py +80 -0
  72. package/scripts/fig_windrose.py +82 -0
  73. package/scripts/font_detector.py +85 -0
  74. package/scripts/gen_figure.py +583 -0
  75. package/scripts/generate_samples.py +322 -0
  76. package/scripts/palette_picker.py +244 -0
  77. package/scripts/palette_pref.py +179 -0
  78. package/scripts/palette_preview.py +334 -0
  79. package/scripts/palettes.py +461 -0
  80. package/scripts/registry.py +138 -0
  81. package/scripts/render_utils.py +233 -0
  82. package/scripts/showcase.py +179 -0
  83. package/scripts/style_manager.py +267 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ """Shared rendering utilities for palette_picker.py and showcase.py.
3
+
4
+ Why this exists
5
+ ---------------
6
+ Before this file existed, palette_picker.py and showcase.py each had a copy
7
+ of:
8
+
9
+ _p() path join helper
10
+ _load_fonts() CJK font detection with the same candidate list
11
+ _render_chart() subprocess wrapper that calls gen_figure.py
12
+ compose_grid() Pillow canvas + header + cell-paste pattern
13
+
14
+ That was ~200 lines of duplicated code. This module is the single home for
15
+ those utilities, so both poster scripts can focus on what they actually do
16
+ (choosing which charts to render, arranging the grid) rather than re-
17
+ implementing the rendering plumbing.
18
+
19
+ Public API
20
+ ----------
21
+ join(*parts) -> absolute path
22
+ load_fonts() -> (dict[size->Font], used_path)
23
+ render_chart(...) -> (out_path, error, elapsed_seconds)
24
+ compose_grid(...) -> canvas Image (composes thumbnails into a grid)
25
+ """
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
32
+ SKILL_DIR = os.path.dirname(SCRIPT_DIR)
33
+ GEN = os.path.join(SCRIPT_DIR, "gen_figure.py")
34
+ PYTHON = sys.executable
35
+
36
+
37
+ def join(*parts):
38
+ """Join path parts and return the absolute path."""
39
+ return os.path.abspath(os.path.join(*[str(p) for p in parts]))
40
+
41
+
42
+ def load_fonts(sizes=(30, 26, 20, 17, 14, 12, 10)):
43
+ """Load CJK-capable fonts at several sizes.
44
+
45
+ Returns (fonts_dict, used_path) where fonts_dict maps size -> ImageFont.
46
+ Falls back to the system default font if no CJK-capable file is found.
47
+ """
48
+ from PIL import ImageFont
49
+ candidates = []
50
+ try:
51
+ from font_detector import detect_cjk_font
52
+ det = detect_cjk_font()
53
+ if det:
54
+ candidates.append(det)
55
+ except Exception:
56
+ pass
57
+ candidates += [
58
+ os.environ.get("FONT_PATH", ""),
59
+ r"C:/Windows/Fonts/msyh.ttc",
60
+ r"C:/Windows/Fonts/msyh.ttf",
61
+ r"C:/Windows/Fonts/simsun.ttc",
62
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
63
+ ]
64
+ used = None
65
+ fonts = {}
66
+ for size in sizes:
67
+ f = None
68
+ for path in candidates:
69
+ if path and os.path.exists(path):
70
+ try:
71
+ f = ImageFont.truetype(path, size)
72
+ except Exception:
73
+ continue
74
+ if f is not None:
75
+ break
76
+ fonts[size] = f
77
+ if used is None and f is not None:
78
+ used = getattr(f, "path", None)
79
+ return fonts, used
80
+
81
+
82
+ def render_chart(chart_type, data_path, out_path, style="nature",
83
+ palette=None, width=3.2, height=2.4, dpi=120,
84
+ timeout=120, extra_args=None):
85
+ """Render one chart via gen_figure.py.
86
+
87
+ Returns (out_path_or_None, error_or_None, elapsed_seconds).
88
+ out_path is None (and error set) if the render failed.
89
+ """
90
+ t0 = time.time()
91
+ cmd = [PYTHON, GEN,
92
+ "--type", chart_type,
93
+ "--data", data_path,
94
+ "--out", out_path,
95
+ "--style", style,
96
+ "--width", str(width),
97
+ "--height", str(height),
98
+ "--dpi", str(dpi)]
99
+ if palette:
100
+ cmd += ["--palette", palette]
101
+ if extra_args:
102
+ cmd += list(extra_args)
103
+ try:
104
+ proc = subprocess.run(cmd, capture_output=True, text=True,
105
+ timeout=timeout)
106
+ dt = time.time() - t0
107
+ if proc.returncode != 0 or not os.path.exists(out_path) \
108
+ or os.path.getsize(out_path) == 0:
109
+ return None, (proc.stderr or "").strip()[-300:], dt
110
+ return out_path, None, dt
111
+ except Exception as exc:
112
+ return None, str(exc), time.time() - t0
113
+
114
+
115
+ def compose_grid(thumbnails, chart_order, header_text, subtitle_text,
116
+ cols, cell_w=360, cell_h=280, label_h=28,
117
+ gap=10, pad=16, header_h=72, footer_text=None,
118
+ author_tag="LIKE Sci Figures"):
119
+ """Compose a grid of chart thumbnails into a single poster canvas.
120
+
121
+ thumbnails: dict[chart_type -> png_path]
122
+ chart_order: ordered list of chart types (determines grid layout)
123
+ header_text / subtitle_text: lines for the green header band
124
+ cols: number of columns (rows computed from len(chart_order))
125
+ author_tag: text displayed at the bottom-right corner of the header (e.g. "LIKE")
126
+ footer_text: optional line at the bottom
127
+
128
+ Returns the composed PIL Image (caller is responsible for .save()).
129
+ """
130
+ import math
131
+ from PIL import Image, ImageDraw, ImageFont
132
+
133
+ n = len(chart_order)
134
+ rows = max(1, int(math.ceil(n / cols)))
135
+
136
+ width = cols * cell_w + (cols - 1) * gap + 2 * pad
137
+ height = (header_h + rows * (cell_h + label_h)
138
+ + (rows - 1) * gap + 2 * pad
139
+ + (40 if footer_text else 0))
140
+
141
+ canvas = Image.new("RGB", (width, height), "white")
142
+ d = ImageDraw.Draw(canvas)
143
+ fonts, font_used = load_fonts((22, 18, 14, 12, 10))
144
+ f22 = fonts.get(22) or ImageFont.load_default()
145
+ f18 = fonts.get(18) or ImageFont.load_default()
146
+ f14 = fonts.get(14) or ImageFont.load_default()
147
+ f12 = fonts.get(12) or ImageFont.load_default()
148
+
149
+ # header
150
+ d.rectangle([0, 0, width, header_h], fill="#0B6E4F")
151
+ d.text((pad + 2, 10), header_text, fill="white", font=f22)
152
+ d.text((pad + 2, 42), subtitle_text, fill="#D7E1EB", font=f14)
153
+
154
+ # author tag at the bottom-right corner of the header band
155
+ if author_tag:
156
+ bbox = d.textbbox((0, 0), author_tag, font=f18)
157
+ tag_w = bbox[2] - bbox[0]
158
+ d.text((width - pad - tag_w - 2, header_h - 28),
159
+ author_tag, fill="#D7E1EB", font=f18)
160
+
161
+ # cells
162
+ for idx, chart_type in enumerate(chart_order):
163
+ col = idx % cols
164
+ row = idx // cols
165
+ x = pad + col * (cell_w + gap)
166
+ y = header_h + pad + row * (cell_h + label_h + gap)
167
+
168
+ d.rectangle([x, y, x + cell_w, y + cell_h],
169
+ fill="white", outline="#DDDDDD")
170
+ png = thumbnails.get(chart_type)
171
+ if png and os.path.exists(png):
172
+ im = Image.open(png).convert("RGB")
173
+ if im.width != cell_w or im.height != cell_h:
174
+ im = im.resize((cell_w, cell_h), Image.LANCZOS)
175
+ canvas.paste(im, (x, y))
176
+ else:
177
+ d.text((x + 10, y + cell_h // 2 - 10), "渲染失败",
178
+ fill="#999999", font=f14)
179
+
180
+ from registry import ZH_NAME
181
+ label = "%s (%s)" % (ZH_NAME.get(chart_type, chart_type),
182
+ chart_type)
183
+ d.text((x + 4, y + cell_h + 4), label, fill="#111111", font=f14)
184
+
185
+ if footer_text:
186
+ d.text((pad, height - 30), footer_text, fill="#555555", font=f12)
187
+
188
+ return canvas
189
+
190
+
191
+ # ── palette_pref integration ───────────────────────────────────────────────
192
+ STATE_REL = os.path.join(".workbuddy", "like-sci-figures", "palette.json")
193
+ AUTO_SENTINEL = "auto"
194
+
195
+
196
+ def read_palette_state(state_path=None, cwd=None):
197
+ """Read the task-scoped palette preference, if any.
198
+
199
+ Returns (palette_slug_or_None, state_file_path). palette is None when no
200
+ record exists; the string "auto" when the user explicitly opted out.
201
+ """
202
+ import json
203
+ if state_path is None:
204
+ state_path = os.path.join(cwd or os.getcwd(), STATE_REL)
205
+ state_path = os.path.abspath(state_path)
206
+ if not os.path.exists(state_path):
207
+ return None, state_path
208
+ try:
209
+ with open(state_path, "r", encoding="utf-8") as fh:
210
+ rec = json.load(fh)
211
+ value = (rec or {}).get("palette")
212
+ return (value if value else None), state_path
213
+ except Exception:
214
+ return None, state_path
215
+
216
+
217
+ def write_palette_state(slug, state_path=None, cwd=None):
218
+ """Persist the user's palette choice for the current task."""
219
+ import json
220
+ import time
221
+ if state_path is None:
222
+ state_path = os.path.join(cwd or os.getcwd(), STATE_REL)
223
+ state_path = os.path.abspath(state_path)
224
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
225
+ rec = {
226
+ "palette": slug,
227
+ "chosen_at": time.strftime("%Y-%m-%d %H:%M:%S"),
228
+ "cwd": os.getcwd(),
229
+ }
230
+ with open(state_path, "w", encoding="utf-8") as fh:
231
+ json.dump(rec, fh, ensure_ascii=False, indent=2)
232
+ fh.write("\n")
233
+ return state_path
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Render every chart type with one palette + one style and stitch a showcase.
3
+
4
+ Why this exists
5
+ ---------------
6
+ After the user picks a palette (via palette_picker.py) and a style, they want
7
+ to see what that combination looks like across *all* chart types — not just
8
+ the 5 representative ones in the picker poster. This script:
9
+
10
+ 1. Renders each of the 36 chart types from examples/ with the chosen
11
+ palette and style (calling gen_figure.py for real).
12
+ 2. Stitches the thumbnails into a single grid PNG.
13
+ 3. Writes a manifest.json listing what was rendered, with per-chart
14
+ timings and output sizes.
15
+
16
+ The user can then present the grid to the user as a "here's what every chart
17
+ will look like with your choice" preview.
18
+
19
+ Usage
20
+ -----
21
+ showcase.py # defaults: --style nature
22
+ showcase.py --palette wx06_amber_navy --style ieee
23
+ showcase.py --palette p6 --style science --out outputs/my-showcase.png
24
+ showcase.py --dry-run # show plan, render nothing
25
+
26
+ Notes
27
+ -----
28
+ * The grid layout is auto-computed: ceil(sqrt(n)) columns for a near-square
29
+ grid, with per-chart Chinese labels.
30
+ * Requires Pillow. Thumbnails need only matplotlib (via gen_figure.py).
31
+ * Chart-type → example mapping lives in registry.py; shared rendering helpers
32
+ live in render_utils.py. This module only owns the showcase-specific layout.
33
+ """
34
+ import argparse
35
+ import json
36
+ import math
37
+ import os
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ from concurrent.futures import ThreadPoolExecutor
42
+
43
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
44
+ sys.path.insert(0, SCRIPT_DIR)
45
+
46
+ from registry import CHART_EXAMPLES, example_path, ZH_NAME
47
+ from render_utils import render_chart, compose_grid
48
+
49
+ SKILL_DIR = os.path.dirname(SCRIPT_DIR)
50
+ DEFAULT_OUT = os.path.join(SKILL_DIR, "references", "showcase.png")
51
+
52
+
53
+ def generate(out_path, palette=None, style="nature", workers=4,
54
+ overwrite=False, dry_run=False):
55
+ """Render all chart types and compose the showcase grid + manifest."""
56
+ chart_order = sorted(CHART_EXAMPLES)
57
+
58
+ if dry_run:
59
+ print("would render %d charts (%s)"
60
+ % (len(chart_order), ", ".join(chart_order)))
61
+ return 0
62
+ if out_path and os.path.exists(out_path) and not overwrite:
63
+ print("(showcase already exists, skipped — use --force to rebuild)")
64
+ print(out_path)
65
+ return 0
66
+
67
+ tmpdir = tempfile.mkdtemp(prefix="sf_showcase_")
68
+ thumbs = {}
69
+ manifest = {
70
+ "style": style,
71
+ "palette": palette,
72
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
73
+ "charts": [],
74
+ }
75
+
76
+ def _do(args):
77
+ chart_type, data_path = args
78
+ png, err, dt = render_chart(
79
+ chart_type, data_path,
80
+ os.path.join(tmpdir, "%s.png" % chart_type),
81
+ style=style, palette=palette,
82
+ width=3.2, height=2.4, dpi=120)
83
+ return chart_type, png, err, dt
84
+
85
+ with ThreadPoolExecutor(max_workers=workers) as pool:
86
+ for chart_type, out_png, err, dt in pool.map(
87
+ _do,
88
+ [(ct, example_path(ct)) for ct in chart_order]
89
+ if all(example_path(ct) for ct in chart_order)
90
+ else [(ct, example_path(ct)) for ct in chart_order
91
+ if example_path(ct)]):
92
+ if out_png:
93
+ thumbs[chart_type] = out_png
94
+ manifest["charts"].append({
95
+ "type": chart_type,
96
+ "zh": ZH_NAME.get(chart_type, chart_type),
97
+ "example": CHART_EXAMPLES[chart_type],
98
+ "output": os.path.basename(out_png),
99
+ "bytes": os.path.getsize(out_png),
100
+ "seconds": round(dt, 2),
101
+ "status": "ok",
102
+ })
103
+ else:
104
+ manifest["charts"].append({
105
+ "type": chart_type,
106
+ "zh": ZH_NAME.get(chart_type, chart_type),
107
+ "example": CHART_EXAMPLES[chart_type],
108
+ "output": None,
109
+ "bytes": 0,
110
+ "seconds": round(dt, 2),
111
+ "status": "failed",
112
+ "error": err,
113
+ })
114
+
115
+ # ── compose grid ─────────────────────────────────────────────────────
116
+ n = len(chart_order)
117
+ cols = max(1, int(math.ceil(math.sqrt(n))))
118
+ canvas = compose_grid(
119
+ thumbnails=thumbs,
120
+ chart_order=chart_order,
121
+ header_text="LIKE Sci Figures — Showcase · %s × %s"
122
+ % (style, palette or "(default)"),
123
+ subtitle_text="%d chart types · %s"
124
+ % (n, manifest["generated_at"]),
125
+ cols=cols,
126
+ cell_w=360, cell_h=280, label_h=28, gap=10, pad=16, header_h=72,
127
+ footer_text=None,
128
+ )
129
+
130
+ # ── save ─────────────────────────────────────────────────────────────
131
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
132
+ canvas.save(out_path, dpi=(160, 160))
133
+
134
+ manifest_path = os.path.splitext(out_path)[0] + ".manifest.json"
135
+ with open(manifest_path, "w", encoding="utf-8") as fh:
136
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
137
+ fh.write("\n")
138
+
139
+ ok_count = sum(1 for c in manifest["charts"] if c["status"] == "ok")
140
+ fail_count = sum(1 for c in manifest["charts"] if c["status"] == "failed")
141
+ if fail_count:
142
+ print("WARNING: %d chart(s) failed to render" % fail_count,
143
+ file=sys.stderr)
144
+ for c in manifest["charts"]:
145
+ if c["status"] == "failed":
146
+ print(" %s: %s" % (c["type"], c.get("error", "?")),
147
+ file=sys.stderr)
148
+ print("Showcase grid: %s (%d x %d px, %d bytes)"
149
+ % (out_path, canvas.width, canvas.height,
150
+ os.path.getsize(out_path)))
151
+ print("Manifest: %s" % manifest_path)
152
+ print("Charts rendered: %d ok, %d failed" % (ok_count, fail_count))
153
+ return 0 if fail_count == 0 else 1
154
+
155
+
156
+ def main():
157
+ ap = argparse.ArgumentParser(
158
+ prog="showcase.py",
159
+ description="Render every chart type with one palette + style into "
160
+ "a single grid poster, plus a manifest.json.")
161
+ ap.add_argument("--out", default=DEFAULT_OUT,
162
+ help="Grid PNG output (default: references/showcase.png)")
163
+ ap.add_argument("--palette", "-p", default=None,
164
+ help="Palette slug/alias to showcase (default: none)")
165
+ ap.add_argument("--style", "-s", default="nature",
166
+ help="Journal style (default: %(default)s)")
167
+ ap.add_argument("--workers", type=int, default=4, help="Parallel renders")
168
+ ap.add_argument("--force", action="store_true",
169
+ help="Rebuild even if the poster already exists")
170
+ ap.add_argument("--dry-run", action="store_true",
171
+ help="Print what would be rendered and exit")
172
+ args = ap.parse_args()
173
+ return generate(args.out, palette=args.palette, style=args.style,
174
+ workers=args.workers, overwrite=args.force,
175
+ dry_run=args.dry_run) or 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Style manager for like-sci-figures skill.
4
+ Integrates scienceplots library with fallback built-in styles.
5
+ """
6
+ import matplotlib.pyplot as plt
7
+ import warnings
8
+
9
+ # scienceplots style aliases map to their actual style names
10
+ SCIENCEPLOTS_STYLES = [
11
+ "ieee",
12
+ "nature",
13
+ "springer",
14
+ "aaas",
15
+ "science",
16
+ "lancet",
17
+ "acs",
18
+ "aps",
19
+ "cell",
20
+ "nejm",
21
+ "mdpi",
22
+ ]
23
+
24
+ # Built-in fallback styles that approximate scienceplots when it's not installed
25
+ # Each entry: (name, rc_params_dict)
26
+ BUILTIN_STYLES = {
27
+ "ieee": {
28
+ "figure.figsize": (6.5, 4.5),
29
+ "figure.dpi": 300,
30
+ "font.size": 8,
31
+ "font.family": "serif",
32
+ "axes.titlesize": 9,
33
+ "axes.labelsize": 8,
34
+ "axes.linewidth": 0.8,
35
+ "xtick.labelsize": 7,
36
+ "ytick.labelsize": 7,
37
+ "lines.linewidth": 1.2,
38
+ "grid.alpha": 0.3,
39
+ "grid.linestyle": "--",
40
+ },
41
+ "nature": {
42
+ "figure.figsize": (7, 5),
43
+ "figure.dpi": 300,
44
+ "font.size": 7,
45
+ "font.family": "sans-serif",
46
+ "axes.titlesize": 8,
47
+ "axes.labelsize": 7,
48
+ "axes.linewidth": 0.6,
49
+ "xtick.labelsize": 6,
50
+ "ytick.labelsize": 6,
51
+ "lines.linewidth": 1.0,
52
+ "grid.alpha": 0.2,
53
+ },
54
+ "springer": {
55
+ "figure.figsize": (7.8, 5.2),
56
+ "figure.dpi": 300,
57
+ "font.size": 8,
58
+ "font.family": "serif",
59
+ "axes.titlesize": 9,
60
+ "axes.labelsize": 8,
61
+ "axes.linewidth": 0.7,
62
+ "xtick.labelsize": 7,
63
+ "ytick.labelsize": 7,
64
+ "lines.linewidth": 1.1,
65
+ "grid.alpha": 0.3,
66
+ },
67
+ "aaas": {
68
+ "figure.figsize": (5.5, 4),
69
+ "figure.dpi": 300,
70
+ "font.size": 7,
71
+ "font.family": "sans-serif",
72
+ "axes.titlesize": 8,
73
+ "axes.labelsize": 7,
74
+ "axes.linewidth": 0.5,
75
+ "xtick.labelsize": 6,
76
+ "ytick.labelsize": 6,
77
+ "lines.linewidth": 1.0,
78
+ },
79
+ "science": {
80
+ "figure.figsize": (5.5, 4.5),
81
+ "figure.dpi": 300,
82
+ "font.size": 7,
83
+ "font.family": "sans-serif",
84
+ "axes.titlesize": 8,
85
+ "axes.labelsize": 7,
86
+ "axes.linewidth": 0.5,
87
+ "xtick.labelsize": 6,
88
+ "ytick.labelsize": 6,
89
+ "lines.linewidth": 1.0,
90
+ },
91
+ "lancet": {
92
+ "figure.figsize": (8, 5.5),
93
+ "figure.dpi": 300,
94
+ "font.size": 9,
95
+ "font.family": "serif",
96
+ "axes.titlesize": 10,
97
+ "axes.labelsize": 9,
98
+ "axes.linewidth": 0.8,
99
+ "xtick.labelsize": 8,
100
+ "ytick.labelsize": 8,
101
+ "lines.linewidth": 1.3,
102
+ },
103
+ "acs": {
104
+ "figure.figsize": (6, 4.5),
105
+ "figure.dpi": 300,
106
+ "font.size": 8,
107
+ "font.family": "sans-serif",
108
+ "axes.titlesize": 9,
109
+ "axes.labelsize": 8,
110
+ "axes.linewidth": 0.8,
111
+ "xtick.labelsize": 7,
112
+ "ytick.labelsize": 7,
113
+ "lines.linewidth": 1.2,
114
+ },
115
+ "aps": {
116
+ "figure.figsize": (6.5, 4.5),
117
+ "figure.dpi": 300,
118
+ "font.size": 8,
119
+ "font.family": "serif",
120
+ "axes.titlesize": 9,
121
+ "axes.labelsize": 8,
122
+ "axes.linewidth": 0.7,
123
+ "xtick.labelsize": 7,
124
+ "ytick.labelsize": 7,
125
+ "lines.linewidth": 1.1,
126
+ },
127
+ "cell": {
128
+ "figure.figsize": (7, 5),
129
+ "figure.dpi": 300,
130
+ "font.size": 8,
131
+ "font.family": "sans-serif",
132
+ "axes.titlesize": 9,
133
+ "axes.labelsize": 8,
134
+ "axes.linewidth": 0.8,
135
+ "xtick.labelsize": 7,
136
+ "ytick.labelsize": 7,
137
+ "lines.linewidth": 1.2,
138
+ "grid.alpha": 0.25,
139
+ },
140
+ "nejm": {
141
+ "figure.figsize": (6, 4.5),
142
+ "figure.dpi": 300,
143
+ "font.size": 8,
144
+ "font.family": "sans-serif",
145
+ "axes.titlesize": 9,
146
+ "axes.labelsize": 8,
147
+ "axes.linewidth": 0.8,
148
+ "xtick.labelsize": 7,
149
+ "ytick.labelsize": 7,
150
+ "lines.linewidth": 1.3,
151
+ "grid.alpha": 0.3,
152
+ },
153
+ "mdpi": {
154
+ "figure.figsize": (7.5, 5),
155
+ "figure.dpi": 300,
156
+ "font.size": 9,
157
+ "font.family": "sans-serif",
158
+ "axes.titlesize": 10,
159
+ "axes.labelsize": 9,
160
+ "axes.linewidth": 0.8,
161
+ "xtick.labelsize": 8,
162
+ "ytick.labelsize": 8,
163
+ "lines.linewidth": 1.3,
164
+ "grid.alpha": 0.3,
165
+ },
166
+ }
167
+
168
+ # Color palettes per journal style
169
+ COLOR_PALETTES = {
170
+ "ieee": ["#0072BD", "#D95319", "#EDB120", "#7E2F8E", "#77AC30", "#4DBEEE", "#A2142F"],
171
+ "nature": ["#E64B35", "#4DBBD5", "#00A087", "#3C5488", "#F39B7F", "#8491B4", "#91D1C2", "#DC0000"],
172
+ "springer": ["#00468B", "#ED0000", "#42B540", "#0099B4", "#925E9F", "#FDAF91", "#AD002A", "#1B1919"],
173
+ "aaas": ["#3B4992", "#EE0000", "#008B45", "#631879", "#008280", "#BB0021", "#5F559B", "#A20056"],
174
+ "science": ["#3B4992", "#EE0000", "#008B45", "#631879", "#008280", "#BB0021"],
175
+ "lancet": ["#00468B", "#ED0000", "#42B540", "#0099B4", "#525252", "#7F6F00", "#ED7D31"],
176
+ "acs": ["#E37222", "#07889B", "#66B9BF", "#EEAA7B", "#155265", "#E3A41B", "#7F7F7F"],
177
+ "aps": ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#F0E442", "#56B4E9"],
178
+ "cell": ["#3B4992", "#EE0000", "#008B45", "#631879", "#008280", "#BB0021", "#5F559B"],
179
+ "nejm": ["#1F77B4", "#D62728", "#2CA02C", "#9467BD", "#FF7F0E", "#17BECF", "#BCBD22"],
180
+ "mdpi": ["#E37222", "#07889B", "#66B9BF", "#EEAA7B", "#155265", "#E3A41B", "#7F7F7F"],
181
+ }
182
+
183
+ DEFAULT_STYLE = "nature"
184
+ SCIENCEPLOTS_AVAILABLE = False
185
+ SCIENCEPLOTS_UNREACHABLE = False
186
+
187
+
188
+ def _check_scienceplots():
189
+ """Check if scienceplots is available and can be imported."""
190
+ global SCIENCEPLOTS_AVAILABLE, SCIENCEPLOTS_UNREACHABLE
191
+ if SCIENCEPLOTS_UNREACHABLE:
192
+ return False
193
+ try:
194
+ import scienceplots
195
+ SCIENCEPLOTS_AVAILABLE = True
196
+ return True
197
+ except ImportError:
198
+ SCIENCEPLOTS_AVAILABLE = False
199
+ SCIENCEPLOTS_UNREACHABLE = True
200
+ return False
201
+
202
+
203
+ def setup_style(style_name=DEFAULT_STYLE, custom_rc=None):
204
+ """
205
+ Set up the matplotlib style for the given journal.
206
+
207
+ Args:
208
+ style_name: One of the SCIENCEPLOTS_STYLES
209
+ custom_rc: Optional dict of rcParams overrides
210
+
211
+ Returns:
212
+ dict with color palette and style metadata
213
+ """
214
+ style_name = style_name.lower().strip()
215
+ if style_name not in SCIENCEPLOTS_STYLES and style_name not in BUILTIN_STYLES:
216
+ print(f"Warning: Unknown style '{style_name}', falling back to 'nature'")
217
+ style_name = "nature"
218
+
219
+ # scienceplots only provides 3 secondary styles: ieee, nature, science
220
+ _SCIENCEPLOTS_REAL_STYLES = {"ieee", "nature", "science"}
221
+
222
+ if _check_scienceplots():
223
+ with warnings.catch_warnings():
224
+ warnings.simplefilter("ignore")
225
+ if style_name in _SCIENCEPLOTS_REAL_STYLES:
226
+ plt.style.use(["science", style_name])
227
+ else:
228
+ # Use "science" base + our built-in rc params for unsupported styles
229
+ plt.style.use("science")
230
+ rc = BUILTIN_STYLES.get(style_name, BUILTIN_STYLES["nature"])
231
+ plt.rcParams.update(rc)
232
+ else:
233
+ rc = BUILTIN_STYLES.get(style_name, BUILTIN_STYLES["nature"])
234
+ plt.rcParams.update(rc)
235
+
236
+ # Always apply a few quality defaults
237
+ # Disable LaTeX (usetex) to avoid LaTeX dependency issues;
238
+ # matplotlib's built-in mathtext handles math rendering fine.
239
+ # Also provide sans-serif fallbacks for serif fonts (Windows lacks Times).
240
+ plt.rcParams.update({
241
+ "text.usetex": False,
242
+ "font.serif": ["DejaVu Serif", "Times New Roman", "Times", "serif"],
243
+ "savefig.dpi": 300,
244
+ "savefig.bbox": "tight",
245
+ "savefig.pad_inches": 0.05,
246
+ "axes.unicode_minus": False,
247
+ })
248
+
249
+ # Apply any user overrides
250
+ if custom_rc:
251
+ plt.rcParams.update(custom_rc)
252
+
253
+ return {
254
+ "colors": COLOR_PALETTES.get(style_name, COLOR_PALETTES["nature"]),
255
+ "name": style_name,
256
+ "scienceplots_loaded": SCIENCEPLOTS_AVAILABLE,
257
+ }
258
+
259
+
260
+ def list_styles():
261
+ """Return list of available style names."""
262
+ return SCIENCEPLOTS_STYLES[:]
263
+
264
+
265
+ def reset_style():
266
+ """Reset matplotlib rcParams to defaults."""
267
+ plt.rcdefaults()