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,322 @@
1
+ #!/usr/bin/env python3
2
+ """Generate product preview assets: samples/ and showcases/.
3
+
4
+ samples/
5
+ 36 standalone PNG files — one per chart type, rendered with
6
+ palette=wx01_blue_red and style=nature. These are the per-chart
7
+ sample images used in the product page / docs.
8
+
9
+ showcases/
10
+ 34 full-grid PNG files — one per palette, each showing all 36
11
+ chart types rendered with that palette. These are the per-palette
12
+ showcase images used in the palette gallery.
13
+
14
+ Usage
15
+ -----
16
+ generate_samples.py # generate both
17
+ generate_samples.py --samples-only # only samples/
18
+ generate_samples.py --showcases-only # only showcases/
19
+ generate_samples.py --force # overwrite existing
20
+ generate_samples.py --style ieee # different style
21
+ generate_samples.py --dry-run # print plan, no renders
22
+
23
+ Notes
24
+ -----
25
+ * Reuses render_utils.render_chart() and render_utils.compose_grid()
26
+ so there is zero duplicated rendering code.
27
+ * ThreadPoolExecutor for parallel renders (default 4 workers).
28
+ * Requires Pillow.
29
+ """
30
+ import argparse
31
+ import json
32
+ import math
33
+ import os
34
+ import re
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from concurrent.futures import ThreadPoolExecutor
39
+
40
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
41
+ sys.path.insert(0, SCRIPT_DIR)
42
+
43
+ from registry import CHART_EXAMPLES, CHART_TYPES, example_path, ZH_NAME
44
+ from render_utils import render_chart, compose_grid, load_fonts
45
+ from palettes import list_palettes, palette_info
46
+
47
+ SKILL_DIR = os.path.dirname(SCRIPT_DIR)
48
+ SAMPLES_DIR = os.path.join(SKILL_DIR, "samples")
49
+ SHOWCASES_DIR = os.path.join(SKILL_DIR, "showcases")
50
+
51
+ # Sample images: higher resolution for standalone viewing
52
+ SAMPLE_W = 6.0
53
+ SAMPLE_H = 4.0
54
+ SAMPLE_DPI = 200
55
+
56
+ # Showcase thumbnails: compact grid
57
+ THUMB_W = 3.2
58
+ THUMB_H = 2.4
59
+ THUMB_DPI = 120
60
+
61
+
62
+ # ── samples/ generation ────────────────────────────────────────────────────
63
+
64
+
65
+ def _render_sample(chart_type, style, palette, out_dir):
66
+ """Render one chart type as a standalone sample PNG."""
67
+ data = example_path(chart_type)
68
+ if not data:
69
+ return chart_type, None, "no example file"
70
+ out = os.path.join(out_dir, "%s.png" % chart_type)
71
+ png, err, dt = render_chart(
72
+ chart_type, data, out,
73
+ style=style, palette=palette,
74
+ width=SAMPLE_W, height=SAMPLE_H, dpi=SAMPLE_DPI,
75
+ )
76
+ return chart_type, png, err, dt
77
+
78
+
79
+ def generate_samples(style="nature", palette="wx01_blue_red", workers=4,
80
+ force=False, dry_run=False):
81
+ """Render all 36 chart types as individual sample PNGs."""
82
+ chart_order = sorted(CHART_EXAMPLES)
83
+ n = len(chart_order)
84
+
85
+ if dry_run:
86
+ print("[samples] would render %d charts -> samples/" % n)
87
+ for ct in chart_order:
88
+ print(" %-20s -> samples/%s.png" % (ct, ct))
89
+ return 0
90
+
91
+ os.makedirs(SAMPLES_DIR, exist_ok=True)
92
+
93
+ results = {}
94
+ errors = []
95
+ t0 = time.time()
96
+
97
+ with ThreadPoolExecutor(max_workers=workers) as pool:
98
+ for ct, png, err, dt in pool.map(
99
+ lambda ct: _render_sample(ct, style, palette, SAMPLES_DIR),
100
+ chart_order
101
+ ):
102
+ if png:
103
+ results[ct] = png
104
+ else:
105
+ errors.append("%s: %s" % (ct, err))
106
+
107
+ elapsed = time.time() - t0
108
+ ok = len(results)
109
+
110
+ # Write manifest
111
+ manifest = {
112
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
113
+ "style": style,
114
+ "palette": palette,
115
+ "width_inch": SAMPLE_W,
116
+ "height_inch": SAMPLE_H,
117
+ "dpi": SAMPLE_DPI,
118
+ "charts": [],
119
+ }
120
+ for ct in chart_order:
121
+ path = os.path.join(SAMPLES_DIR, "%s.png" % ct)
122
+ entry = {
123
+ "type": ct,
124
+ "zh": ZH_NAME.get(ct, ct),
125
+ "file": "%s.png" % ct,
126
+ "status": "ok" if ct in results else "failed",
127
+ }
128
+ if os.path.exists(path):
129
+ entry["bytes"] = os.path.getsize(path)
130
+ manifest["charts"].append(entry)
131
+
132
+ manifest_path = os.path.join(SAMPLES_DIR, "manifest.json")
133
+ with open(manifest_path, "w", encoding="utf-8") as fh:
134
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
135
+ fh.write("\n")
136
+
137
+ print("Samples: %d ok, %d failed (%.1fs) -> %s"
138
+ % (ok, len(errors), elapsed, SAMPLES_DIR))
139
+ if errors:
140
+ for e in errors:
141
+ print(" FAIL %s" % e, file=sys.stderr)
142
+
143
+ return 0 if not errors else 1
144
+
145
+
146
+ # ── showcases/ generation ──────────────────────────────────────────────────
147
+
148
+
149
+ def _render_palette_showcase(palette_slug, style, workers=4,
150
+ force=False, dry_run=False):
151
+ """Render one palette's full 36-chart grid."""
152
+ info = palette_info(palette_slug)
153
+ if not info:
154
+ return palette_slug, None, "unknown palette"
155
+
156
+ out_path = os.path.join(SHOWCASES_DIR, "%s.png" % palette_slug)
157
+ if os.path.exists(out_path) and not force:
158
+ print(" %-20s (exists, skipped)" % palette_slug)
159
+ return palette_slug, out_path, None
160
+
161
+ if dry_run:
162
+ print(" %-20s would render" % palette_slug)
163
+ return palette_slug, None, None
164
+
165
+ tmpdir = tempfile.mkdtemp(prefix="sf_show_%s_" % palette_slug)
166
+ chart_order = sorted(CHART_EXAMPLES)
167
+ thumbs = {}
168
+
169
+ def _do(ct):
170
+ data = example_path(ct)
171
+ png, err, _ = render_chart(
172
+ ct, data,
173
+ os.path.join(tmpdir, "%s.png" % ct),
174
+ style=style, palette=palette_slug,
175
+ width=THUMB_W, height=THUMB_H, dpi=THUMB_DPI,
176
+ )
177
+ return ct, png, err
178
+
179
+ with ThreadPoolExecutor(max_workers=workers) as pool:
180
+ for ct, png, err in pool.map(_do, chart_order):
181
+ if png:
182
+ thumbs[ct] = png
183
+
184
+ # Compose grid
185
+ cols = max(1, int(math.ceil(math.sqrt(len(chart_order)))))
186
+ n_colors = len(info.get("colors", []))
187
+ domain_str = "/".join(info.get("domain", ["跨学科通用"]))
188
+ # 去掉 zh 里的序号前缀("13 Nature 旗舰…" → "Nature 旗舰…"),
189
+ # 避免与标题末尾的配色代码 wx13_… 重复
190
+ zh_name = re.sub(r"^\s*\d+\s*", "", info.get("zh", palette_slug))
191
+ canvas = compose_grid(
192
+ thumbnails=thumbs,
193
+ chart_order=chart_order,
194
+ # 标题行: 中文标题(配色中文名) + 配色代码(slug)
195
+ header_text="%s · %s" % (zh_name, palette_slug),
196
+ # 副标题: 适用领域 + 色数
197
+ subtitle_text="适用领域:%s · %d 色"
198
+ % (domain_str, n_colors),
199
+ cols=cols,
200
+ cell_w=360, cell_h=280, label_h=28, gap=10, pad=16, header_h=72,
201
+ footer_text=None,
202
+ )
203
+ canvas.save(out_path, dpi=(160, 160))
204
+ return palette_slug, out_path, None
205
+
206
+
207
+ def generate_showcases(style="nature", workers=4, force=False, dry_run=False):
208
+ """Render all 34 palettes as full 36-chart grids."""
209
+ palettes = list_palettes()
210
+ n = len(palettes)
211
+
212
+ if dry_run:
213
+ print("[showcases] would render %d palettes -> showcases/" % n)
214
+ for p in palettes:
215
+ print(" %-20s -> showcases/%s.png" % (p, p))
216
+ return 0
217
+
218
+ os.makedirs(SHOWCASES_DIR, exist_ok=True)
219
+
220
+ results = {}
221
+ errors = []
222
+ t0 = time.time()
223
+
224
+ for slug in palettes:
225
+ print(" Rendering palette: %s ..." % slug)
226
+ slug_r, png, err = _render_palette_showcase(
227
+ slug, style, workers=workers, force=force, dry_run=False
228
+ )
229
+ if png:
230
+ results[slug] = png
231
+ elif err:
232
+ errors.append("%s: %s" % (slug, err))
233
+
234
+ elapsed = time.time() - t0
235
+
236
+ # Write manifest
237
+ manifest = {
238
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
239
+ "style": style,
240
+ "palettes": [],
241
+ }
242
+ for slug in palettes:
243
+ info = palette_info(slug)
244
+ path = os.path.join(SHOWCASES_DIR, "%s.png" % slug)
245
+ entry = {
246
+ "palette": slug,
247
+ "zh": info.get("zh", "") if info else "",
248
+ "kind": info.get("kind", "") if info else "",
249
+ "colors": info.get("colors", []) if info else [],
250
+ "file": "%s.png" % slug,
251
+ "status": "ok" if slug in results else "failed",
252
+ }
253
+ if os.path.exists(path):
254
+ entry["bytes"] = os.path.getsize(path)
255
+ manifest["palettes"].append(entry)
256
+
257
+ manifest_path = os.path.join(SHOWCASES_DIR, "manifest.json")
258
+ with open(manifest_path, "w", encoding="utf-8") as fh:
259
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
260
+ fh.write("\n")
261
+
262
+ print("Showcases: %d ok, %d failed (%.1fs) -> %s"
263
+ % (len(results), len(errors), elapsed, SHOWCASES_DIR))
264
+ if errors:
265
+ for e in errors:
266
+ print(" FAIL %s" % e, file=sys.stderr)
267
+
268
+ return 0 if not errors else 1
269
+
270
+
271
+ # ── CLI ──────────────────────────────────────────────────────────────────────
272
+
273
+
274
+ def main():
275
+ ap = argparse.ArgumentParser(
276
+ prog="generate_samples.py",
277
+ description="Generate samples/ (per-chart PNGs) and showcases/ "
278
+ "(per-palette grids) for the LIKE Sci Figures product page.",
279
+ )
280
+ ap.add_argument("--style", default="nature",
281
+ help="Journal style (default: %(default)s)")
282
+ ap.add_argument("--palette", default="wx01_blue_red",
283
+ help="Palette for samples/ (default: %(default)s)")
284
+ ap.add_argument("--workers", type=int, default=4,
285
+ help="Parallel render workers (default: %(default)s)")
286
+ ap.add_argument("--force", action="store_true",
287
+ help="Overwrite existing files")
288
+ ap.add_argument("--samples-only", action="store_true",
289
+ help="Only generate samples/")
290
+ ap.add_argument("--showcases-only", action="store_true",
291
+ help="Only generate showcases/")
292
+ ap.add_argument("--dry-run", action="store_true",
293
+ help="Print plan without rendering")
294
+ args = ap.parse_args()
295
+
296
+ rc = 0
297
+ do_samples = not args.showcases_only
298
+ do_showcases = not args.samples_only
299
+
300
+ if do_samples:
301
+ print("=" * 60)
302
+ print("GENERATING SAMPLES/")
303
+ print("=" * 60)
304
+ rc |= generate_samples(
305
+ style=args.style, palette=args.palette,
306
+ workers=args.workers, force=args.force, dry_run=args.dry_run,
307
+ )
308
+
309
+ if do_showcases:
310
+ print("=" * 60)
311
+ print("GENERATING SHOWCASES/")
312
+ print("=" * 60)
313
+ rc |= generate_showcases(
314
+ style=args.style, workers=args.workers,
315
+ force=args.force, dry_run=args.dry_run,
316
+ )
317
+
318
+ return rc
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """Build the colour-palette comparison poster shown at first skill invocation.
3
+
4
+ Why this exists
5
+ ---------------
6
+ This skill ships 34 curated palettes, but the *user* should decide which one
7
+ to use (the workflow in SKILL.md asks for it the first time the skill is used
8
+ in a task). Judging many palettes from a text table is unreliable, so this
9
+ script renders each palette on the same handful of representative charts and
10
+ stitches the results into one poster:
11
+
12
+ references/palette-picker.png
13
+
14
+ Rows = the palettes (name, kind, colour count, full swatch strip, domain)
15
+ Columns = representative chart types, all rendered from the same examples/ data
16
+
17
+ Every thumbnail is produced by calling gen_figure.py for real, so what the
18
+ user sees in the poster is byte-for-byte what the skill will produce in the
19
+ task.
20
+
21
+ Usage
22
+ -----
23
+ palette_picker.py # write references/palette-picker.png
24
+ palette_picker.py --out /tmp/pk.png # write somewhere else
25
+ palette_picker.py --charts bar,scatter,heatmap
26
+ palette_picker.py --style ieee --force
27
+ palette_picker.py --dry-run # render thumbnails, no poster
28
+
29
+ Notes
30
+ -----
31
+ * The poster is a build artefact: it is skipped when the target already exists
32
+ (regenerate with --force), so an agent can present it immediately without
33
+ waiting for ~50 figure renders.
34
+ * Requires Pillow for the compositing step. Thumbnails themselves need only
35
+ matplotlib (via gen_figure.py).
36
+ * Chart-type → example mapping lives in registry.py; shared rendering helpers
37
+ live in render_utils.py. This module only owns the picker-specific layout.
38
+ """
39
+ import argparse
40
+ import os
41
+ import sys
42
+ import tempfile
43
+ from concurrent.futures import ThreadPoolExecutor
44
+
45
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
46
+ sys.path.insert(0, SCRIPT_DIR)
47
+
48
+ from registry import example_path, ZH_NAME
49
+ from render_utils import render_chart, load_fonts
50
+
51
+ SKILL_DIR = os.path.dirname(SCRIPT_DIR)
52
+ DEFAULT_OUT = os.path.join(SKILL_DIR, "references", "palette-picker.png")
53
+
54
+ # Representative charts: categorical, trend, grouped points, proportion, and a
55
+ # continuous colour band (the heatmap shows how a palette reads as a colormap).
56
+ DEFAULT_CHARTS = ["bar", "line", "scatter", "pie", "heatmap"]
57
+
58
+ KIND_ZH = {"qualitative": "定性", "sequential": "顺序", "diverging": "发散"}
59
+ KIND_RGB = {"qualitative": (0, 102, 204),
60
+ "sequential": (46, 125, 50),
61
+ "diverging": (183, 28, 28)}
62
+
63
+ # ── poster geometry ────────────────────────────────────────────────────────
64
+ HEADER_H = 106
65
+ COL_HEAD_H = 44
66
+ PAD = 16
67
+ LABEL_W = 420
68
+ CELL_W = 300
69
+ CELL_H = 210
70
+ GAP = 14
71
+ SWATCH_CELL = 46
72
+
73
+
74
+ def _render_thumbnail(chart, style, palette, tmpdir):
75
+ """Render one (chart, palette) thumbnail via render_utils."""
76
+ out = os.path.join(tmpdir, "%s_%s.png" % (palette, chart))
77
+ data = example_path(chart)
78
+ if not data:
79
+ return palette, chart, None, "no example file for %s" % chart
80
+ png, err, _ = render_chart(chart, data, out, style=style, palette=palette,
81
+ width=4.0, height=2.8, dpi=120)
82
+ return palette, chart, png, err
83
+
84
+
85
+ def generate(out_path, charts=DEFAULT_CHARTS, style="nature", workers=4,
86
+ overwrite=False, dry_run=False):
87
+ """Render every (palette x chart) thumbnail and compose the poster."""
88
+ from palettes import list_palettes, palette_info
89
+ from PIL import Image, ImageDraw, ImageFont
90
+
91
+ palettes = [s for s in list_palettes() if palette_info(s)]
92
+
93
+ if dry_run:
94
+ print("would render %d thumbnails (%d palettes x %d charts)"
95
+ % (len(palettes) * len(charts), len(palettes), len(charts)))
96
+ return 0
97
+ if out_path and os.path.exists(out_path) and not overwrite:
98
+ print("(poster already exists, skipped — use --force to rebuild)")
99
+ print(out_path)
100
+ return 0
101
+
102
+ tmpdir = tempfile.mkdtemp(prefix="sf_picker_")
103
+ todo = [(slug, chart) for slug in palettes for chart in charts]
104
+ thumbs = {}
105
+ errors = []
106
+ with ThreadPoolExecutor(max_workers=workers) as pool:
107
+ for slug, chart, png, err in pool.map(
108
+ lambda p: _render_thumbnail(p[1], style, p[0], tmpdir),
109
+ todo):
110
+ if png:
111
+ thumbs[(slug, chart)] = png
112
+ else:
113
+ errors.append("%s/%s: %s" % (slug, chart, err))
114
+
115
+ # ── geometry ─────────────────────────────────────────────────────────
116
+ width = LABEL_W + len(charts) * CELL_W + (len(charts) + 1) * GAP + PAD
117
+ row_h = CELL_H + GAP
118
+ height = PAD + HEADER_H + COL_HEAD_H + len(palettes) * row_h + PAD
119
+ canvas = Image.new("RGB", (width, height), "white")
120
+ d = ImageDraw.Draw(canvas)
121
+ fonts, font_used = load_fonts((30, 26, 20, 17, 14))
122
+ f30 = fonts.get(30) or ImageFont.load_default()
123
+ f20 = fonts.get(20) or ImageFont.load_default()
124
+ f17 = fonts.get(17) or ImageFont.load_default()
125
+ f14 = fonts.get(14) or ImageFont.load_default()
126
+
127
+ # ── title ────────────────────────────────────────────────────────────
128
+ d.rectangle([0, 0, width, HEADER_H], fill="#0B6E4F")
129
+ d.text((PAD + 2, 12), "LIKE Sci Figures — 配色方案选择",
130
+ fill="white", font=f30)
131
+ d.text((PAD + 2, 54),
132
+ "%d 套精选科研配色 × %d 类代表图表 · 风格: %s · 适合多领域学术出版"
133
+ % (len(palettes), len(charts), style),
134
+ fill="#D7E1EB", font=f20)
135
+
136
+ # LIKE watermark on the right side of header
137
+ tag = "LIKE"
138
+ bbox = d.textbbox((0, 0), tag, font=f20)
139
+ tag_w = bbox[2] - bbox[0]
140
+ d.text((width - PAD - tag_w - 2, 16), tag,
141
+ fill="#D7E1EB", font=f20)
142
+
143
+ # ── column headers ───────────────────────────────────────────────────
144
+ y0 = PAD + HEADER_H
145
+ for i, chart in enumerate(charts):
146
+ x = LABEL_W + i * (CELL_W + GAP) + GAP
147
+ d.text((x + 8, y0 + 11), ZH_NAME.get(chart, chart),
148
+ fill="#111111", font=f20)
149
+ hy = y0 + COL_HEAD_H
150
+
151
+ # ── one row per palette ──────────────────────────────────────────────
152
+ for r, slug in enumerate(palettes):
153
+ info = palette_info(slug)
154
+ colors = list(info.get("colors") or [])
155
+ y = hy + r * row_h
156
+ d.rectangle([PAD, y, PAD + width - 2 * PAD, y + CELL_H],
157
+ fill="#FAFBFC", outline="#E4E7EB")
158
+
159
+ lx = PAD + GAP
160
+ d.text((lx, y + 10), slug, fill="#111111", font=f17)
161
+ kind = info.get("kind") or ""
162
+ badge_w = 62
163
+ d.rounded_rectangle([lx, y + 36, lx + badge_w, y + 36 + 24],
164
+ radius=4, fill=KIND_RGB.get(kind, (90, 90, 90)))
165
+ d.text((lx + 8, y + 40), KIND_ZH.get(kind, kind),
166
+ fill="white", font=f14)
167
+ d.text((lx + badge_w + 10, y + 40), "%d 色" % len(colors),
168
+ fill="#555555", font=f14)
169
+
170
+ # Domain tags
171
+ domain_list = info.get("domain") or []
172
+ domain_text = "适用:" + "/".join(domain_list[:3])
173
+ d.text((lx, y + 66), domain_text[:40],
174
+ fill="#777777", font=f14)
175
+
176
+ for i, hexc in enumerate(colors):
177
+ cx = lx + (i % SWATCH_CELL) * (CELL_W / float(SWATCH_CELL))
178
+ cy = y + 90 + (i // SWATCH_CELL) * 30
179
+ d.rectangle([cx, cy, cx + CELL_W / float(SWATCH_CELL), cy + 26],
180
+ fill=hexc)
181
+ if CELL_W / float(SWATCH_CELL) >= 34:
182
+ d.text((cx + 4, cy + 5), hexc, fill="#000000", font=f14)
183
+
184
+ d.text((lx, y + 148), (info.get("zh") or "")[:18],
185
+ fill="#444444", font=f14)
186
+ d.text((lx, y + 172), (info.get("desc") or "")[:28],
187
+ fill="#777777", font=f14)
188
+
189
+ for i, chart in enumerate(charts):
190
+ px = LABEL_W + i * (CELL_W + GAP) + GAP
191
+ png = thumbs.get((slug, chart))
192
+ if png and os.path.exists(png):
193
+ im = Image.open(png).convert("RGB")
194
+ if im.width != CELL_W or im.height != CELL_H:
195
+ im = im.resize((CELL_W, CELL_H), Image.LANCZOS)
196
+ d.rectangle([px, y, px + CELL_W, y + CELL_H],
197
+ fill="white", outline="#DDDDDD")
198
+ canvas.paste(im, (px, y))
199
+ else:
200
+ d.rectangle([px, y, px + CELL_W, y + CELL_H],
201
+ fill="#F1F3F5", outline="#DDDDDD")
202
+ d.text((px + 10, y + 95), "渲染失败", fill="#999999",
203
+ font=f14)
204
+
205
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
206
+ canvas.save(out_path, dpi=(160, 160))
207
+ if errors:
208
+ print("WARNING: %d thumbnail(s) failed to render:" % len(errors),
209
+ file=sys.stderr)
210
+ for e in errors[:10]:
211
+ print(" " + e, file=sys.stderr)
212
+ print("Palette picker poster: %s (%d x %d px, %d bytes, font=%s)"
213
+ % (out_path, width, height, os.path.getsize(out_path),
214
+ font_used or "default"))
215
+ print("Charts shown: " + ", ".join(ZH_NAME.get(c, c) for c in charts))
216
+ return 0 if not errors else 1
217
+
218
+
219
+ def main():
220
+ ap = argparse.ArgumentParser(
221
+ prog="palette_picker.py",
222
+ description="Build the palette comparison poster shown to the user "
223
+ "when the skill is first used in a task.")
224
+ ap.add_argument("--out", default=DEFAULT_OUT,
225
+ help="Poster output path (default: references/palette-picker.png)")
226
+ ap.add_argument("--charts", default=",".join(DEFAULT_CHARTS),
227
+ help="Comma separated representative chart types "
228
+ "(default: %(default)s)")
229
+ ap.add_argument("--style", default="nature",
230
+ help="Journal style used for every thumbnail (default: %(default)s)")
231
+ ap.add_argument("--workers", type=int, default=4, help="Parallel renders")
232
+ ap.add_argument("--force", action="store_true",
233
+ help="Rebuild even if the poster already exists")
234
+ ap.add_argument("--dry-run", action="store_true",
235
+ help="Print what would be rendered and exit")
236
+ args = ap.parse_args()
237
+ charts = [c.strip() for c in args.charts.split(",") if c.strip()]
238
+ return generate(args.out, charts=charts, style=args.style,
239
+ workers=args.workers, overwrite=args.force,
240
+ dry_run=args.dry_run) or 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())