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.
- package/LICENSE +56 -0
- package/README.md +93 -2
- package/cli.js +79 -0
- package/examples/area.json +9 -0
- package/examples/bar.json +15 -0
- package/examples/bland_altman.json +128 -0
- package/examples/bode.json +8 -0
- package/examples/box.json +8 -0
- package/examples/bubble.json +7 -0
- package/examples/contour.json +10 -0
- package/examples/corr_matrix.json +99 -0
- package/examples/dendrogram.json +5 -0
- package/examples/density.json +8 -0
- package/examples/dot_plot.json +8 -0
- package/examples/ecdf.json +377 -0
- package/examples/errorbar.json +7 -0
- package/examples/forest.json +12 -0
- package/examples/heatmap.json +10 -0
- package/examples/hexbin.json +6009 -0
- package/examples/histogram.json +6 -0
- package/examples/kaplan_meier.json +6 -0
- package/examples/line.json +11 -0
- package/examples/manhattan.json +7 -0
- package/examples/panels.json +28 -0
- package/examples/pca.json +12 -0
- package/examples/periodogram.json +7 -0
- package/examples/pie.json +4 -0
- package/examples/radar.json +7 -0
- package/examples/ridge.json +11 -0
- package/examples/roc.json +6 -0
- package/examples/sankey.json +9 -0
- package/examples/scatter3d.json +6 -0
- package/examples/slope.json +6 -0
- package/examples/stacked_bar.json +12 -0
- package/examples/surface.json +11 -0
- package/examples/taylor.json +9 -0
- package/examples/ternary.json +18 -0
- package/examples/venn.json +12 -0
- package/examples/violin.json +9 -0
- package/examples/volcano.json +7 -0
- package/examples/waterfall.json +5 -0
- package/examples/windrose.json +407 -0
- package/package.json +33 -4
- package/requirements.txt +9 -0
- package/scripts/add_chart.py +201 -0
- package/scripts/build_gallery.py +360 -0
- package/scripts/chart_plugin.py +153 -0
- package/scripts/data_loader.py +260 -0
- package/scripts/fig_3d.py +103 -0
- package/scripts/fig_area.py +57 -0
- package/scripts/fig_bar.py +117 -0
- package/scripts/fig_biomed.py +289 -0
- package/scripts/fig_box.py +60 -0
- package/scripts/fig_bubble.py +65 -0
- package/scripts/fig_core.py +195 -0
- package/scripts/fig_datasci.py +276 -0
- package/scripts/fig_distribution.py +59 -0
- package/scripts/fig_engineering.py +224 -0
- package/scripts/fig_environ.py +239 -0
- package/scripts/fig_errorbar.py +49 -0
- package/scripts/fig_forest.py +54 -0
- package/scripts/fig_heatmap.py +51 -0
- package/scripts/fig_line.py +39 -0
- package/scripts/fig_pca.py +101 -0
- package/scripts/fig_pie.py +52 -0
- package/scripts/fig_radar.py +32 -0
- package/scripts/fig_sankey.py +196 -0
- package/scripts/fig_scatter.py +44 -0
- package/scripts/fig_subplots.py +124 -0
- package/scripts/fig_taylor.py +80 -0
- package/scripts/fig_venn.py +80 -0
- package/scripts/fig_windrose.py +82 -0
- package/scripts/font_detector.py +85 -0
- package/scripts/gen_figure.py +583 -0
- package/scripts/generate_samples.py +322 -0
- package/scripts/palette_picker.py +244 -0
- package/scripts/palette_pref.py +179 -0
- package/scripts/palette_preview.py +334 -0
- package/scripts/palettes.py +461 -0
- package/scripts/registry.py +138 -0
- package/scripts/render_utils.py +233 -0
- package/scripts/showcase.py +179 -0
- package/scripts/style_manager.py +267 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task-scoped colour-palette preference store for like-sci-figures.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
This skill ships 10 curated palettes. The workflow requires the *user* to pick
|
|
7
|
+
one the first time the skill is used in a task, and to keep using that same
|
|
8
|
+
palette for the rest of the task. An LLM agent may lose conversation context
|
|
9
|
+
(compaction, context switching, multi-turn gaps), so the choice is persisted
|
|
10
|
+
to disk next to the working directory — one record per task / project.
|
|
11
|
+
|
|
12
|
+
State file
|
|
13
|
+
----------
|
|
14
|
+
<cwd>/.workbuddy/like-sci-figures/palette.json (override: --state)
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"palette": "wx06_amber_navy", # slug, or the literal string "auto"
|
|
18
|
+
"chosen_at": "2026-10-16 18:20:00",
|
|
19
|
+
"cwd": "C:\\path\\to\\project"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Values
|
|
23
|
+
------
|
|
24
|
+
<slug> a real palette, e.g. wx06_amber_navy / p6 / 6 / hex list
|
|
25
|
+
auto the user explicitly declined to pick one -> do NOT pass
|
|
26
|
+
--palette, let the journal style supply its own colours
|
|
27
|
+
|
|
28
|
+
Exit codes (read them — do not just check stdout)
|
|
29
|
+
-------------------------------------------------
|
|
30
|
+
get 0 = a choice is recorded (stdout = palette value)
|
|
31
|
+
2 = no choice yet for this task -> the agent must ask the user
|
|
32
|
+
set 0 = saved, 1 = missing arg, 3 = unknown palette name
|
|
33
|
+
clear 0 = removed (or nothing to clear)
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
42
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
43
|
+
|
|
44
|
+
# Where the record lives, relative to the working directory.
|
|
45
|
+
STATE_REL = os.path.join(".workbuddy", "like-sci-figures", "palette.json")
|
|
46
|
+
# Sentinel meaning "user opted out of choosing; use the style's own colours".
|
|
47
|
+
AUTO = "auto"
|
|
48
|
+
# What the agent should append to every gen_figure.py call once a choice exists.
|
|
49
|
+
FLAG = "--palette"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def state_path(override=None):
|
|
53
|
+
"""Absolute path of the state file for the current task."""
|
|
54
|
+
return os.path.abspath(override or os.path.join(os.getcwd(), STATE_REL))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read(path):
|
|
58
|
+
try:
|
|
59
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
60
|
+
rec = json.load(fh)
|
|
61
|
+
if isinstance(rec, dict):
|
|
62
|
+
return rec
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve(name):
|
|
69
|
+
"""Validate a palette name and return its canonical slug, or None."""
|
|
70
|
+
if str(name).strip().lower() == AUTO:
|
|
71
|
+
return AUTO
|
|
72
|
+
try:
|
|
73
|
+
from palettes import get_palette, palette_info
|
|
74
|
+
except Exception:
|
|
75
|
+
return str(name).strip()
|
|
76
|
+
info = palette_info(name)
|
|
77
|
+
if info:
|
|
78
|
+
return info["slug"]
|
|
79
|
+
# Not a known slug/alias — allow an explicit hex list through.
|
|
80
|
+
try:
|
|
81
|
+
get_palette(name)
|
|
82
|
+
return str(name).strip()
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_get(args):
|
|
88
|
+
path = state_path(args.state)
|
|
89
|
+
rec = _read(path)
|
|
90
|
+
value = (rec or {}).get("palette")
|
|
91
|
+
if not value:
|
|
92
|
+
print("(no palette chosen yet for this task)", file=sys.stderr)
|
|
93
|
+
print("(ask the user to choose, then run: palette_pref.py set --palette <slug>)",
|
|
94
|
+
file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
print(value)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_set(args):
|
|
101
|
+
raw = (args.palette or "").strip()
|
|
102
|
+
if not raw:
|
|
103
|
+
print("ERROR: --palette is required (a slug like wx06_amber_navy, an alias "
|
|
104
|
+
"like p6, a hex list, or 'auto' to opt out)", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
slug = _resolve(raw)
|
|
107
|
+
if slug is None:
|
|
108
|
+
print(f"ERROR: unknown palette '{raw}'.", file=sys.stderr)
|
|
109
|
+
print(" Available: wx01_blue_red ... wx10_vivid_contrast "
|
|
110
|
+
"(aliases p1-p10 / 1-10 / wx1-wx10), or 'auto'.", file=sys.stderr)
|
|
111
|
+
return 3
|
|
112
|
+
path = state_path(args.state)
|
|
113
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
114
|
+
rec = {
|
|
115
|
+
"palette": slug,
|
|
116
|
+
"chosen_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
117
|
+
"cwd": os.getcwd(),
|
|
118
|
+
}
|
|
119
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
120
|
+
json.dump(rec, fh, ensure_ascii=False, indent=2)
|
|
121
|
+
fh.write("\n")
|
|
122
|
+
if slug == AUTO:
|
|
123
|
+
print("Palette preference saved: auto (user opted out -> omit --palette)")
|
|
124
|
+
else:
|
|
125
|
+
print(f"Palette preference saved: {slug}")
|
|
126
|
+
print(f"State file: {path}")
|
|
127
|
+
print(f"All later figure calls in this task must append: {FLAG} {slug}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_clear(args):
|
|
132
|
+
path = state_path(args.state)
|
|
133
|
+
if os.path.exists(path):
|
|
134
|
+
try:
|
|
135
|
+
os.remove(path)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
print(f"ERROR: could not remove {path}: {exc}", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
print(f"Removed {path}")
|
|
140
|
+
return 0
|
|
141
|
+
print("(nothing to clear)")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_path(args):
|
|
146
|
+
print(state_path(args.state))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main():
|
|
151
|
+
ap = argparse.ArgumentParser(
|
|
152
|
+
prog="palette_pref.py",
|
|
153
|
+
description="Remember which colour palette the user chose for the current task.",
|
|
154
|
+
)
|
|
155
|
+
ap.add_argument("--state", default=None,
|
|
156
|
+
help="Explicit state file path (default: "
|
|
157
|
+
".workbuddy/like-sci-figures/palette.json under cwd)")
|
|
158
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
159
|
+
|
|
160
|
+
p_get = sub.add_parser("get", help="print the recorded palette (exit 2 if none)")
|
|
161
|
+
p_get.set_defaults(func=cmd_get)
|
|
162
|
+
|
|
163
|
+
p_set = sub.add_parser("set", help="record the user's palette choice")
|
|
164
|
+
p_set.add_argument("--palette", "-p", required=True,
|
|
165
|
+
help="slug / alias / hex list, or 'auto' to opt out")
|
|
166
|
+
p_set.set_defaults(func=cmd_set)
|
|
167
|
+
|
|
168
|
+
p_clear = sub.add_parser("clear", help="forget the recorded choice")
|
|
169
|
+
p_clear.set_defaults(func=cmd_clear)
|
|
170
|
+
|
|
171
|
+
p_path = sub.add_parser("path", help="print where the state file will be written")
|
|
172
|
+
p_path.set_defaults(func=cmd_path)
|
|
173
|
+
|
|
174
|
+
args = ap.parse_args()
|
|
175
|
+
return args.func(args) or 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main())
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate individual preview images for each palette.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
When the skill prompts the user to pick a palette, they need to *see* each
|
|
7
|
+
palette in action — not just a colour-swatch list. The existing palette-picker
|
|
8
|
+
poster crams all 34 palettes into one tall image, which is hard to scan.
|
|
9
|
+
|
|
10
|
+
This script renders one standalone preview PNG per palette, each containing:
|
|
11
|
+
1. Green header with palette name + LIKE watermark
|
|
12
|
+
2. Kind badge + domain tags
|
|
13
|
+
3. Colour swatches strip (all colours side-by-side)
|
|
14
|
+
4. Five representative chart thumbnails (bar, line, scatter, pie, heatmap)
|
|
15
|
+
|
|
16
|
+
The 34 individual previews make it easy to scroll through and pick the right
|
|
17
|
+
one at a glance.
|
|
18
|
+
|
|
19
|
+
Usage
|
|
20
|
+
-----
|
|
21
|
+
palette_preview.py # generate all 34 previews
|
|
22
|
+
palette_preview.py --style nature # different journal style
|
|
23
|
+
palette_preview.py --force # overwrite existing
|
|
24
|
+
palette_preview.py --palettes wx01,wx06 # only specific palettes
|
|
25
|
+
palette_preview.py --dry-run # print plan, render nothing
|
|
26
|
+
palette_preview.py --manifest-only # regenerate manifest only
|
|
27
|
+
"""
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import time
|
|
34
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
35
|
+
|
|
36
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
37
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
38
|
+
|
|
39
|
+
from registry import example_path, ZH_NAME
|
|
40
|
+
from render_utils import render_chart, load_fonts
|
|
41
|
+
from palettes import list_palettes, palette_info
|
|
42
|
+
|
|
43
|
+
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
|
|
44
|
+
PREVIEW_DIR = os.path.join(SKILL_DIR, "previews")
|
|
45
|
+
|
|
46
|
+
# Five representative chart types for the preview
|
|
47
|
+
CHARTS = ["bar", "line", "scatter", "pie", "heatmap"]
|
|
48
|
+
|
|
49
|
+
# ── geometry ───────────────────────────────────────────────────────────────
|
|
50
|
+
W = 800 # canvas width (px)
|
|
51
|
+
HEADER_H = 64
|
|
52
|
+
INFO_H = 44
|
|
53
|
+
SWATCH_H = 36
|
|
54
|
+
PAD = 16
|
|
55
|
+
GAP = 8
|
|
56
|
+
CELL_W = (W - 2 * PAD - 2 * GAP) // 3 # 3 columns → 252 px
|
|
57
|
+
CELL_H = 196
|
|
58
|
+
LABEL_H = 22
|
|
59
|
+
FOOTER_H = 22
|
|
60
|
+
ROWS = 2
|
|
61
|
+
TOTAL_H = (HEADER_H + INFO_H + SWATCH_H + GAP
|
|
62
|
+
+ ROWS * (CELL_H + LABEL_H) + (ROWS - 1) * GAP
|
|
63
|
+
+ PAD + FOOTER_H)
|
|
64
|
+
|
|
65
|
+
KIND_ZH = {"qualitative": "定性", "sequential": "顺序", "diverging": "发散"}
|
|
66
|
+
KIND_RGB = {"qualitative": (0, 102, 204),
|
|
67
|
+
"sequential": (46, 125, 50),
|
|
68
|
+
"diverging": (183, 28, 28)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_chart_thumbs(palette_slug, style, tmpdir, charts=CHARTS):
|
|
72
|
+
"""Render representative chart thumbnails for one palette."""
|
|
73
|
+
thumbs = {}
|
|
74
|
+
def _do(chart):
|
|
75
|
+
out = os.path.join(tmpdir, "%s_%s.png" % (palette_slug, chart))
|
|
76
|
+
data = example_path(chart)
|
|
77
|
+
if not data:
|
|
78
|
+
return chart, None
|
|
79
|
+
png, err, _ = render_chart(
|
|
80
|
+
chart, data, out, style=style, palette=palette_slug,
|
|
81
|
+
width=3.2, height=2.4, dpi=120,
|
|
82
|
+
extra_args=["--no-legend"])
|
|
83
|
+
return chart, png
|
|
84
|
+
|
|
85
|
+
with ThreadPoolExecutor(max_workers=len(charts)) as pool:
|
|
86
|
+
for chart, png in pool.map(_do, charts):
|
|
87
|
+
if png:
|
|
88
|
+
thumbs[chart] = png
|
|
89
|
+
return thumbs
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _compose_preview(thumbs, palette_slug, style, tmpdir):
|
|
93
|
+
"""Compose the final preview image for one palette."""
|
|
94
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
95
|
+
|
|
96
|
+
info = palette_info(palette_slug)
|
|
97
|
+
if not info:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
colors = list(info.get("colors") or [])
|
|
101
|
+
zh = info.get("zh") or palette_slug
|
|
102
|
+
kind = info.get("kind") or ""
|
|
103
|
+
domain_list = info.get("domain") or []
|
|
104
|
+
|
|
105
|
+
width = W
|
|
106
|
+
height = TOTAL_H
|
|
107
|
+
canvas = Image.new("RGB", (width, height), "white")
|
|
108
|
+
d = ImageDraw.Draw(canvas)
|
|
109
|
+
fonts, font_used = load_fonts((22, 18, 14, 12, 10))
|
|
110
|
+
f22 = fonts.get(22) or ImageFont.load_default()
|
|
111
|
+
f18 = fonts.get(18) or ImageFont.load_default()
|
|
112
|
+
f14 = fonts.get(14) or ImageFont.load_default()
|
|
113
|
+
f12 = fonts.get(12) or ImageFont.load_default()
|
|
114
|
+
|
|
115
|
+
# ── header ───────────────────────────────────────────────────────────
|
|
116
|
+
d.rectangle([0, 0, width, HEADER_H], fill="#0B6E4F")
|
|
117
|
+
d.text((PAD + 2, 10), "LIKE Sci Figures — 配色预览",
|
|
118
|
+
fill="white", font=f22)
|
|
119
|
+
d.text((PAD + 2, 40), "%s · 风格: %s" % (zh, style),
|
|
120
|
+
fill="#D7E1EB", font=f14)
|
|
121
|
+
|
|
122
|
+
# LIKE watermark (right-aligned)
|
|
123
|
+
tag = "LIKE"
|
|
124
|
+
bbox = d.textbbox((0, 0), tag, font=f18)
|
|
125
|
+
tag_w = bbox[2] - bbox[0]
|
|
126
|
+
d.text((width - PAD - tag_w - 2, 14), tag,
|
|
127
|
+
fill="#D7E1EB", font=f18)
|
|
128
|
+
|
|
129
|
+
# ── info strip ───────────────────────────────────────────────────────
|
|
130
|
+
y = HEADER_H
|
|
131
|
+
d.rectangle([0, y, width, y + INFO_H], fill="#F5F7FA")
|
|
132
|
+
|
|
133
|
+
# kind badge
|
|
134
|
+
lx = PAD + 4
|
|
135
|
+
badge_w = 58
|
|
136
|
+
d.rounded_rectangle(
|
|
137
|
+
[lx, y + 10, lx + badge_w, y + 10 + 24],
|
|
138
|
+
radius=4, fill=KIND_RGB.get(kind, (90, 90, 90)))
|
|
139
|
+
d.text((lx + 8, y + 14), KIND_ZH.get(kind, kind),
|
|
140
|
+
fill="white", font=f14)
|
|
141
|
+
|
|
142
|
+
# colour count
|
|
143
|
+
d.text((lx + badge_w + 10, y + 14),
|
|
144
|
+
"%d 色" % len(colors), fill="#555555", font=f14)
|
|
145
|
+
|
|
146
|
+
# domain tags
|
|
147
|
+
if domain_list:
|
|
148
|
+
domain_text = "适用:" + "/".join(domain_list[:3])
|
|
149
|
+
d.text((lx + badge_w + 60, y + 14), domain_text[:40],
|
|
150
|
+
fill="#777777", font=f14)
|
|
151
|
+
|
|
152
|
+
# ── colour swatches ─────────────────────────────────────────────────
|
|
153
|
+
y += INFO_H
|
|
154
|
+
d.rectangle([0, y, width, y + SWATCH_H], fill="#FAFBFC")
|
|
155
|
+
|
|
156
|
+
swatch_x = PAD + 4
|
|
157
|
+
max_colors = 20
|
|
158
|
+
swatch_w = min(28, (width - 2 * PAD - 12) // max_colors)
|
|
159
|
+
swatch_h = 22
|
|
160
|
+
swatch_y = y + (SWATCH_H - swatch_h) // 2
|
|
161
|
+
shown = min(len(colors), max_colors)
|
|
162
|
+
for i, hexc in enumerate(colors[:shown]):
|
|
163
|
+
cx = swatch_x + i * swatch_w
|
|
164
|
+
d.rectangle([cx, swatch_y, cx + swatch_w, swatch_y + swatch_h],
|
|
165
|
+
fill=hexc, outline="#CCCCCC", width=1)
|
|
166
|
+
if len(colors) > shown:
|
|
167
|
+
d.text((swatch_x + shown * swatch_w + 6, swatch_y + 4),
|
|
168
|
+
"+%d" % (len(colors) - shown), fill="#999999", font=f12)
|
|
169
|
+
|
|
170
|
+
# ── chart grid ───────────────────────────────────────────────────────
|
|
171
|
+
y += SWATCH_H + GAP
|
|
172
|
+
for idx, chart_type in enumerate(CHARTS):
|
|
173
|
+
col = idx % 3
|
|
174
|
+
row = idx // 3
|
|
175
|
+
x = PAD + col * (CELL_W + GAP)
|
|
176
|
+
cy = y + row * (CELL_H + LABEL_H + GAP)
|
|
177
|
+
|
|
178
|
+
# cell border
|
|
179
|
+
d.rectangle([x, cy, x + CELL_W, cy + CELL_H],
|
|
180
|
+
fill="white", outline="#DDDDDD")
|
|
181
|
+
|
|
182
|
+
# paste thumbnail
|
|
183
|
+
png = thumbs.get(chart_type)
|
|
184
|
+
if png and os.path.exists(png):
|
|
185
|
+
im = Image.open(png).convert("RGB")
|
|
186
|
+
if im.width != CELL_W or im.height != CELL_H:
|
|
187
|
+
im = im.resize((CELL_W, CELL_H), Image.LANCZOS)
|
|
188
|
+
canvas.paste(im, (x, cy))
|
|
189
|
+
else:
|
|
190
|
+
d.text((x + 10, cy + CELL_H // 2 - 10), "渲染失败",
|
|
191
|
+
fill="#999999", font=f14)
|
|
192
|
+
|
|
193
|
+
# label
|
|
194
|
+
label = "%s (%s)" % (ZH_NAME.get(chart_type, chart_type),
|
|
195
|
+
chart_type)
|
|
196
|
+
d.text((x + 4, cy + CELL_H + 4), label, fill="#111111", font=f14)
|
|
197
|
+
|
|
198
|
+
# ── footer ───────────────────────────────────────────────────────────
|
|
199
|
+
fy = height - FOOTER_H
|
|
200
|
+
bbox = d.textbbox((0, 0), tag, font=f12)
|
|
201
|
+
d.text((width - PAD - (bbox[2] - bbox[0]) - 2, fy + 4),
|
|
202
|
+
tag, fill="#999999", font=f12)
|
|
203
|
+
|
|
204
|
+
return canvas
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def generate(style="nature", workers=4, force=False, dry_run=False,
|
|
208
|
+
manifest_only=False, palette_filter=None):
|
|
209
|
+
"""Generate preview PNGs for all (or selected) palettes."""
|
|
210
|
+
all_slugs = [s for s in list_palettes() if palette_info(s)]
|
|
211
|
+
|
|
212
|
+
if palette_filter:
|
|
213
|
+
all_slugs = [s for s in all_slugs if s in palette_filter]
|
|
214
|
+
|
|
215
|
+
if dry_run:
|
|
216
|
+
print("would render %d previews -> %s" % (len(all_slugs), PREVIEW_DIR))
|
|
217
|
+
for slug in all_slugs:
|
|
218
|
+
print(" %-30s -> previews/%s.png" % (slug, slug))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
if manifest_only:
|
|
222
|
+
# Just regenerate manifest from existing files
|
|
223
|
+
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
224
|
+
manifest = _write_manifest(all_slugs, style)
|
|
225
|
+
print("Manifest regenerated: %s" % manifest)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
229
|
+
results = {}
|
|
230
|
+
errors = []
|
|
231
|
+
t0 = time.time()
|
|
232
|
+
|
|
233
|
+
for slug in all_slugs:
|
|
234
|
+
out_path = os.path.join(PREVIEW_DIR, "%s.png" % slug)
|
|
235
|
+
if os.path.exists(out_path) and not force:
|
|
236
|
+
print(" %-30s (exists, skipped)" % slug)
|
|
237
|
+
results[slug] = out_path
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
print(" Rendering: %s ..." % slug)
|
|
241
|
+
tmpdir = tempfile.mkdtemp(prefix="sf_prev_%s_" % slug)
|
|
242
|
+
try:
|
|
243
|
+
thumbs = _render_chart_thumbs(slug, style, tmpdir)
|
|
244
|
+
canvas = _compose_preview(thumbs, slug, style, tmpdir)
|
|
245
|
+
if canvas:
|
|
246
|
+
canvas.save(out_path, dpi=(160, 160))
|
|
247
|
+
results[slug] = out_path
|
|
248
|
+
else:
|
|
249
|
+
errors.append("%s: compose returned None" % slug)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
errors.append("%s: %s" % (slug, str(exc)))
|
|
252
|
+
|
|
253
|
+
# cleanup
|
|
254
|
+
import shutil
|
|
255
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
256
|
+
|
|
257
|
+
elapsed = time.time() - t0
|
|
258
|
+
|
|
259
|
+
# Write manifest
|
|
260
|
+
manifest = _write_manifest(all_slugs, style)
|
|
261
|
+
|
|
262
|
+
print("\nPreviews: %d ok, %d failed (%.1fs) -> %s"
|
|
263
|
+
% (len(results), len(errors), elapsed, PREVIEW_DIR))
|
|
264
|
+
if errors:
|
|
265
|
+
for e in errors:
|
|
266
|
+
print(" FAIL %s" % e, file=sys.stderr)
|
|
267
|
+
print("Manifest: %s" % manifest)
|
|
268
|
+
return 0 if not errors else 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _write_manifest(all_slugs, style):
|
|
272
|
+
"""Write or update the preview manifest."""
|
|
273
|
+
from palettes import palette_info as _pi
|
|
274
|
+
manifest = {
|
|
275
|
+
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
276
|
+
"style": style,
|
|
277
|
+
"charts": CHARTS,
|
|
278
|
+
"previews": [],
|
|
279
|
+
}
|
|
280
|
+
for slug in all_slugs:
|
|
281
|
+
info = _pi(slug)
|
|
282
|
+
path = os.path.join(PREVIEW_DIR, "%s.png" % slug)
|
|
283
|
+
entry = {
|
|
284
|
+
"palette": slug,
|
|
285
|
+
"zh": info.get("zh", "") if info else "",
|
|
286
|
+
"kind": info.get("kind", "") if info else "",
|
|
287
|
+
"domain": info.get("domain", []) if info else [],
|
|
288
|
+
"file": "%s.png" % slug,
|
|
289
|
+
"status": "ok" if os.path.exists(path) else "missing",
|
|
290
|
+
}
|
|
291
|
+
if os.path.exists(path):
|
|
292
|
+
entry["bytes"] = os.path.getsize(path)
|
|
293
|
+
manifest["previews"].append(entry)
|
|
294
|
+
|
|
295
|
+
manifest_path = os.path.join(PREVIEW_DIR, "manifest.json")
|
|
296
|
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
297
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
298
|
+
fh.write("\n")
|
|
299
|
+
return manifest_path
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main():
|
|
303
|
+
ap = argparse.ArgumentParser(
|
|
304
|
+
prog="palette_preview.py",
|
|
305
|
+
description="Generate individual preview PNGs for each palette, "
|
|
306
|
+
"showing representative charts + colour swatches.")
|
|
307
|
+
ap.add_argument("--style", default="nature",
|
|
308
|
+
help="Journal style for chart thumbnails (default: %(default)s)")
|
|
309
|
+
ap.add_argument("--workers", type=int, default=4,
|
|
310
|
+
help="Parallel render workers (default: %(default)s)")
|
|
311
|
+
ap.add_argument("--force", action="store_true",
|
|
312
|
+
help="Overwrite existing preview images")
|
|
313
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
314
|
+
help="Print plan without rendering")
|
|
315
|
+
ap.add_argument("--manifest-only", action="store_true",
|
|
316
|
+
help="Only regenerate manifest, skip rendering")
|
|
317
|
+
ap.add_argument("--palettes", default=None,
|
|
318
|
+
help="Comma-separated palette slugs to preview (default: all)")
|
|
319
|
+
args = ap.parse_args()
|
|
320
|
+
|
|
321
|
+
palette_filter = None
|
|
322
|
+
if args.palettes:
|
|
323
|
+
palette_filter = set(c.strip() for c in args.palettes.split(",") if c.strip())
|
|
324
|
+
|
|
325
|
+
return generate(
|
|
326
|
+
style=args.style, workers=args.workers,
|
|
327
|
+
force=args.force, dry_run=args.dry_run,
|
|
328
|
+
manifest_only=args.manifest_only,
|
|
329
|
+
palette_filter=palette_filter,
|
|
330
|
+
) or 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
sys.exit(main())
|