figfoundry 0.1.0__tar.gz

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.
@@ -0,0 +1,4 @@
1
+ # Normalize line endings to LF in the repo (cross-platform Python standard).
2
+ * text=auto eol=lf
3
+ *.bat text eol=crlf
4
+ *.npz binary
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Environments / caches
10
+ .venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Editors / OS
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tai D. Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: figfoundry
3
+ Version: 0.1.0
4
+ Summary: WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code.
5
+ Project-URL: Homepage, https://github.com/ductai199x/figfoundry
6
+ Project-URL: Repository, https://github.com/ductai199x/figfoundry
7
+ Project-URL: Issues, https://github.com/ductai199x/figfoundry/issues
8
+ Author-email: Tai Duc Nguyen <taiducnguyen.drexel@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: data-visualization,figures,matplotlib,plotting,scientific-figures,wysiwyg
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Multimedia :: Graphics
22
+ Classifier: Topic :: Scientific/Engineering :: Visualization
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: fastapi>=0.110
25
+ Requires-Dist: matplotlib>=3.9
26
+ Requires-Dist: numpy>=1.24
27
+ Requires-Dist: pydantic>=2.0
28
+ Requires-Dist: python-multipart>=0.0.9
29
+ Requires-Dist: uvicorn>=0.29
30
+ Provides-Extra: dev
31
+ Requires-Dist: httpx2>=2.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Fig Foundry
36
+
37
+ A WYSIWYG matplotlib figure composer. Load your data (`.npz` / `.pkl` / `.csv`),
38
+ drag signals onto a free canvas to build plots, arrange and style them, and export
39
+ clean, reproducible Python.
40
+
41
+ **The preview renders by executing the generated code**, so what you see is
42
+ byte-for-byte what the exported `.py` produces. There is no second renderer to
43
+ drift.
44
+
45
+ Fig Foundry is a standalone local app with no dependency on any particular
46
+ project's data. It ships with a small neutral `sample.npz` (1-D signals plus a
47
+ 2-D field) so a fresh install has something to open; load your own dataset to work
48
+ with real data.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv tool install ./path/to/fig_editor # installs a `figfoundry` command
54
+ # or, from a git repo:
55
+ uv tool install figfoundry --from "git+https://github.com/<you>/<repo>.git"
56
+ ```
57
+
58
+ Then run:
59
+
60
+ ```bash
61
+ figfoundry # starts the web app on http://127.0.0.1:8037 and opens the browser
62
+ figfoundry serve --port 9000 --no-browser
63
+ ```
64
+
65
+ ## Command line
66
+
67
+ The same engines are available as subcommands for scripting:
68
+
69
+ ```bash
70
+ figfoundry list # list saved figures
71
+ figfoundry new --name my-fig # create a blank figure, print its slug
72
+ figfoundry export SLUG [--out PATH] # write a figure's generated .py
73
+ figfoundry render SLUG --format svg|pdf|png # render a figure to a file
74
+ figfoundry datasets # list available datasets
75
+ ```
76
+
77
+ ## Data
78
+
79
+ A *dataset* is a named collection of signals (arrays). Supported formats:
80
+
81
+ - **`.npz`** — numpy zip of named arrays. Safe by construction.
82
+ - **`.pkl`** — Python pickle, loaded through a **whitelist unpickler** that only
83
+ permits numpy arrays and plain containers (blocks `os`, `subprocess`, `socket`,
84
+ `ctypes`, …). A malicious pickle cannot execute code.
85
+ - **`.csv`** — a table with a header row; each numeric column becomes a signal.
86
+
87
+ 1-D and 2-D arrays are both supported (2-D for image/contour/vector plots). Load a
88
+ dataset with the **Load data…** button in the toolbar; each canvas is tied to one
89
+ dataset, and its signals populate the library.
90
+
91
+ Your figures, datasets, and exports live in `~/.figfoundry/`.
92
+
93
+ ## What you can do
94
+
95
+ - Drag a signal onto the canvas to make a plot (or onto an existing plot to
96
+ overlay a line / σ-band / error bars / replace).
97
+ - **Plot types (23):** line, step, scatter, grouped scatter (clustering), bar,
98
+ bar (horizontal), histogram, box plot, violin plot, 2-D histogram, hexbin, image,
99
+ contour (filled / lines), pcolormesh, vector field, streamplot, decision boundary,
100
+ pie, polar, 3-D scatter, surface (3-D), wireframe (3-D).
101
+ - Move, resize (with snapping), and nudge (arrow keys) plots and elements.
102
+ - Multi-select (Shift+click) to nudge / move / delete several at once.
103
+ - Per-plot: data, color, line/marker, σ-band, error bars, ticks, labels, limits,
104
+ spines, title, colormap + colorbar, and artists (vline, hline, text, annotate,
105
+ arrow, box, circle, ellipse, polygon, span, legend).
106
+ - **Suggest accessible colors** — one click applies the colorblind-safe Okabe-Ito
107
+ palette to a plot's series.
108
+ - **LaTeX mathtext** in labels, titles, and text (e.g. `$x_0$`).
109
+ - Layout: grid, align, distribute, margins, sync axis ranges.
110
+ - Figure elements: axis labels, legend, suptitle, free text.
111
+ - Name / duplicate / delete figures. Export the `.py` (copy or download), and the
112
+ figure itself as **PNG / SVG / PDF** (SVG and PDF are vector, for publication).
113
+
114
+ Everything is also available through a small JSON API (`/api/canvases`,
115
+ `/api/datasets`, `/api/palettes`, `/api/canvases/{slug}/render|code|figure|download`).
116
+
117
+ ## Testing
118
+
119
+ The test suite lives in `tests/` (outside the package, so it is never shipped).
120
+ It proves the WYSIWYG invariant (for every plot type), the pickle-safety
121
+ whitelist, and dataset I/O (npz/pkl/csv), each against a throwaway data dir:
122
+
123
+ ```bash
124
+ uv run --extra dev pytest -q
125
+ ```
@@ -0,0 +1,91 @@
1
+ # Fig Foundry
2
+
3
+ A WYSIWYG matplotlib figure composer. Load your data (`.npz` / `.pkl` / `.csv`),
4
+ drag signals onto a free canvas to build plots, arrange and style them, and export
5
+ clean, reproducible Python.
6
+
7
+ **The preview renders by executing the generated code**, so what you see is
8
+ byte-for-byte what the exported `.py` produces. There is no second renderer to
9
+ drift.
10
+
11
+ Fig Foundry is a standalone local app with no dependency on any particular
12
+ project's data. It ships with a small neutral `sample.npz` (1-D signals plus a
13
+ 2-D field) so a fresh install has something to open; load your own dataset to work
14
+ with real data.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv tool install ./path/to/fig_editor # installs a `figfoundry` command
20
+ # or, from a git repo:
21
+ uv tool install figfoundry --from "git+https://github.com/<you>/<repo>.git"
22
+ ```
23
+
24
+ Then run:
25
+
26
+ ```bash
27
+ figfoundry # starts the web app on http://127.0.0.1:8037 and opens the browser
28
+ figfoundry serve --port 9000 --no-browser
29
+ ```
30
+
31
+ ## Command line
32
+
33
+ The same engines are available as subcommands for scripting:
34
+
35
+ ```bash
36
+ figfoundry list # list saved figures
37
+ figfoundry new --name my-fig # create a blank figure, print its slug
38
+ figfoundry export SLUG [--out PATH] # write a figure's generated .py
39
+ figfoundry render SLUG --format svg|pdf|png # render a figure to a file
40
+ figfoundry datasets # list available datasets
41
+ ```
42
+
43
+ ## Data
44
+
45
+ A *dataset* is a named collection of signals (arrays). Supported formats:
46
+
47
+ - **`.npz`** — numpy zip of named arrays. Safe by construction.
48
+ - **`.pkl`** — Python pickle, loaded through a **whitelist unpickler** that only
49
+ permits numpy arrays and plain containers (blocks `os`, `subprocess`, `socket`,
50
+ `ctypes`, …). A malicious pickle cannot execute code.
51
+ - **`.csv`** — a table with a header row; each numeric column becomes a signal.
52
+
53
+ 1-D and 2-D arrays are both supported (2-D for image/contour/vector plots). Load a
54
+ dataset with the **Load data…** button in the toolbar; each canvas is tied to one
55
+ dataset, and its signals populate the library.
56
+
57
+ Your figures, datasets, and exports live in `~/.figfoundry/`.
58
+
59
+ ## What you can do
60
+
61
+ - Drag a signal onto the canvas to make a plot (or onto an existing plot to
62
+ overlay a line / σ-band / error bars / replace).
63
+ - **Plot types (23):** line, step, scatter, grouped scatter (clustering), bar,
64
+ bar (horizontal), histogram, box plot, violin plot, 2-D histogram, hexbin, image,
65
+ contour (filled / lines), pcolormesh, vector field, streamplot, decision boundary,
66
+ pie, polar, 3-D scatter, surface (3-D), wireframe (3-D).
67
+ - Move, resize (with snapping), and nudge (arrow keys) plots and elements.
68
+ - Multi-select (Shift+click) to nudge / move / delete several at once.
69
+ - Per-plot: data, color, line/marker, σ-band, error bars, ticks, labels, limits,
70
+ spines, title, colormap + colorbar, and artists (vline, hline, text, annotate,
71
+ arrow, box, circle, ellipse, polygon, span, legend).
72
+ - **Suggest accessible colors** — one click applies the colorblind-safe Okabe-Ito
73
+ palette to a plot's series.
74
+ - **LaTeX mathtext** in labels, titles, and text (e.g. `$x_0$`).
75
+ - Layout: grid, align, distribute, margins, sync axis ranges.
76
+ - Figure elements: axis labels, legend, suptitle, free text.
77
+ - Name / duplicate / delete figures. Export the `.py` (copy or download), and the
78
+ figure itself as **PNG / SVG / PDF** (SVG and PDF are vector, for publication).
79
+
80
+ Everything is also available through a small JSON API (`/api/canvases`,
81
+ `/api/datasets`, `/api/palettes`, `/api/canvases/{slug}/render|code|figure|download`).
82
+
83
+ ## Testing
84
+
85
+ The test suite lives in `tests/` (outside the package, so it is never shipped).
86
+ It proves the WYSIWYG invariant (for every plot type), the pickle-safety
87
+ whitelist, and dataset I/O (npz/pkl/csv), each against a throwaway data dir:
88
+
89
+ ```bash
90
+ uv run --extra dev pytest -q
91
+ ```
@@ -0,0 +1,7 @@
1
+ """Fig Foundry -- a WYSIWYG matplotlib figure composer.
2
+
3
+ Load a dataset (.npz / .pkl), drag signals onto a canvas to make plots, arrange
4
+ and style them freely, and export clean, reproducible Python. The preview renders
5
+ by executing the generated code, so what you see is exactly what the .py produces.
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,378 @@
1
+ """Fig Foundry -- FastAPI backend.
2
+
3
+ The spec (a scene graph of the figure) is the single source of truth. The browser
4
+ manipulates it; the backend renders it by EXECUTING the generated code (so the
5
+ preview is byte-identical to the export); and it can serialize the spec to a
6
+ standalone .py that references the dataset.
7
+ """
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import re
12
+ import threading
13
+ import time
14
+
15
+ from fastapi import FastAPI, File, HTTPException, UploadFile
16
+ from fastapi.responses import FileResponse, JSONResponse, Response
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel
19
+
20
+ from . import canvas, data_io
21
+
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ USER_DIR = data_io.USER_DIR
24
+ CANVAS_DIR = os.path.join(USER_DIR, "canvases")
25
+ OUT_DIR = os.path.join(USER_DIR, "out")
26
+ os.makedirs(CANVAS_DIR, exist_ok=True)
27
+ os.makedirs(OUT_DIR, exist_ok=True)
28
+
29
+ app = FastAPI(title="Fig Foundry")
30
+
31
+
32
+ @app.middleware("http")
33
+ async def no_cache(request, call_next):
34
+ """Local tool: never let the browser serve stale JS/HTML/CSS after an update."""
35
+ resp = await call_next(request)
36
+ resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
37
+ resp.headers["Pragma"] = "no-cache"
38
+ resp.headers["Expires"] = "0"
39
+ return resp
40
+
41
+
42
+ class CanvasBody(BaseModel):
43
+ spec: dict | None = None
44
+ draft: bool = False
45
+
46
+
47
+ class NewCanvasBody(BaseModel):
48
+ mode: str = "blank"
49
+
50
+
51
+ class UpdateCanvasBody(BaseModel):
52
+ name: str | None = None
53
+ spec: dict | None = None
54
+
55
+
56
+ def _cpath(slug):
57
+ return os.path.join(CANVAS_DIR, f"{slug}.json")
58
+
59
+
60
+ def _check_slug(slug):
61
+ # Slugs are generated as <timestamp>-<hex>; reject anything with path separators
62
+ # (including %-decoded ones, e.g. "..%2F..%2Fetc") so a crafted slug can't
63
+ # traverse out of the canvases dir.
64
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", slug):
65
+ raise HTTPException(404, "canvas not found")
66
+
67
+
68
+ def _read(slug):
69
+ _check_slug(slug)
70
+ p = _cpath(slug)
71
+ if not os.path.exists(p):
72
+ raise HTTPException(404, "canvas not found")
73
+ try:
74
+ with open(p) as f:
75
+ doc = json.load(f)
76
+ except (OSError, ValueError) as e: # ValueError covers json.JSONDecodeError
77
+ raise HTTPException(500, "canvas file is corrupt") from e
78
+ # Every endpoint below does doc["spec"] / doc["spec"].get(...); a partial or
79
+ # hand-edited file without a dict spec would be a raw 500 KeyError/AttributeError.
80
+ if not isinstance(doc, dict) or not isinstance(doc.get("spec"), dict):
81
+ raise HTTPException(500, "canvas file is corrupt: missing spec")
82
+ return doc
83
+
84
+
85
+ def _check_dataset_name(name):
86
+ if os.path.basename(name) != name or name in (".", ".."):
87
+ raise HTTPException(400, "invalid dataset name")
88
+
89
+
90
+ _write_lock = threading.Lock()
91
+
92
+
93
+ def _write(slug, doc):
94
+ # Serialize writes (threadpool) and make each atomic (temp + rename) so a reader
95
+ # never sees a partial/corrupt file. On Windows a concurrent reader's open handle
96
+ # can briefly refuse the rename, so retry it; clean up the temp on any failure.
97
+ with _write_lock:
98
+ tmp = _cpath(slug) + ".tmp"
99
+ try:
100
+ with open(tmp, "w") as f:
101
+ json.dump(doc, f, indent=2)
102
+ last = None
103
+ for _ in range(20):
104
+ try:
105
+ os.replace(tmp, _cpath(slug))
106
+ last = None
107
+ break
108
+ except PermissionError as e:
109
+ last = e
110
+ time.sleep(0.02)
111
+ if last is not None:
112
+ raise last
113
+ except BaseException:
114
+ if os.path.exists(tmp):
115
+ os.remove(tmp)
116
+ raise
117
+
118
+
119
+ def _list_canvases():
120
+ out = []
121
+ for f in sorted(os.listdir(CANVAS_DIR), reverse=True):
122
+ if not f.endswith(".json"):
123
+ continue
124
+ try:
125
+ with open(os.path.join(CANVAS_DIR, f)) as fh:
126
+ doc = json.load(fh)
127
+ out.append({"slug": f[:-5], "name": doc.get("name", "Untitled"),
128
+ "plots": len(doc.get("spec", {}).get("plots", [])), "updated": doc.get("updated")})
129
+ except Exception:
130
+ continue
131
+ return out
132
+
133
+
134
+ # --------------------------------------------------------------------------- pages
135
+ @app.get("/")
136
+ def index():
137
+ return FileResponse(os.path.join(HERE, "static", "index.html"))
138
+
139
+
140
+ @app.get("/canvas")
141
+ def canvas_page():
142
+ return FileResponse(os.path.join(HERE, "static", "canvas.html"))
143
+
144
+
145
+ # --------------------------------------------------------------------------- datasets
146
+ # The picker calls this on every open; re-parsing (unpickling!) each dataset file
147
+ # every time is pure waste for large files. Cache per-file summaries keyed by
148
+ # (mtime, size) -- a re-upload of the same name changes both, so it invalidates.
149
+ _ds_list_cache = {}
150
+
151
+
152
+ @app.get("/api/datasets")
153
+ def list_datasets():
154
+ out = []
155
+ for f in sorted(os.listdir(data_io.DATA_DIR)):
156
+ if f.endswith((".npz", ".pkl", ".pickle", ".csv")):
157
+ p = os.path.join(data_io.DATA_DIR, f)
158
+ try:
159
+ st = os.stat(p)
160
+ except OSError:
161
+ continue
162
+ hit = _ds_list_cache.get(f)
163
+ if hit is not None and hit[0] == st.st_mtime and hit[1] == st.st_size:
164
+ out.append(hit[2])
165
+ continue
166
+ try:
167
+ arrays, kind = data_io.load_arrays(p)
168
+ info = {"name": f, "kind": kind, "n_signals": len(arrays)}
169
+ except Exception as e:
170
+ info = {"name": f, "kind": "?", "n_signals": 0, "error": str(e)}
171
+ _ds_list_cache[f] = (st.st_mtime, st.st_size, info)
172
+ out.append(info)
173
+ return {"datasets": out}
174
+
175
+
176
+ @app.get("/api/datasets/{name}/signals")
177
+ def dataset_signals(name):
178
+ """The signals in a specific dataset file (independent of any canvas)."""
179
+ _check_dataset_name(name)
180
+ path = os.path.join(data_io.DATA_DIR, name)
181
+ if not os.path.exists(path):
182
+ raise HTTPException(404, "dataset not found")
183
+ try:
184
+ arrays, kind = data_io.load_arrays(path)
185
+ except Exception as e:
186
+ raise HTTPException(400, f"could not load dataset: {e}") from e
187
+ return {"dataset": name, "kind": kind, "signals": data_io.list_signals(arrays)}
188
+
189
+
190
+ @app.post("/api/datasets")
191
+ async def upload_dataset(file: UploadFile = File(...)):
192
+ """Upload a .npz / .pkl / .csv dataset. pkl goes through the whitelist
193
+ SafeUnpickler. The payload is validated before it is written, so a bad upload
194
+ can never clobber an existing dataset of the same name."""
195
+ name = os.path.basename(file.filename or "upload")
196
+ if not name.endswith((".npz", ".pkl", ".pickle", ".csv")):
197
+ raise HTTPException(400, "only .npz / .pkl / .csv datasets are supported")
198
+ payload = await file.read()
199
+ try:
200
+ arrays, kind = data_io.load_arrays_bytes(payload, name)
201
+ except Exception as e:
202
+ raise HTTPException(400, f"could not load dataset: {e}") from e
203
+ try:
204
+ _write_atomic_bytes(os.path.join(data_io.DATA_DIR, name), payload)
205
+ except OSError as e: # e.g. a filename character the OS refuses (newline on Windows)
206
+ raise HTTPException(400, f"could not save dataset: {e}") from e
207
+ # Belt and braces: canvas.get_dataset_arrays already invalidates by
208
+ # (mtime, size) on read, but dropping the entry here too guarantees the next
209
+ # signal-list call re-stats and re-loads the freshly uploaded file.
210
+ canvas._ds_cache.pop(name, None)
211
+ return {"name": name, "kind": kind, "n_signals": len(arrays), "signals": data_io.list_signals(arrays)}
212
+
213
+
214
+ def _write_atomic_bytes(path, payload):
215
+ """Atomic byte write (unique temp + rename) so a reader never sees a partial
216
+ file and two concurrent uploads of the same name can't clobber each other's
217
+ temp. The temp name is unique per thread: a shared "<path>.tmp" would let one
218
+ thread's os.replace grab the other's in-progress write. On Windows a
219
+ concurrent reader's handle can briefly refuse the rename, so retry it."""
220
+ tmp = f"{path}.{threading.get_ident()}.tmp"
221
+ try:
222
+ with open(tmp, "wb") as f:
223
+ f.write(payload)
224
+ last = None
225
+ for _ in range(20):
226
+ try:
227
+ os.replace(tmp, path)
228
+ last = None
229
+ break
230
+ except PermissionError as e:
231
+ last = e
232
+ time.sleep(0.02)
233
+ if last is not None:
234
+ raise last
235
+ except BaseException:
236
+ with contextlib.suppress(OSError):
237
+ os.remove(tmp)
238
+ raise
239
+
240
+
241
+ # --------------------------------------------------------------------------- canvases
242
+ @app.get("/api/canvases")
243
+ def list_canvases():
244
+ return {"canvases": _list_canvases()}
245
+
246
+
247
+ @app.post("/api/canvases")
248
+ def new_canvas(body: NewCanvasBody):
249
+ slug = time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
250
+ spec = canvas.blank_spec() # always blank; load a dataset to get signals
251
+ _write(slug, {"name": "Untitled", "spec": spec, "updated": time.time()})
252
+ return {"slug": slug, "name": "Untitled"}
253
+
254
+
255
+ @app.get("/api/canvases/{slug}")
256
+ def get_canvas(slug):
257
+ doc = _read(slug)
258
+ return {"slug": slug, "name": doc.get("name", "Untitled"), "spec": doc["spec"]}
259
+
260
+
261
+ @app.put("/api/canvases/{slug}")
262
+ def update_canvas(slug, body: UpdateCanvasBody):
263
+ doc = _read(slug)
264
+ if body.name is not None:
265
+ doc["name"] = body.name
266
+ if body.spec is not None:
267
+ doc["spec"] = body.spec
268
+ doc["updated"] = time.time()
269
+ _write(slug, doc)
270
+ return {"ok": True}
271
+
272
+
273
+ @app.delete("/api/canvases/{slug}")
274
+ def delete_canvas(slug):
275
+ _check_slug(slug)
276
+ p = _cpath(slug)
277
+ if os.path.exists(p):
278
+ os.remove(p)
279
+ return {"ok": True}
280
+
281
+
282
+ @app.post("/api/canvases/{slug}/duplicate")
283
+ def duplicate_canvas(slug):
284
+ doc = _read(slug)
285
+ newslug = time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
286
+ name = str(doc.get("name") or "Untitled") # a hand-edited file may hold a non-str
287
+ newdoc = {"name": name + " copy", "spec": json.loads(json.dumps(doc["spec"])), "updated": time.time()}
288
+ _write(newslug, newdoc)
289
+ return {"slug": newslug, "name": newdoc["name"]}
290
+
291
+
292
+ @app.get("/api/canvases/{slug}/signals")
293
+ def canvas_signals(slug):
294
+ """The signal library for a canvas = the signals in its dataset (empty if none)."""
295
+ doc = _read(slug)
296
+ ds = doc["spec"].get("dataset")
297
+ if not ds:
298
+ return {"dataset": None, "signals": []}
299
+ try:
300
+ arrays = canvas.get_dataset_arrays(ds)
301
+ except HTTPException:
302
+ raise
303
+ except Exception as e: # dataset deleted from disk, or a non-str name in a hand-edited spec
304
+ raise HTTPException(400, f"could not load dataset: {e}") from e
305
+ return {"dataset": ds, "signals": data_io.list_signals(arrays)}
306
+
307
+
308
+ @app.post("/api/canvases/{slug}/render")
309
+ def render_canvas(slug, body: CanvasBody | None = None):
310
+ spec = body.spec if (body is not None and body.spec is not None) else _read(slug)["spec"]
311
+ draft = body.draft if body is not None else False
312
+ try:
313
+ return canvas.render_to_png(spec, draft=draft)
314
+ except Exception as e:
315
+ raise HTTPException(400, f"render failed: {e}") from e
316
+
317
+
318
+ @app.get("/api/canvases/{slug}/code")
319
+ def code_canvas(slug):
320
+ try:
321
+ code = canvas.codegen_canvas(_read(slug)["spec"])
322
+ except HTTPException:
323
+ raise # _read's 404/500 must not be re-wrapped as a codegen failure
324
+ except Exception as e:
325
+ raise HTTPException(400, f"codegen failed: {e}") from e
326
+ return JSONResponse({"code": code})
327
+
328
+
329
+ @app.get("/api/canvases/{slug}/download")
330
+ def download_canvas(slug):
331
+ doc = _read(slug)
332
+ try:
333
+ code = canvas.codegen_canvas(doc["spec"])
334
+ except HTTPException:
335
+ raise
336
+ except Exception as e:
337
+ raise HTTPException(400, f"codegen failed: {e}") from e
338
+ # Same filename-safe character set as export_figure: the name is client-
339
+ # controlled, and FileResponse's Content-Disposition handling of it has
340
+ # changed across starlette versions (older ones quoted it raw).
341
+ base = str(doc.get("name") or "canvas").strip().replace(" ", "_") or "canvas"
342
+ fname = re.sub(r"[^A-Za-z0-9._-]", "_", base) + ".py"
343
+ path = os.path.join(OUT_DIR, f"canvas_{slug}.py")
344
+ with open(path, "w") as f:
345
+ f.write(code)
346
+ return FileResponse(path, filename=fname, media_type="text/x-python")
347
+
348
+
349
+ _FIG_CT = {"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}
350
+
351
+
352
+ @app.get("/api/canvases/{slug}/figure")
353
+ def export_figure(slug, format: str = "png"):
354
+ """Render the figure (by executing the code) and return it as png / svg / pdf.
355
+ svg and pdf are vector, suitable for publication."""
356
+ if format not in _FIG_CT:
357
+ raise HTTPException(400, "format must be png, svg, or pdf")
358
+ doc = _read(slug)
359
+ try:
360
+ data = canvas.render_to_bytes(doc["spec"], fmt=format)
361
+ except HTTPException:
362
+ raise
363
+ except Exception as e:
364
+ raise HTTPException(400, f"render failed: {e}") from e
365
+ base = str(doc.get("name") or "figure").strip().replace(" ", "_") or "figure"
366
+ # The canvas name is client-controlled; keep the hand-built header value to
367
+ # filename-safe characters so a quote/newline can't break Content-Disposition.
368
+ fname = re.sub(r"[^A-Za-z0-9._-]", "_", base) + "." + format
369
+ return Response(content=data, media_type=_FIG_CT[format],
370
+ headers={"Content-Disposition": f'attachment; filename="{fname}"'})
371
+
372
+
373
+ @app.get("/api/palettes")
374
+ def palettes():
375
+ return {"palettes": canvas.get_palettes()}
376
+
377
+
378
+ app.mount("/static", StaticFiles(directory=os.path.join(HERE, "static")), name="static")