figrecipe 0.7.4__py3-none-any.whl → 0.9.0__py3-none-any.whl
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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +2 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
File management routes for the figure editor.
|
|
5
|
+
Handles file listing, switching, creation, deletion, renaming, duplication, and download.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from flask import jsonify, request, send_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_file_routes(app, editor):
|
|
12
|
+
"""Register file management routes with the Flask app."""
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ._helpers import render_with_overrides
|
|
16
|
+
from ._hitmap import generate_hitmap, hitmap_to_base64
|
|
17
|
+
|
|
18
|
+
@app.route("/api/files")
|
|
19
|
+
def list_files():
|
|
20
|
+
"""List available recipe files in working directory as a tree structure."""
|
|
21
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
22
|
+
|
|
23
|
+
def build_tree(directory: Path, relative_base: Path = None) -> list:
|
|
24
|
+
"""Recursively build tree structure from directory."""
|
|
25
|
+
if relative_base is None:
|
|
26
|
+
relative_base = directory
|
|
27
|
+
|
|
28
|
+
items = []
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
entries = sorted(
|
|
32
|
+
directory.iterdir(),
|
|
33
|
+
key=lambda x: (not x.is_dir(), x.name.lower()),
|
|
34
|
+
)
|
|
35
|
+
except PermissionError:
|
|
36
|
+
return items
|
|
37
|
+
|
|
38
|
+
for entry in entries:
|
|
39
|
+
if entry.name.startswith("."):
|
|
40
|
+
continue
|
|
41
|
+
if entry.name.endswith(".overrides.yaml"):
|
|
42
|
+
continue
|
|
43
|
+
if entry.name == "__pycache__":
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
rel_path = str(entry.relative_to(relative_base))
|
|
47
|
+
|
|
48
|
+
if entry.is_dir():
|
|
49
|
+
children = build_tree(entry, relative_base)
|
|
50
|
+
if children:
|
|
51
|
+
items.append(
|
|
52
|
+
{
|
|
53
|
+
"path": rel_path,
|
|
54
|
+
"name": entry.name,
|
|
55
|
+
"type": "directory",
|
|
56
|
+
"children": children,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
elif entry.suffix.lower() in (".yaml", ".yml"):
|
|
60
|
+
png_path = entry.with_suffix(".png")
|
|
61
|
+
has_png = png_path.exists()
|
|
62
|
+
|
|
63
|
+
items.append(
|
|
64
|
+
{
|
|
65
|
+
"path": rel_path,
|
|
66
|
+
"name": entry.stem,
|
|
67
|
+
"type": "file",
|
|
68
|
+
"has_image": has_png,
|
|
69
|
+
"is_current": (
|
|
70
|
+
editor.recipe_path
|
|
71
|
+
and entry.resolve() == editor.recipe_path.resolve()
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return items
|
|
77
|
+
|
|
78
|
+
tree = build_tree(working_dir)
|
|
79
|
+
|
|
80
|
+
flat_files = []
|
|
81
|
+
|
|
82
|
+
def flatten_tree(items):
|
|
83
|
+
for item in items:
|
|
84
|
+
if item["type"] == "directory":
|
|
85
|
+
flatten_tree(item.get("children", []))
|
|
86
|
+
else:
|
|
87
|
+
flat_files.append(item)
|
|
88
|
+
|
|
89
|
+
flatten_tree(tree)
|
|
90
|
+
|
|
91
|
+
return jsonify(
|
|
92
|
+
{
|
|
93
|
+
"tree": tree,
|
|
94
|
+
"files": flat_files,
|
|
95
|
+
"working_dir": str(working_dir),
|
|
96
|
+
"current_file": (
|
|
97
|
+
str(editor.recipe_path.name) if editor.recipe_path else None
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@app.route("/api/switch", methods=["POST"])
|
|
103
|
+
def switch_file():
|
|
104
|
+
"""Switch to a different recipe file."""
|
|
105
|
+
from .._reproducer import reproduce
|
|
106
|
+
from .._wrappers._figure import RecordingFigure
|
|
107
|
+
|
|
108
|
+
data = request.get_json() or {}
|
|
109
|
+
file_path = data.get("path")
|
|
110
|
+
|
|
111
|
+
if not file_path:
|
|
112
|
+
return jsonify({"error": "No file path provided"}), 400
|
|
113
|
+
|
|
114
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
115
|
+
full_path = working_dir / file_path
|
|
116
|
+
|
|
117
|
+
if not full_path.exists():
|
|
118
|
+
return jsonify({"error": f"File not found: {file_path}"}), 404
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
fig, axes = reproduce(full_path)
|
|
122
|
+
|
|
123
|
+
if not isinstance(fig, RecordingFigure):
|
|
124
|
+
from .._recorder import FigureRecord, Recorder
|
|
125
|
+
|
|
126
|
+
wrapped_fig = RecordingFigure.__new__(RecordingFigure)
|
|
127
|
+
wrapped_fig._fig = fig
|
|
128
|
+
wrapped_fig._axes = [[ax] for ax in fig.axes]
|
|
129
|
+
wrapped_fig._recorder = Recorder()
|
|
130
|
+
wrapped_fig._recorder._figure_record = FigureRecord(
|
|
131
|
+
figsize=tuple(fig.get_size_inches()),
|
|
132
|
+
dpi=int(fig.dpi),
|
|
133
|
+
)
|
|
134
|
+
fig = wrapped_fig
|
|
135
|
+
|
|
136
|
+
editor.fig = fig
|
|
137
|
+
editor.recipe_path = full_path
|
|
138
|
+
editor._hitmap_generated = False
|
|
139
|
+
editor._color_map = {}
|
|
140
|
+
|
|
141
|
+
editor._init_style_overrides(None)
|
|
142
|
+
|
|
143
|
+
hitmap_img, editor._color_map = generate_hitmap(editor.fig)
|
|
144
|
+
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
145
|
+
editor._hitmap_generated = True
|
|
146
|
+
|
|
147
|
+
base64_img, bboxes, img_size = render_with_overrides(
|
|
148
|
+
editor.fig,
|
|
149
|
+
editor.get_effective_style(),
|
|
150
|
+
editor.dark_mode,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return jsonify(
|
|
154
|
+
{
|
|
155
|
+
"success": True,
|
|
156
|
+
"image": base64_img,
|
|
157
|
+
"bboxes": bboxes,
|
|
158
|
+
"color_map": editor._color_map,
|
|
159
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
160
|
+
"file": file_path,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return jsonify({"error": str(e)}), 500
|
|
166
|
+
|
|
167
|
+
@app.route("/api/new", methods=["POST"])
|
|
168
|
+
def new_figure():
|
|
169
|
+
"""Create a new blank figure and save it as a physical file."""
|
|
170
|
+
from .. import reproduce, save, subplots
|
|
171
|
+
from .._wrappers._figure import RecordingFigure
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
fig, ax = subplots()
|
|
175
|
+
ax.set_title("New Figure")
|
|
176
|
+
|
|
177
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
178
|
+
base_name = "new_figure"
|
|
179
|
+
counter = 1
|
|
180
|
+
while True:
|
|
181
|
+
file_path = working_dir / f"{base_name}_{counter:03d}.yaml"
|
|
182
|
+
if not file_path.exists():
|
|
183
|
+
break
|
|
184
|
+
counter += 1
|
|
185
|
+
|
|
186
|
+
png_path = file_path.with_suffix(".png")
|
|
187
|
+
save(fig, png_path, validate=False, verbose=False)
|
|
188
|
+
|
|
189
|
+
reproduced_fig, axes = reproduce(file_path)
|
|
190
|
+
|
|
191
|
+
if not isinstance(reproduced_fig, RecordingFigure):
|
|
192
|
+
from .._recorder import FigureRecord, Recorder
|
|
193
|
+
|
|
194
|
+
wrapped_fig = RecordingFigure.__new__(RecordingFigure)
|
|
195
|
+
wrapped_fig._fig = reproduced_fig
|
|
196
|
+
wrapped_fig._axes = (
|
|
197
|
+
[[ax] for ax in reproduced_fig.axes]
|
|
198
|
+
if hasattr(reproduced_fig, "axes")
|
|
199
|
+
else [[axes]]
|
|
200
|
+
)
|
|
201
|
+
wrapped_fig._recorder = Recorder()
|
|
202
|
+
wrapped_fig._recorder._figure_record = FigureRecord(
|
|
203
|
+
figsize=tuple(reproduced_fig.get_size_inches()),
|
|
204
|
+
dpi=int(reproduced_fig.dpi),
|
|
205
|
+
)
|
|
206
|
+
reproduced_fig = wrapped_fig
|
|
207
|
+
|
|
208
|
+
editor.fig = reproduced_fig
|
|
209
|
+
editor.recipe_path = file_path
|
|
210
|
+
editor._hitmap_generated = False
|
|
211
|
+
editor._color_map = {}
|
|
212
|
+
|
|
213
|
+
editor._init_style_overrides(None)
|
|
214
|
+
|
|
215
|
+
hitmap_img, editor._color_map = generate_hitmap(editor.fig)
|
|
216
|
+
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
217
|
+
editor._hitmap_generated = True
|
|
218
|
+
|
|
219
|
+
base64_img, bboxes, img_size = render_with_overrides(
|
|
220
|
+
editor.fig,
|
|
221
|
+
editor.get_effective_style(),
|
|
222
|
+
editor.dark_mode,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return jsonify(
|
|
226
|
+
{
|
|
227
|
+
"success": True,
|
|
228
|
+
"image": base64_img,
|
|
229
|
+
"bboxes": bboxes,
|
|
230
|
+
"color_map": editor._color_map,
|
|
231
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
232
|
+
"file": str(file_path.relative_to(working_dir)),
|
|
233
|
+
"file_name": file_path.stem,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
import traceback
|
|
239
|
+
|
|
240
|
+
traceback.print_exc()
|
|
241
|
+
return jsonify({"error": str(e)}), 500
|
|
242
|
+
|
|
243
|
+
@app.route("/api/delete", methods=["POST"])
|
|
244
|
+
def delete_figure():
|
|
245
|
+
"""Delete a figure file and its associated files."""
|
|
246
|
+
data = request.get_json() or {}
|
|
247
|
+
file_path = data.get("path")
|
|
248
|
+
|
|
249
|
+
if not file_path:
|
|
250
|
+
return jsonify({"error": "No file path provided"}), 400
|
|
251
|
+
|
|
252
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
253
|
+
full_path = working_dir / file_path
|
|
254
|
+
|
|
255
|
+
if full_path.suffix.lower() in (".yaml", ".yml"):
|
|
256
|
+
base_path = full_path.with_suffix("")
|
|
257
|
+
elif full_path.suffix.lower() == ".png":
|
|
258
|
+
base_path = full_path.with_suffix("")
|
|
259
|
+
else:
|
|
260
|
+
return jsonify({"error": "Invalid file type"}), 400
|
|
261
|
+
|
|
262
|
+
is_current = editor.recipe_path and base_path == editor.recipe_path.with_suffix(
|
|
263
|
+
""
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
deleted_files = []
|
|
267
|
+
errors = []
|
|
268
|
+
|
|
269
|
+
for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
|
|
270
|
+
target = base_path.with_suffix(ext)
|
|
271
|
+
if target.exists():
|
|
272
|
+
try:
|
|
273
|
+
target.unlink()
|
|
274
|
+
deleted_files.append(target.name)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
errors.append(f"{target.name}: {e}")
|
|
277
|
+
|
|
278
|
+
if not deleted_files:
|
|
279
|
+
return jsonify({"error": "No files found to delete"}), 404
|
|
280
|
+
|
|
281
|
+
switch_to = None
|
|
282
|
+
if is_current:
|
|
283
|
+
for pattern in ["*.yaml", "*.yml"]:
|
|
284
|
+
for f in working_dir.glob(pattern):
|
|
285
|
+
if not f.name.startswith(".") and not f.name.endswith(
|
|
286
|
+
".overrides.yaml"
|
|
287
|
+
):
|
|
288
|
+
switch_to = str(f.relative_to(working_dir))
|
|
289
|
+
break
|
|
290
|
+
if switch_to:
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
return jsonify(
|
|
294
|
+
{
|
|
295
|
+
"success": True,
|
|
296
|
+
"deleted": deleted_files,
|
|
297
|
+
"errors": errors if errors else None,
|
|
298
|
+
"was_current": is_current,
|
|
299
|
+
"switch_to": switch_to,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@app.route("/api/rename", methods=["POST"])
|
|
304
|
+
def rename_figure():
|
|
305
|
+
"""Rename a figure file and its associated files."""
|
|
306
|
+
data = request.get_json() or {}
|
|
307
|
+
old_path = data.get("path")
|
|
308
|
+
new_name = data.get("new_name")
|
|
309
|
+
|
|
310
|
+
if not old_path:
|
|
311
|
+
return jsonify({"error": "No file path provided"}), 400
|
|
312
|
+
if not new_name:
|
|
313
|
+
return jsonify({"error": "No new name provided"}), 400
|
|
314
|
+
|
|
315
|
+
new_name = Path(new_name).stem
|
|
316
|
+
if not new_name or "/" in new_name or "\\" in new_name:
|
|
317
|
+
return jsonify({"error": "Invalid new name"}), 400
|
|
318
|
+
|
|
319
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
320
|
+
full_path = working_dir / old_path
|
|
321
|
+
|
|
322
|
+
if full_path.suffix.lower() in (".yaml", ".yml"):
|
|
323
|
+
old_base = full_path.with_suffix("")
|
|
324
|
+
elif full_path.suffix.lower() == ".png":
|
|
325
|
+
old_base = full_path.with_suffix("")
|
|
326
|
+
else:
|
|
327
|
+
return jsonify({"error": "Invalid file type"}), 400
|
|
328
|
+
|
|
329
|
+
new_base = working_dir / new_name
|
|
330
|
+
|
|
331
|
+
for ext in [".yaml", ".png"]:
|
|
332
|
+
if new_base.with_suffix(ext).exists():
|
|
333
|
+
return jsonify({"error": f"File {new_name}{ext} already exists"}), 400
|
|
334
|
+
|
|
335
|
+
renamed_files = []
|
|
336
|
+
errors = []
|
|
337
|
+
|
|
338
|
+
for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
|
|
339
|
+
old_file = old_base.with_suffix(ext)
|
|
340
|
+
new_file = new_base.with_suffix(ext)
|
|
341
|
+
if old_file.exists():
|
|
342
|
+
try:
|
|
343
|
+
old_file.rename(new_file)
|
|
344
|
+
renamed_files.append({"from": old_file.name, "to": new_file.name})
|
|
345
|
+
except Exception as e:
|
|
346
|
+
errors.append(f"{old_file.name}: {e}")
|
|
347
|
+
|
|
348
|
+
if not renamed_files:
|
|
349
|
+
return jsonify({"error": "No files found to rename"}), 404
|
|
350
|
+
|
|
351
|
+
if editor.recipe_path and old_base == editor.recipe_path.with_suffix(""):
|
|
352
|
+
editor.recipe_path = new_base.with_suffix(".yaml")
|
|
353
|
+
|
|
354
|
+
return jsonify(
|
|
355
|
+
{
|
|
356
|
+
"success": True,
|
|
357
|
+
"renamed": renamed_files,
|
|
358
|
+
"new_name": new_name,
|
|
359
|
+
"errors": errors if errors else None,
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@app.route("/api/duplicate", methods=["POST"])
|
|
364
|
+
def duplicate_figure():
|
|
365
|
+
"""Duplicate a figure file and its associated files."""
|
|
366
|
+
import shutil
|
|
367
|
+
|
|
368
|
+
data = request.get_json() or {}
|
|
369
|
+
file_path = data.get("path")
|
|
370
|
+
|
|
371
|
+
if not file_path:
|
|
372
|
+
return jsonify({"error": "No file path provided"}), 400
|
|
373
|
+
|
|
374
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
375
|
+
full_path = working_dir / file_path
|
|
376
|
+
|
|
377
|
+
if full_path.suffix.lower() in (".yaml", ".yml"):
|
|
378
|
+
base_path = full_path.with_suffix("")
|
|
379
|
+
elif full_path.suffix.lower() == ".png":
|
|
380
|
+
base_path = full_path.with_suffix("")
|
|
381
|
+
else:
|
|
382
|
+
return jsonify({"error": "Invalid file type"}), 400
|
|
383
|
+
|
|
384
|
+
original_name = base_path.stem
|
|
385
|
+
counter = 1
|
|
386
|
+
while True:
|
|
387
|
+
new_name = f"{original_name}_copy_{counter:02d}"
|
|
388
|
+
new_base = base_path.parent / new_name
|
|
389
|
+
if not new_base.with_suffix(".yaml").exists():
|
|
390
|
+
break
|
|
391
|
+
counter += 1
|
|
392
|
+
|
|
393
|
+
copied_files = []
|
|
394
|
+
errors = []
|
|
395
|
+
|
|
396
|
+
for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
|
|
397
|
+
old_file = base_path.with_suffix(ext)
|
|
398
|
+
new_file = new_base.with_suffix(ext)
|
|
399
|
+
if old_file.exists():
|
|
400
|
+
try:
|
|
401
|
+
shutil.copy2(old_file, new_file)
|
|
402
|
+
copied_files.append({"from": old_file.name, "to": new_file.name})
|
|
403
|
+
except Exception as e:
|
|
404
|
+
errors.append(f"{old_file.name}: {e}")
|
|
405
|
+
|
|
406
|
+
if not copied_files:
|
|
407
|
+
return jsonify({"error": "No files found to duplicate"}), 404
|
|
408
|
+
|
|
409
|
+
return jsonify(
|
|
410
|
+
{
|
|
411
|
+
"success": True,
|
|
412
|
+
"copied": copied_files,
|
|
413
|
+
"new_name": new_name,
|
|
414
|
+
"new_path": str(new_base.with_suffix(".yaml").relative_to(working_dir)),
|
|
415
|
+
"errors": errors if errors else None,
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
@app.route("/api/download")
|
|
420
|
+
def download_figure():
|
|
421
|
+
"""Download a figure file (YAML recipe)."""
|
|
422
|
+
file_path = request.args.get("path")
|
|
423
|
+
|
|
424
|
+
if not file_path:
|
|
425
|
+
return jsonify({"error": "No file path provided"}), 400
|
|
426
|
+
|
|
427
|
+
working_dir = getattr(editor, "working_dir", Path.cwd())
|
|
428
|
+
full_path = working_dir / file_path
|
|
429
|
+
|
|
430
|
+
if full_path.suffix.lower() not in (".yaml", ".yml"):
|
|
431
|
+
yaml_path = full_path.with_suffix(".yaml")
|
|
432
|
+
if yaml_path.exists():
|
|
433
|
+
full_path = yaml_path
|
|
434
|
+
else:
|
|
435
|
+
return jsonify({"error": "File not found"}), 404
|
|
436
|
+
|
|
437
|
+
if not full_path.exists():
|
|
438
|
+
return jsonify({"error": "File not found"}), 404
|
|
439
|
+
|
|
440
|
+
return send_file(full_path, as_attachment=True, download_name=full_path.name)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = ["register_file_routes"]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Image drop Flask route handlers for the figure editor.
|
|
4
|
+
|
|
5
|
+
Handles drag & drop of external images to create imshow panels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from flask import jsonify, request
|
|
14
|
+
from PIL import Image
|
|
15
|
+
|
|
16
|
+
from ._helpers import render_with_overrides
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y):
|
|
20
|
+
"""Add an image panel using the figrecipe recording system.
|
|
21
|
+
|
|
22
|
+
This ensures dropped images become proper panels (C, D, E...) that
|
|
23
|
+
integrate with the existing coordinate system and pipeline.
|
|
24
|
+
"""
|
|
25
|
+
from .._wrappers._axes import RecordingAxes
|
|
26
|
+
|
|
27
|
+
# Get the underlying matplotlib figure
|
|
28
|
+
mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
|
|
29
|
+
|
|
30
|
+
# Calculate panel position based on drop location
|
|
31
|
+
# Default size: 40% of figure in each dimension
|
|
32
|
+
panel_width = 0.4
|
|
33
|
+
panel_height = 0.4
|
|
34
|
+
|
|
35
|
+
# Convert drop position to axes position (bottom-left origin for matplotlib)
|
|
36
|
+
left = max(0.05, min(0.55, drop_x - panel_width / 2))
|
|
37
|
+
bottom = max(0.05, min(0.55, (1 - drop_y) - panel_height / 2))
|
|
38
|
+
|
|
39
|
+
# Add new axes at the drop position
|
|
40
|
+
mpl_ax = mpl_fig.add_axes([left, bottom, panel_width, panel_height])
|
|
41
|
+
|
|
42
|
+
# If we have a RecordingFigure with recorder, wrap the axes properly
|
|
43
|
+
if hasattr(editor.fig, "_recorder"):
|
|
44
|
+
recorder = editor.fig._recorder
|
|
45
|
+
# Determine position index for the new panel
|
|
46
|
+
existing_axes = len(mpl_fig.get_axes()) - 1 # -1 because we just added one
|
|
47
|
+
position = (existing_axes, 0) # Simple sequential positioning
|
|
48
|
+
|
|
49
|
+
# Create RecordingAxes wrapper
|
|
50
|
+
wrapped_ax = RecordingAxes(mpl_ax, recorder, position=position)
|
|
51
|
+
|
|
52
|
+
# Call imshow through wrapper so it gets recorded
|
|
53
|
+
wrapped_ax.imshow(img_array, id=f"dropped_{filename[:15]}")
|
|
54
|
+
wrapped_ax.set_title(filename[:20])
|
|
55
|
+
wrapped_ax.axis("off")
|
|
56
|
+
|
|
57
|
+
# Add to figure's axes list
|
|
58
|
+
if hasattr(editor.fig, "_axes"):
|
|
59
|
+
# Append as a new row
|
|
60
|
+
editor.fig._axes.append([wrapped_ax])
|
|
61
|
+
else:
|
|
62
|
+
# Fallback: raw matplotlib (no recording)
|
|
63
|
+
mpl_ax.imshow(img_array)
|
|
64
|
+
mpl_ax.set_title(filename[:20])
|
|
65
|
+
mpl_ax.axis("off")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_and_update_hitmap(editor):
|
|
69
|
+
"""Re-render figure and regenerate hitmap after modification.
|
|
70
|
+
|
|
71
|
+
Returns JSON-serializable response dict with image, bboxes, and size.
|
|
72
|
+
"""
|
|
73
|
+
from ._hitmap import generate_hitmap, hitmap_to_base64
|
|
74
|
+
|
|
75
|
+
base64_img, bboxes, img_size = render_with_overrides(
|
|
76
|
+
editor.fig,
|
|
77
|
+
editor.get_effective_style(),
|
|
78
|
+
editor.dark_mode,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
hitmap_img, color_map = generate_hitmap(editor.fig, dpi=150)
|
|
82
|
+
editor._color_map = color_map
|
|
83
|
+
editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
|
|
84
|
+
editor._hitmap_generated = True
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"success": True,
|
|
88
|
+
"image": base64_img,
|
|
89
|
+
"bboxes": bboxes,
|
|
90
|
+
"img_size": {"width": img_size[0], "height": img_size[1]},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def register_image_routes(app, editor):
|
|
95
|
+
"""Register image drop routes with the Flask app."""
|
|
96
|
+
|
|
97
|
+
@app.route("/add_image_panel", methods=["POST"])
|
|
98
|
+
def add_image_panel():
|
|
99
|
+
"""Add a new panel with an imshow of the dropped image.
|
|
100
|
+
|
|
101
|
+
Expects JSON: {image_data: base64, filename: str, drop_x: float, drop_y: float}
|
|
102
|
+
drop_x, drop_y are normalized (0-1) positions where the image was dropped.
|
|
103
|
+
"""
|
|
104
|
+
data = request.get_json() or {}
|
|
105
|
+
image_data = data.get("image_data")
|
|
106
|
+
filename = data.get("filename", "dropped_image")
|
|
107
|
+
drop_x = data.get("drop_x", 0.5)
|
|
108
|
+
drop_y = data.get("drop_y", 0.5)
|
|
109
|
+
|
|
110
|
+
if not image_data:
|
|
111
|
+
return jsonify({"error": "Missing image_data"}), 400
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
image_bytes = base64.b64decode(image_data)
|
|
115
|
+
img = Image.open(io.BytesIO(image_bytes))
|
|
116
|
+
img_array = np.array(img)
|
|
117
|
+
|
|
118
|
+
_add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y)
|
|
119
|
+
return jsonify(_render_and_update_hitmap(editor))
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
import traceback
|
|
123
|
+
|
|
124
|
+
traceback.print_exc()
|
|
125
|
+
return jsonify({"error": f"Failed to add image: {str(e)}"}), 500
|
|
126
|
+
|
|
127
|
+
@app.route("/add_image_from_url", methods=["POST"])
|
|
128
|
+
def add_image_from_url():
|
|
129
|
+
"""Add a new panel with an imshow from a URL.
|
|
130
|
+
|
|
131
|
+
Expects JSON: {url: str, drop_x: float, drop_y: float}
|
|
132
|
+
"""
|
|
133
|
+
data = request.get_json() or {}
|
|
134
|
+
url = data.get("url")
|
|
135
|
+
drop_x = data.get("drop_x", 0.5)
|
|
136
|
+
drop_y = data.get("drop_y", 0.5)
|
|
137
|
+
|
|
138
|
+
if not url:
|
|
139
|
+
return jsonify({"error": "Missing url"}), 400
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
143
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
144
|
+
image_bytes = response.read()
|
|
145
|
+
|
|
146
|
+
img = Image.open(io.BytesIO(image_bytes))
|
|
147
|
+
img_array = np.array(img)
|
|
148
|
+
filename = url.split("/")[-1].split("?")[0][:20]
|
|
149
|
+
|
|
150
|
+
_add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y)
|
|
151
|
+
return jsonify(_render_and_update_hitmap(editor))
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
import traceback
|
|
155
|
+
|
|
156
|
+
traceback.print_exc()
|
|
157
|
+
return jsonify({"error": f"Failed to add image from URL: {str(e)}"}), 500
|
|
158
|
+
|
|
159
|
+
@app.route("/load_recipe", methods=["POST"])
|
|
160
|
+
def load_recipe():
|
|
161
|
+
"""Load a recipe file dropped onto the editor.
|
|
162
|
+
|
|
163
|
+
Expects JSON: {recipe_content: str, filename: str}
|
|
164
|
+
"""
|
|
165
|
+
data = request.get_json() or {}
|
|
166
|
+
recipe_content = data.get("recipe_content")
|
|
167
|
+
filename = data.get("filename", "recipe.yaml")
|
|
168
|
+
|
|
169
|
+
if not recipe_content:
|
|
170
|
+
return jsonify({"error": "Missing recipe_content"}), 400
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
import tempfile
|
|
174
|
+
|
|
175
|
+
import figrecipe as fr
|
|
176
|
+
|
|
177
|
+
# Write recipe to temp file
|
|
178
|
+
with tempfile.NamedTemporaryFile(
|
|
179
|
+
mode="w", suffix=".yaml", delete=False
|
|
180
|
+
) as f:
|
|
181
|
+
f.write(recipe_content)
|
|
182
|
+
temp_path = f.name
|
|
183
|
+
|
|
184
|
+
# Reproduce figure from recipe
|
|
185
|
+
fig, axes = fr.reproduce(temp_path)
|
|
186
|
+
|
|
187
|
+
# Update editor's figure
|
|
188
|
+
editor.fig = fig
|
|
189
|
+
editor._hitmap_generated = False
|
|
190
|
+
|
|
191
|
+
return jsonify({"success": True, "message": f"Loaded {filename}"})
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
import traceback
|
|
195
|
+
|
|
196
|
+
traceback.print_exc()
|
|
197
|
+
return jsonify({"error": f"Failed to load recipe: {str(e)}"}), 500
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = ["register_image_routes"]
|