scitex 2.15.1__py3-none-any.whl → 2.15.2__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.
- scitex/__init__.py +68 -61
- scitex/_mcp_tools/introspect.py +42 -23
- scitex/_mcp_tools/template.py +24 -0
- scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +30 -1550
- scitex/ai/classification/timeseries/_sliding_window_core.py +467 -0
- scitex/ai/classification/timeseries/_sliding_window_plotting.py +369 -0
- scitex/audio/__init__.py +2 -2
- scitex/audio/_tts.py +18 -10
- scitex/audio/engines/base.py +17 -10
- scitex/audio/engines/elevenlabs_engine.py +1 -1
- scitex/canvas/editor/flask_editor/_core/__init__.py +27 -0
- scitex/canvas/editor/flask_editor/_core/_bbox_extraction.py +200 -0
- scitex/canvas/editor/flask_editor/_core/_editor.py +173 -0
- scitex/canvas/editor/flask_editor/_core/_export_helpers.py +353 -0
- scitex/canvas/editor/flask_editor/_core/_routes_basic.py +190 -0
- scitex/canvas/editor/flask_editor/_core/_routes_export.py +332 -0
- scitex/canvas/editor/flask_editor/_core/_routes_panels.py +252 -0
- scitex/canvas/editor/flask_editor/_core/_routes_save.py +218 -0
- scitex/canvas/editor/flask_editor/_core.py +25 -1684
- scitex/cli/introspect.py +112 -74
- scitex/cli/main.py +2 -0
- scitex/cli/plt.py +357 -0
- scitex/cli/repro.py +15 -8
- scitex/cli/resource.py +15 -8
- scitex/cli/scholar/__init__.py +15 -8
- scitex/cli/social.py +6 -6
- scitex/cli/stats.py +15 -8
- scitex/cli/template.py +129 -12
- scitex/cli/tex.py +15 -8
- scitex/cli/writer.py +15 -8
- scitex/cloud/__init__.py +41 -2
- scitex/config/_env_registry.py +84 -19
- scitex/context/__init__.py +22 -0
- scitex/dev/__init__.py +20 -1
- scitex/gen/__init__.py +50 -14
- scitex/gen/_list_packages.py +4 -4
- scitex/introspect/__init__.py +16 -9
- scitex/introspect/_core.py +7 -8
- scitex/{gen/_inspect_module.py → introspect/_list_api.py} +43 -54
- scitex/introspect/_mcp/__init__.py +10 -6
- scitex/introspect/_mcp/handlers.py +37 -12
- scitex/introspect/_members.py +7 -3
- scitex/introspect/_signature.py +3 -3
- scitex/introspect/_source.py +2 -2
- scitex/io/_save.py +1 -2
- scitex/logging/_formatters.py +19 -9
- scitex/mcp_server.py +1 -1
- scitex/os/__init__.py +4 -0
- scitex/{gen → os}/_check_host.py +4 -5
- scitex/plt/__init__.py +11 -14
- scitex/session/__init__.py +26 -7
- scitex/session/_decorator.py +1 -1
- scitex/sh/__init__.py +7 -4
- scitex/social/__init__.py +10 -8
- scitex/stats/_mcp/_handlers/__init__.py +31 -0
- scitex/stats/_mcp/_handlers/_corrections.py +113 -0
- scitex/stats/_mcp/_handlers/_descriptive.py +78 -0
- scitex/stats/_mcp/_handlers/_effect_size.py +106 -0
- scitex/stats/_mcp/_handlers/_format.py +94 -0
- scitex/stats/_mcp/_handlers/_normality.py +110 -0
- scitex/stats/_mcp/_handlers/_posthoc.py +224 -0
- scitex/stats/_mcp/_handlers/_power.py +247 -0
- scitex/stats/_mcp/_handlers/_recommend.py +102 -0
- scitex/stats/_mcp/_handlers/_run_test.py +279 -0
- scitex/stats/_mcp/_handlers/_stars.py +48 -0
- scitex/stats/_mcp/handlers.py +19 -1171
- scitex/stats/auto/_stat_style.py +175 -0
- scitex/stats/auto/_style_definitions.py +411 -0
- scitex/stats/auto/_styles.py +22 -620
- scitex/stats/descriptive/__init__.py +11 -8
- scitex/stats/descriptive/_ci.py +39 -0
- scitex/stats/power/_power.py +15 -4
- scitex/str/__init__.py +2 -1
- scitex/str/_title_case.py +63 -0
- scitex/template/__init__.py +25 -10
- scitex/template/_code_templates.py +147 -0
- scitex/template/_mcp/handlers.py +81 -0
- scitex/template/_mcp/tool_schemas.py +55 -0
- scitex/template/_templates/__init__.py +51 -0
- scitex/template/_templates/audio.py +233 -0
- scitex/template/_templates/canvas.py +312 -0
- scitex/template/_templates/capture.py +268 -0
- scitex/template/_templates/config.py +43 -0
- scitex/template/_templates/diagram.py +294 -0
- scitex/template/_templates/io.py +107 -0
- scitex/template/_templates/module.py +53 -0
- scitex/template/_templates/plt.py +202 -0
- scitex/template/_templates/scholar.py +267 -0
- scitex/template/_templates/session.py +130 -0
- scitex/template/_templates/session_minimal.py +43 -0
- scitex/template/_templates/session_plot.py +67 -0
- scitex/template/_templates/session_stats.py +77 -0
- scitex/template/_templates/stats.py +323 -0
- scitex/template/_templates/writer.py +296 -0
- scitex/ui/_backends/_email.py +10 -2
- scitex/ui/_backends/_webhook.py +5 -1
- scitex/web/_search_pubmed.py +10 -6
- {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/METADATA +1 -1
- {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/RECORD +105 -64
- scitex/gen/_ci.py +0 -12
- scitex/gen/_title_case.py +0 -89
- /scitex/{gen → context}/_detect_environment.py +0 -0
- /scitex/{gen → context}/_get_notebook_path.py +0 -0
- /scitex/{gen/_shell.py → sh/_shell_legacy.py} +0 -0
- {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/WHEEL +0 -0
- {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/entry_points.txt +0 -0
- {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,1688 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
#
|
|
3
|
-
# File:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- SciTeX style defaults pre-filled
|
|
28
|
-
- Auto-finds available port if default is in use
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def __init__(
|
|
32
|
-
self,
|
|
33
|
-
json_path: Path,
|
|
34
|
-
metadata: Dict[str, Any],
|
|
35
|
-
csv_data: Optional[Any] = None,
|
|
36
|
-
png_path: Optional[Path] = None,
|
|
37
|
-
hitmap_path: Optional[Path] = None,
|
|
38
|
-
manual_overrides: Optional[Dict[str, Any]] = None,
|
|
39
|
-
port: int = 5050,
|
|
40
|
-
panel_info: Optional[Dict[str, Any]] = None,
|
|
41
|
-
):
|
|
42
|
-
self.json_path = Path(json_path)
|
|
43
|
-
self.metadata = metadata
|
|
44
|
-
self.csv_data = csv_data
|
|
45
|
-
self.png_path = Path(png_path) if png_path else None
|
|
46
|
-
self.hitmap_path = Path(hitmap_path) if hitmap_path else None
|
|
47
|
-
self.manual_overrides = manual_overrides or {}
|
|
48
|
-
self._requested_port = port
|
|
49
|
-
self.port = port
|
|
50
|
-
self.panel_info = panel_info # For multi-panel figure bundles
|
|
51
|
-
|
|
52
|
-
# Extract hit_regions from metadata for color-based element detection
|
|
53
|
-
self.hit_regions = metadata.get("hit_regions", {})
|
|
54
|
-
self.color_map = self.hit_regions.get("color_map", {})
|
|
55
|
-
|
|
56
|
-
# Get SciTeX defaults and merge with metadata
|
|
57
|
-
from .._defaults import extract_defaults_from_metadata, get_scitex_defaults
|
|
58
|
-
|
|
59
|
-
self.scitex_defaults = get_scitex_defaults()
|
|
60
|
-
self.metadata_defaults = extract_defaults_from_metadata(metadata)
|
|
61
|
-
|
|
62
|
-
# Start with defaults, then overlay manual overrides
|
|
63
|
-
self.current_overrides = copy.deepcopy(self.scitex_defaults)
|
|
64
|
-
self.current_overrides.update(self.metadata_defaults)
|
|
65
|
-
self.current_overrides.update(self.manual_overrides)
|
|
66
|
-
|
|
67
|
-
# Track initial state to detect modifications
|
|
68
|
-
self._initial_overrides = copy.deepcopy(self.current_overrides)
|
|
69
|
-
self._user_modified = False
|
|
70
|
-
|
|
71
|
-
def run(self):
|
|
72
|
-
"""Launch the web editor."""
|
|
73
|
-
try:
|
|
74
|
-
from flask import Flask, jsonify, render_template_string, request
|
|
75
|
-
except ImportError:
|
|
76
|
-
raise ImportError(
|
|
77
|
-
"Flask is required for web editor. Install: pip install flask"
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
# Handle port conflicts - always use port 5050
|
|
81
|
-
import time
|
|
82
|
-
|
|
83
|
-
max_retries = 3
|
|
84
|
-
for attempt in range(max_retries):
|
|
85
|
-
if check_port_available(self._requested_port):
|
|
86
|
-
self.port = self._requested_port
|
|
87
|
-
break
|
|
88
|
-
print(
|
|
89
|
-
f"Port {self._requested_port} in use. Freeing... (attempt {attempt + 1}/{max_retries})"
|
|
90
|
-
)
|
|
91
|
-
kill_process_on_port(self._requested_port)
|
|
92
|
-
time.sleep(1.0) # Wait for port release
|
|
93
|
-
else:
|
|
94
|
-
# After retries, use requested port anyway (Flask will error if unavailable)
|
|
95
|
-
print(f"Warning: Port {self._requested_port} may still be in use")
|
|
96
|
-
self.port = self._requested_port
|
|
97
|
-
|
|
98
|
-
# Configure Flask with static folder path
|
|
99
|
-
import os
|
|
100
|
-
|
|
101
|
-
static_folder = os.path.join(os.path.dirname(__file__), "static")
|
|
102
|
-
app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
|
|
103
|
-
editor = self
|
|
104
|
-
|
|
105
|
-
def _export_composed_figure(editor, formats=["png", "svg"], dpi=150):
|
|
106
|
-
"""Helper to compose and export figure to bundle."""
|
|
107
|
-
import matplotlib
|
|
108
|
-
import numpy as np
|
|
109
|
-
from PIL import Image
|
|
110
|
-
|
|
111
|
-
from scitex.io import ZipBundle
|
|
112
|
-
|
|
113
|
-
matplotlib.use("Agg")
|
|
114
|
-
import io
|
|
115
|
-
import json as json_module
|
|
116
|
-
import zipfile
|
|
117
|
-
|
|
118
|
-
import matplotlib.pyplot as plt
|
|
119
|
-
|
|
120
|
-
if not editor.panel_info:
|
|
121
|
-
return {"success": False, "error": "No panel info"}
|
|
122
|
-
|
|
123
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
124
|
-
figure_dir = editor.panel_info.get("figure_dir")
|
|
125
|
-
|
|
126
|
-
if not bundle_path and not figure_dir:
|
|
127
|
-
return {"success": False, "error": "No bundle path"}
|
|
128
|
-
|
|
129
|
-
figure_name = (
|
|
130
|
-
Path(bundle_path).stem
|
|
131
|
-
if bundle_path
|
|
132
|
-
else (
|
|
133
|
-
Path(figure_dir).stem.replace(".figure", "")
|
|
134
|
-
if figure_dir
|
|
135
|
-
else "figure"
|
|
136
|
-
)
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
# Read spec.json for layout and layout.json for position overrides
|
|
140
|
-
spec = {}
|
|
141
|
-
layout_overrides = {}
|
|
142
|
-
if bundle_path:
|
|
143
|
-
try:
|
|
144
|
-
with ZipBundle(bundle_path, mode="r") as bundle:
|
|
145
|
-
spec = bundle.read_json("spec.json")
|
|
146
|
-
try:
|
|
147
|
-
layout_overrides = bundle.read_json("layout.json")
|
|
148
|
-
except:
|
|
149
|
-
pass
|
|
150
|
-
except:
|
|
151
|
-
pass
|
|
152
|
-
elif figure_dir:
|
|
153
|
-
spec_path = Path(figure_dir) / "spec.json"
|
|
154
|
-
if spec_path.exists():
|
|
155
|
-
with open(spec_path) as f:
|
|
156
|
-
spec = json_module.load(f)
|
|
157
|
-
layout_path = Path(figure_dir) / "layout.json"
|
|
158
|
-
if layout_path.exists():
|
|
159
|
-
with open(layout_path) as f:
|
|
160
|
-
layout_overrides = json_module.load(f)
|
|
161
|
-
|
|
162
|
-
# Also check in-memory layout overrides
|
|
163
|
-
if editor.panel_info and editor.panel_info.get("layout"):
|
|
164
|
-
layout_overrides = editor.panel_info.get("layout", {})
|
|
165
|
-
|
|
166
|
-
# Get figure dimensions
|
|
167
|
-
fig_width_mm = 180
|
|
168
|
-
fig_height_mm = 120
|
|
169
|
-
if "figure" in spec:
|
|
170
|
-
fig_info = spec.get("figure", {})
|
|
171
|
-
styles = fig_info.get("styles", {})
|
|
172
|
-
size = styles.get("size", {})
|
|
173
|
-
fig_width_mm = size.get("width_mm", 180)
|
|
174
|
-
fig_height_mm = size.get("height_mm", 120)
|
|
175
|
-
|
|
176
|
-
fig_width_in = fig_width_mm / 25.4
|
|
177
|
-
fig_height_in = fig_height_mm / 25.4
|
|
178
|
-
|
|
179
|
-
fig = plt.figure(
|
|
180
|
-
figsize=(fig_width_in, fig_height_in), dpi=dpi, facecolor="white"
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
# Compose panels
|
|
184
|
-
panels_spec = spec.get("panels", [])
|
|
185
|
-
panel_paths = editor.panel_info.get("panel_paths", [])
|
|
186
|
-
panel_is_zip = editor.panel_info.get("panel_is_zip", [])
|
|
187
|
-
|
|
188
|
-
for panel_spec in panels_spec:
|
|
189
|
-
panel_id = panel_spec.get("id", "")
|
|
190
|
-
pos = panel_spec.get("position", {})
|
|
191
|
-
size = panel_spec.get("size", {})
|
|
192
|
-
|
|
193
|
-
# Skip overview/auxiliary panels (only compose main panels A-Z)
|
|
194
|
-
panel_id_lower = panel_id.lower()
|
|
195
|
-
if any(
|
|
196
|
-
skip in panel_id_lower
|
|
197
|
-
for skip in ["overview", "thumb", "preview", "aux"]
|
|
198
|
-
):
|
|
199
|
-
continue
|
|
200
|
-
|
|
201
|
-
# Find panel path first (needed to check layout_overrides)
|
|
202
|
-
panel_path = None
|
|
203
|
-
is_zip = False
|
|
204
|
-
panel_name = None
|
|
205
|
-
for idx, pp in enumerate(panel_paths):
|
|
206
|
-
pp_name = Path(pp).stem.replace(".plot", "")
|
|
207
|
-
if (
|
|
208
|
-
pp_name == panel_id
|
|
209
|
-
or pp_name.startswith(f"panel_{panel_id}_")
|
|
210
|
-
or pp_name == f"panel_{panel_id}"
|
|
211
|
-
or f"_{panel_id}_" in pp_name
|
|
212
|
-
):
|
|
213
|
-
panel_path = pp
|
|
214
|
-
panel_name = Path(pp).name # e.g., "panel_A_twinx.plot"
|
|
215
|
-
is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else False
|
|
216
|
-
break
|
|
217
|
-
|
|
218
|
-
if not panel_path:
|
|
219
|
-
continue
|
|
220
|
-
|
|
221
|
-
# Check for layout overrides (from layout.json or in-memory)
|
|
222
|
-
override = layout_overrides.get(panel_name, {})
|
|
223
|
-
override_pos = override.get("position", {})
|
|
224
|
-
override_size = override.get("size", {})
|
|
225
|
-
|
|
226
|
-
# Use override positions if available, otherwise use spec
|
|
227
|
-
x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
|
|
228
|
-
y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
|
|
229
|
-
w_mm = override_size.get("width_mm", size.get("width_mm", 60))
|
|
230
|
-
h_mm = override_size.get("height_mm", size.get("height_mm", 40))
|
|
231
|
-
|
|
232
|
-
x_frac = x_mm / fig_width_mm
|
|
233
|
-
y_frac = 1 - (y_mm + h_mm) / fig_height_mm
|
|
234
|
-
w_frac = w_mm / fig_width_mm
|
|
235
|
-
h_frac = h_mm / fig_height_mm
|
|
236
|
-
|
|
237
|
-
# Load panel preview
|
|
238
|
-
try:
|
|
239
|
-
# Exclusion patterns for preview selection
|
|
240
|
-
exclude_patterns = ["hitmap", "overview", "thumb", "preview"]
|
|
241
|
-
|
|
242
|
-
if is_zip:
|
|
243
|
-
with ZipBundle(panel_path, mode="r") as plot_bundle:
|
|
244
|
-
with zipfile.ZipFile(panel_path, "r") as zf:
|
|
245
|
-
png_files = [
|
|
246
|
-
n
|
|
247
|
-
for n in zf.namelist()
|
|
248
|
-
if n.endswith(".png")
|
|
249
|
-
and "exports/" in n
|
|
250
|
-
and not any(
|
|
251
|
-
p in n.lower() for p in exclude_patterns
|
|
252
|
-
)
|
|
253
|
-
]
|
|
254
|
-
if png_files:
|
|
255
|
-
preview_path = png_files[0]
|
|
256
|
-
if ".plot/" in preview_path:
|
|
257
|
-
preview_path = preview_path.split(".plot/")[-1]
|
|
258
|
-
img_data = plot_bundle.read_bytes(preview_path)
|
|
259
|
-
img = Image.open(io.BytesIO(img_data))
|
|
260
|
-
ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
|
|
261
|
-
ax.imshow(np.array(img))
|
|
262
|
-
ax.axis("off")
|
|
263
|
-
else:
|
|
264
|
-
plot_dir = Path(panel_path)
|
|
265
|
-
exports_dir = plot_dir / "exports"
|
|
266
|
-
if exports_dir.exists():
|
|
267
|
-
for png_file in exports_dir.glob("*.png"):
|
|
268
|
-
name_lower = png_file.name.lower()
|
|
269
|
-
if not any(p in name_lower for p in exclude_patterns):
|
|
270
|
-
img = Image.open(png_file)
|
|
271
|
-
ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
|
|
272
|
-
ax.imshow(np.array(img))
|
|
273
|
-
ax.axis("off")
|
|
274
|
-
break
|
|
275
|
-
except Exception as e:
|
|
276
|
-
print(f"Could not load panel {panel_id}: {e}")
|
|
277
|
-
|
|
278
|
-
# Draw panel letter
|
|
279
|
-
if (
|
|
280
|
-
panel_id and len(panel_id) <= 2
|
|
281
|
-
): # Only for short IDs like A, B, C...
|
|
282
|
-
# Position letter at top-left corner of panel
|
|
283
|
-
letter_x = x_frac + 0.01
|
|
284
|
-
letter_y = y_frac + h_frac - 0.02
|
|
285
|
-
fig.text(
|
|
286
|
-
letter_x,
|
|
287
|
-
letter_y,
|
|
288
|
-
panel_id,
|
|
289
|
-
fontsize=14,
|
|
290
|
-
fontweight="bold",
|
|
291
|
-
color="black",
|
|
292
|
-
ha="left",
|
|
293
|
-
va="top",
|
|
294
|
-
transform=fig.transFigure,
|
|
295
|
-
bbox=dict(
|
|
296
|
-
boxstyle="square,pad=0.1",
|
|
297
|
-
facecolor="white",
|
|
298
|
-
edgecolor="none",
|
|
299
|
-
alpha=0.8,
|
|
300
|
-
),
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
exported = {}
|
|
304
|
-
|
|
305
|
-
# Save to bundle
|
|
306
|
-
if bundle_path:
|
|
307
|
-
with ZipBundle(bundle_path, mode="a") as bundle:
|
|
308
|
-
for fmt in formats:
|
|
309
|
-
buf = io.BytesIO()
|
|
310
|
-
fig.savefig(
|
|
311
|
-
buf,
|
|
312
|
-
format=fmt,
|
|
313
|
-
dpi=dpi,
|
|
314
|
-
bbox_inches="tight",
|
|
315
|
-
facecolor="white",
|
|
316
|
-
pad_inches=0.02,
|
|
317
|
-
)
|
|
318
|
-
buf.seek(0)
|
|
319
|
-
export_path = f"exports/{figure_name}.{fmt}"
|
|
320
|
-
bundle.write_bytes(export_path, buf.read())
|
|
321
|
-
exported[fmt] = export_path
|
|
322
|
-
|
|
323
|
-
plt.close(fig)
|
|
324
|
-
return {"success": True, "exported": exported}
|
|
325
|
-
|
|
326
|
-
@app.route("/")
|
|
327
|
-
def index():
|
|
328
|
-
# Rebuild template each time for hot reload support
|
|
329
|
-
html_template = build_html_template()
|
|
330
|
-
|
|
331
|
-
# Extract figz and panel paths for display
|
|
332
|
-
json_path_str = str(editor.json_path.resolve())
|
|
333
|
-
figure_path = ""
|
|
334
|
-
panel_path = ""
|
|
335
|
-
|
|
336
|
-
# Check if this is inside a figure bundle
|
|
337
|
-
if ".figure/" in json_path_str:
|
|
338
|
-
parts = json_path_str.split(".figure/")
|
|
339
|
-
figure_path = parts[0] + ".figure"
|
|
340
|
-
panel_path = parts[1] if len(parts) > 1 else ""
|
|
341
|
-
elif ".plot/" in json_path_str:
|
|
342
|
-
parts = json_path_str.split(".plot/")
|
|
343
|
-
figure_path = parts[0] + ".plot"
|
|
344
|
-
panel_path = parts[1] if len(parts) > 1 else ""
|
|
345
|
-
else:
|
|
346
|
-
figure_path = json_path_str
|
|
347
|
-
|
|
348
|
-
return render_template_string(
|
|
349
|
-
html_template,
|
|
350
|
-
filename=figure_path,
|
|
351
|
-
panel_path=panel_path,
|
|
352
|
-
overrides=json.dumps(editor.current_overrides),
|
|
353
|
-
)
|
|
354
|
-
|
|
355
|
-
@app.route("/preview")
|
|
356
|
-
def preview():
|
|
357
|
-
"""Render figure preview with current overrides (same logic as /update)."""
|
|
358
|
-
from ._renderer import render_preview_with_bboxes
|
|
359
|
-
|
|
360
|
-
# Always use renderer for consistency between initial and updated views
|
|
361
|
-
dark_mode = request.args.get("dark_mode", "false").lower() == "true"
|
|
362
|
-
img_data, bboxes, img_size = render_preview_with_bboxes(
|
|
363
|
-
editor.csv_data,
|
|
364
|
-
editor.current_overrides,
|
|
365
|
-
metadata=editor.metadata,
|
|
366
|
-
dark_mode=dark_mode,
|
|
367
|
-
)
|
|
368
|
-
return jsonify(
|
|
369
|
-
{
|
|
370
|
-
"image": img_data,
|
|
371
|
-
"bboxes": bboxes,
|
|
372
|
-
"img_size": img_size,
|
|
373
|
-
"has_hitmap": editor.hitmap_path is not None
|
|
374
|
-
and editor.hitmap_path.exists(),
|
|
375
|
-
"format": "png",
|
|
376
|
-
"panel_info": editor.panel_info,
|
|
377
|
-
}
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
@app.route("/panels")
|
|
381
|
-
def panels():
|
|
382
|
-
"""Return all panel images with bboxes for interactive grid view (figure bundles only).
|
|
383
|
-
|
|
384
|
-
Uses smart load_panel_data helper for transparent zip/directory handling.
|
|
385
|
-
Returns layout info from figz spec.json for unified canvas positioning.
|
|
386
|
-
"""
|
|
387
|
-
import json as json_module
|
|
388
|
-
|
|
389
|
-
from ..edit import load_panel_data
|
|
390
|
-
from ._bbox import (
|
|
391
|
-
extract_bboxes_from_geometry_px,
|
|
392
|
-
extract_bboxes_from_metadata,
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
if not editor.panel_info:
|
|
396
|
-
return jsonify({"error": "Not a multi-panel figure bundle"}), 400
|
|
397
|
-
|
|
398
|
-
panel_names = editor.panel_info["panels"]
|
|
399
|
-
panel_paths = editor.panel_info.get("panel_paths", [])
|
|
400
|
-
panel_is_zip = editor.panel_info.get(
|
|
401
|
-
"panel_is_zip", [False] * len(panel_names)
|
|
402
|
-
)
|
|
403
|
-
figure_dir = Path(editor.panel_info["figure_dir"])
|
|
404
|
-
|
|
405
|
-
if not panel_paths:
|
|
406
|
-
panel_paths = [str(figure_dir / name) for name in panel_names]
|
|
407
|
-
|
|
408
|
-
# Load figz spec.json to get panel layout
|
|
409
|
-
figure_layout = {}
|
|
410
|
-
spec_path = figure_dir / "spec.json"
|
|
411
|
-
if spec_path.exists():
|
|
412
|
-
with open(spec_path) as f:
|
|
413
|
-
figure_spec = json_module.load(f)
|
|
414
|
-
for panel_spec in figure_spec.get("panels", []):
|
|
415
|
-
panel_id = panel_spec.get("id", "")
|
|
416
|
-
figure_layout[panel_id] = {
|
|
417
|
-
"position": panel_spec.get("position", {}),
|
|
418
|
-
"size": panel_spec.get("size", {}),
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
panel_images = []
|
|
422
|
-
|
|
423
|
-
for idx, panel_name in enumerate(panel_names):
|
|
424
|
-
panel_path = panel_paths[idx]
|
|
425
|
-
is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else None
|
|
426
|
-
display_name = panel_name.replace(".plot", "").replace(".plot", "")
|
|
427
|
-
|
|
428
|
-
# Use smart helper to load panel data
|
|
429
|
-
loaded = load_panel_data(panel_path, is_zip=is_zip)
|
|
430
|
-
|
|
431
|
-
panel_data = {
|
|
432
|
-
"name": display_name,
|
|
433
|
-
"image": None,
|
|
434
|
-
"bboxes": None,
|
|
435
|
-
"img_size": None,
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
# Add layout info from figz spec
|
|
439
|
-
if display_name in figure_layout:
|
|
440
|
-
panel_data["layout"] = figure_layout[display_name]
|
|
441
|
-
|
|
442
|
-
if loaded:
|
|
443
|
-
# Get image data
|
|
444
|
-
if loaded.get("is_zip"):
|
|
445
|
-
png_bytes = loaded.get("png_bytes")
|
|
446
|
-
if png_bytes:
|
|
447
|
-
panel_data["image"] = base64.b64encode(png_bytes).decode(
|
|
448
|
-
"utf-8"
|
|
449
|
-
)
|
|
450
|
-
else:
|
|
451
|
-
png_path = loaded.get("png_path")
|
|
452
|
-
if png_path and png_path.exists():
|
|
453
|
-
with open(png_path, "rb") as f:
|
|
454
|
-
panel_data["image"] = base64.b64encode(f.read()).decode(
|
|
455
|
-
"utf-8"
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
# Get image size
|
|
459
|
-
img_size = loaded.get("img_size")
|
|
460
|
-
if img_size:
|
|
461
|
-
panel_data["img_size"] = img_size
|
|
462
|
-
panel_data["width"] = img_size["width"]
|
|
463
|
-
panel_data["height"] = img_size["height"]
|
|
464
|
-
elif loaded.get("png_path"):
|
|
465
|
-
from PIL import Image
|
|
466
|
-
|
|
467
|
-
img = Image.open(loaded["png_path"])
|
|
468
|
-
panel_data["img_size"] = {
|
|
469
|
-
"width": img.size[0],
|
|
470
|
-
"height": img.size[1],
|
|
471
|
-
}
|
|
472
|
-
panel_data["width"], panel_data["height"] = img.size
|
|
473
|
-
img.close()
|
|
474
|
-
|
|
475
|
-
# Extract bboxes - prefer geometry_px.json
|
|
476
|
-
if panel_data.get("img_size"):
|
|
477
|
-
geometry_data = loaded.get("geometry_data")
|
|
478
|
-
metadata = loaded.get("metadata", {})
|
|
479
|
-
|
|
480
|
-
if geometry_data:
|
|
481
|
-
panel_data["bboxes"] = extract_bboxes_from_geometry_px(
|
|
482
|
-
geometry_data,
|
|
483
|
-
panel_data["img_size"]["width"],
|
|
484
|
-
panel_data["img_size"]["height"],
|
|
485
|
-
)
|
|
486
|
-
elif metadata:
|
|
487
|
-
panel_data["bboxes"] = extract_bboxes_from_metadata(
|
|
488
|
-
metadata,
|
|
489
|
-
panel_data["img_size"]["width"],
|
|
490
|
-
panel_data["img_size"]["height"],
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
panel_images.append(panel_data)
|
|
494
|
-
|
|
495
|
-
return jsonify(
|
|
496
|
-
{
|
|
497
|
-
"panels": panel_images,
|
|
498
|
-
"count": len(panel_images),
|
|
499
|
-
"layout": figure_layout,
|
|
500
|
-
}
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
@app.route("/switch_panel/<int:panel_index>")
|
|
504
|
-
def switch_panel(panel_index):
|
|
505
|
-
"""Switch to a different panel in the figure bundle.
|
|
506
|
-
|
|
507
|
-
Uses smart load_panel_data helper for transparent zip/directory handling.
|
|
508
|
-
"""
|
|
509
|
-
from ..edit import load_panel_data
|
|
510
|
-
from ._bbox import (
|
|
511
|
-
extract_bboxes_from_geometry_px,
|
|
512
|
-
extract_bboxes_from_metadata,
|
|
513
|
-
)
|
|
514
|
-
|
|
515
|
-
if not editor.panel_info:
|
|
516
|
-
return jsonify({"error": "Not a multi-panel figure bundle"}), 400
|
|
517
|
-
|
|
518
|
-
panels = editor.panel_info["panels"]
|
|
519
|
-
panel_paths = editor.panel_info.get("panel_paths", [])
|
|
520
|
-
panel_is_zip = editor.panel_info.get("panel_is_zip", [False] * len(panels))
|
|
521
|
-
|
|
522
|
-
if panel_index < 0 or panel_index >= len(panels):
|
|
523
|
-
return jsonify({"error": f"Invalid panel index: {panel_index}"}), 400
|
|
524
|
-
|
|
525
|
-
panel_name = panels[panel_index]
|
|
526
|
-
panel_path = (
|
|
527
|
-
panel_paths[panel_index]
|
|
528
|
-
if panel_paths
|
|
529
|
-
else str(Path(editor.panel_info["figure_dir"]) / panel_name)
|
|
530
|
-
)
|
|
531
|
-
is_zip = (
|
|
532
|
-
panel_is_zip[panel_index] if panel_index < len(panel_is_zip) else None
|
|
533
|
-
)
|
|
534
|
-
|
|
535
|
-
try:
|
|
536
|
-
# Use smart helper to load panel data
|
|
537
|
-
loaded = load_panel_data(panel_path, is_zip=is_zip)
|
|
538
|
-
|
|
539
|
-
if not loaded:
|
|
540
|
-
return (
|
|
541
|
-
jsonify({"error": f"Could not load panel: {panel_name}"}),
|
|
542
|
-
400,
|
|
543
|
-
)
|
|
544
|
-
|
|
545
|
-
# Get image data
|
|
546
|
-
img_data = None
|
|
547
|
-
if loaded.get("is_zip"):
|
|
548
|
-
png_bytes = loaded.get("png_bytes")
|
|
549
|
-
if png_bytes:
|
|
550
|
-
img_data = base64.b64encode(png_bytes).decode("utf-8")
|
|
551
|
-
else:
|
|
552
|
-
png_path = loaded.get("png_path")
|
|
553
|
-
if png_path and png_path.exists():
|
|
554
|
-
with open(png_path, "rb") as f:
|
|
555
|
-
img_data = base64.b64encode(f.read()).decode("utf-8")
|
|
556
|
-
|
|
557
|
-
if not img_data:
|
|
558
|
-
return (
|
|
559
|
-
jsonify({"error": f"No PNG found for panel: {panel_name}"}),
|
|
560
|
-
400,
|
|
561
|
-
)
|
|
562
|
-
|
|
563
|
-
# Get image size
|
|
564
|
-
img_size = loaded.get("img_size", {"width": 0, "height": 0})
|
|
565
|
-
if not img_size and loaded.get("png_path"):
|
|
566
|
-
from PIL import Image
|
|
567
|
-
|
|
568
|
-
img = Image.open(loaded["png_path"])
|
|
569
|
-
img_size = {"width": img.size[0], "height": img.size[1]}
|
|
570
|
-
img.close()
|
|
571
|
-
|
|
572
|
-
# Extract bboxes - prefer geometry_px.json
|
|
573
|
-
bboxes = {}
|
|
574
|
-
geometry_data = loaded.get("geometry_data")
|
|
575
|
-
metadata = loaded.get("metadata", {})
|
|
576
|
-
|
|
577
|
-
if geometry_data and img_size:
|
|
578
|
-
bboxes = extract_bboxes_from_geometry_px(
|
|
579
|
-
geometry_data, img_size["width"], img_size["height"]
|
|
580
|
-
)
|
|
581
|
-
elif metadata and img_size:
|
|
582
|
-
bboxes = extract_bboxes_from_metadata(
|
|
583
|
-
metadata, img_size["width"], img_size["height"]
|
|
584
|
-
)
|
|
585
|
-
|
|
586
|
-
# Update editor state
|
|
587
|
-
editor.metadata = metadata
|
|
588
|
-
editor.panel_info["current_index"] = panel_index
|
|
589
|
-
|
|
590
|
-
# Re-extract defaults from new metadata
|
|
591
|
-
from .._defaults import (
|
|
592
|
-
extract_defaults_from_metadata,
|
|
593
|
-
get_scitex_defaults,
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
editor.scitex_defaults = get_scitex_defaults()
|
|
597
|
-
editor.metadata_defaults = extract_defaults_from_metadata(metadata)
|
|
598
|
-
editor.current_overrides = copy.deepcopy(editor.scitex_defaults)
|
|
599
|
-
editor.current_overrides.update(editor.metadata_defaults)
|
|
600
|
-
editor.current_overrides.update(editor.manual_overrides)
|
|
601
|
-
|
|
602
|
-
return jsonify(
|
|
603
|
-
{
|
|
604
|
-
"success": True,
|
|
605
|
-
"panel_name": panel_name,
|
|
606
|
-
"panel_index": panel_index,
|
|
607
|
-
"image": img_data,
|
|
608
|
-
"bboxes": bboxes,
|
|
609
|
-
"img_size": img_size,
|
|
610
|
-
"overrides": editor.current_overrides,
|
|
611
|
-
}
|
|
612
|
-
)
|
|
613
|
-
except Exception as e:
|
|
614
|
-
import traceback
|
|
615
|
-
|
|
616
|
-
return (
|
|
617
|
-
jsonify(
|
|
618
|
-
{
|
|
619
|
-
"error": f"Failed to switch panel: {str(e)}",
|
|
620
|
-
"traceback": traceback.format_exc(),
|
|
621
|
-
}
|
|
622
|
-
),
|
|
623
|
-
500,
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
@app.route("/hitmap")
|
|
627
|
-
def hitmap():
|
|
628
|
-
"""Return hitmap PNG for element detection."""
|
|
629
|
-
if editor.hitmap_path and editor.hitmap_path.exists():
|
|
630
|
-
with open(editor.hitmap_path, "rb") as f:
|
|
631
|
-
img_data = base64.b64encode(f.read()).decode("utf-8")
|
|
632
|
-
return jsonify(
|
|
633
|
-
{
|
|
634
|
-
"image": img_data,
|
|
635
|
-
"color_map": editor.color_map,
|
|
636
|
-
}
|
|
637
|
-
)
|
|
638
|
-
return jsonify({"error": "No hitmap available"}), 404
|
|
639
|
-
|
|
640
|
-
@app.route("/color_map")
|
|
641
|
-
def color_map():
|
|
642
|
-
"""Return color map for hitmap element identification."""
|
|
643
|
-
return jsonify(
|
|
644
|
-
{
|
|
645
|
-
"color_map": editor.color_map,
|
|
646
|
-
"hit_regions": editor.hit_regions,
|
|
647
|
-
}
|
|
648
|
-
)
|
|
649
|
-
|
|
650
|
-
@app.route("/update", methods=["POST"])
|
|
651
|
-
def update():
|
|
652
|
-
"""Update overrides and re-render with updated properties."""
|
|
653
|
-
from ._renderer import render_preview_with_bboxes
|
|
654
|
-
|
|
655
|
-
data = request.json
|
|
656
|
-
editor.current_overrides.update(data.get("overrides", {}))
|
|
657
|
-
editor._user_modified = True
|
|
658
|
-
|
|
659
|
-
# Check if dark mode is requested from POST data
|
|
660
|
-
dark_mode = data.get("dark_mode", False)
|
|
661
|
-
|
|
662
|
-
# Re-render the figure with updated overrides
|
|
663
|
-
img_data, bboxes, img_size = render_preview_with_bboxes(
|
|
664
|
-
editor.csv_data,
|
|
665
|
-
editor.current_overrides,
|
|
666
|
-
metadata=editor.metadata,
|
|
667
|
-
dark_mode=dark_mode,
|
|
668
|
-
)
|
|
669
|
-
return jsonify(
|
|
670
|
-
{
|
|
671
|
-
"image": img_data,
|
|
672
|
-
"bboxes": bboxes,
|
|
673
|
-
"img_size": img_size,
|
|
674
|
-
"status": "updated",
|
|
675
|
-
}
|
|
676
|
-
)
|
|
677
|
-
|
|
678
|
-
@app.route("/save", methods=["POST"])
|
|
679
|
-
def save():
|
|
680
|
-
"""Save to .manual.json."""
|
|
681
|
-
from ..edit import save_manual_overrides
|
|
682
|
-
|
|
683
|
-
try:
|
|
684
|
-
manual_path = save_manual_overrides(
|
|
685
|
-
editor.json_path, editor.current_overrides
|
|
686
|
-
)
|
|
687
|
-
return jsonify({"status": "saved", "path": str(manual_path)})
|
|
688
|
-
except Exception as e:
|
|
689
|
-
return jsonify({"status": "error", "message": str(e)}), 500
|
|
690
|
-
|
|
691
|
-
@app.route("/save_layout", methods=["POST"])
|
|
692
|
-
def save_layout():
|
|
693
|
-
"""Save panel layout positions to figure bundle."""
|
|
694
|
-
try:
|
|
695
|
-
data = request.get_json()
|
|
696
|
-
layout = data.get("layout", {})
|
|
697
|
-
|
|
698
|
-
if not layout:
|
|
699
|
-
return jsonify(
|
|
700
|
-
{"success": False, "error": "No layout data provided"}
|
|
701
|
-
)
|
|
702
|
-
|
|
703
|
-
# Check if we have panel_info (figure bundle)
|
|
704
|
-
if not editor.panel_info:
|
|
705
|
-
return jsonify(
|
|
706
|
-
{
|
|
707
|
-
"success": False,
|
|
708
|
-
"error": "No panel info available (not a figure bundle)",
|
|
709
|
-
}
|
|
710
|
-
)
|
|
711
|
-
|
|
712
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
713
|
-
if not bundle_path:
|
|
714
|
-
return jsonify(
|
|
715
|
-
{"success": False, "error": "Bundle path not available"}
|
|
716
|
-
)
|
|
717
|
-
|
|
718
|
-
# Update layout in the figure bundle
|
|
719
|
-
from scitex.canvas.io import ZipBundle
|
|
720
|
-
|
|
721
|
-
bundle = ZipBundle(bundle_path)
|
|
722
|
-
|
|
723
|
-
# Read existing layout or create new one
|
|
724
|
-
try:
|
|
725
|
-
existing_layout = bundle.read_json("layout.json")
|
|
726
|
-
except:
|
|
727
|
-
existing_layout = {}
|
|
728
|
-
|
|
729
|
-
# Update layout with new positions
|
|
730
|
-
for panel_name, pos in layout.items():
|
|
731
|
-
if panel_name not in existing_layout:
|
|
732
|
-
existing_layout[panel_name] = {}
|
|
733
|
-
if "position" not in existing_layout[panel_name]:
|
|
734
|
-
existing_layout[panel_name]["position"] = {}
|
|
735
|
-
if "size" not in existing_layout[panel_name]:
|
|
736
|
-
existing_layout[panel_name]["size"] = {}
|
|
737
|
-
|
|
738
|
-
# Update position
|
|
739
|
-
existing_layout[panel_name]["position"]["x_mm"] = pos.get("x_mm", 0)
|
|
740
|
-
existing_layout[panel_name]["position"]["y_mm"] = pos.get("y_mm", 0)
|
|
741
|
-
|
|
742
|
-
# Update size if provided
|
|
743
|
-
if "width_mm" in pos:
|
|
744
|
-
existing_layout[panel_name]["size"]["width_mm"] = pos[
|
|
745
|
-
"width_mm"
|
|
746
|
-
]
|
|
747
|
-
if "height_mm" in pos:
|
|
748
|
-
existing_layout[panel_name]["size"]["height_mm"] = pos[
|
|
749
|
-
"height_mm"
|
|
750
|
-
]
|
|
751
|
-
|
|
752
|
-
# Save updated layout
|
|
753
|
-
bundle.write_json("layout.json", existing_layout)
|
|
754
|
-
|
|
755
|
-
# Update in-memory panel_info
|
|
756
|
-
editor.panel_info["layout"] = existing_layout
|
|
757
|
-
|
|
758
|
-
# Auto-export composed figure to bundle
|
|
759
|
-
export_result = _export_composed_figure(editor, formats=["png", "svg"])
|
|
760
|
-
|
|
761
|
-
return jsonify(
|
|
762
|
-
{
|
|
763
|
-
"success": True,
|
|
764
|
-
"layout": existing_layout,
|
|
765
|
-
"exported": export_result.get("exported", {}),
|
|
766
|
-
}
|
|
767
|
-
)
|
|
768
|
-
|
|
769
|
-
except Exception as e:
|
|
770
|
-
import traceback
|
|
771
|
-
|
|
772
|
-
return jsonify(
|
|
773
|
-
{
|
|
774
|
-
"success": False,
|
|
775
|
-
"error": str(e),
|
|
776
|
-
"traceback": traceback.format_exc(),
|
|
777
|
-
}
|
|
778
|
-
)
|
|
779
|
-
|
|
780
|
-
@app.route("/save_element_position", methods=["POST"])
|
|
781
|
-
def save_element_position():
|
|
782
|
-
"""Save element position (legend/panel_letter) to figure bundle.
|
|
783
|
-
|
|
784
|
-
ONLY legends and panel letters can be repositioned to maintain
|
|
785
|
-
scientific rigor. Data elements are never moved.
|
|
786
|
-
"""
|
|
787
|
-
try:
|
|
788
|
-
data = request.get_json()
|
|
789
|
-
element = data.get("element", "")
|
|
790
|
-
panel = data.get("panel", "")
|
|
791
|
-
element_type = data.get("element_type", "")
|
|
792
|
-
position = data.get("position", {})
|
|
793
|
-
snap_name = data.get("snap_name")
|
|
794
|
-
|
|
795
|
-
# Validate element type (whitelist for scientific rigor)
|
|
796
|
-
ALLOWED_TYPES = ["legend", "panel_letter"]
|
|
797
|
-
if element_type not in ALLOWED_TYPES:
|
|
798
|
-
return jsonify(
|
|
799
|
-
{
|
|
800
|
-
"success": False,
|
|
801
|
-
"error": f"Element type '{element_type}' cannot be repositioned (scientific rigor)",
|
|
802
|
-
}
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
if not editor.panel_info:
|
|
806
|
-
return jsonify(
|
|
807
|
-
{"success": False, "error": "No panel info available"}
|
|
808
|
-
)
|
|
809
|
-
|
|
810
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
811
|
-
if not bundle_path:
|
|
812
|
-
return jsonify(
|
|
813
|
-
{"success": False, "error": "Bundle path not available"}
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
from scitex.canvas.io import ZipBundle
|
|
817
|
-
|
|
818
|
-
bundle = ZipBundle(bundle_path)
|
|
819
|
-
|
|
820
|
-
# Read or create style.json for element positions
|
|
821
|
-
try:
|
|
822
|
-
style = bundle.read_json("style.json")
|
|
823
|
-
except:
|
|
824
|
-
style = {}
|
|
825
|
-
|
|
826
|
-
# Initialize structure
|
|
827
|
-
if "elements" not in style:
|
|
828
|
-
style["elements"] = {}
|
|
829
|
-
if panel not in style["elements"]:
|
|
830
|
-
style["elements"][panel] = {}
|
|
831
|
-
|
|
832
|
-
# Save element position
|
|
833
|
-
style["elements"][panel][element] = {
|
|
834
|
-
"type": element_type,
|
|
835
|
-
"position": position,
|
|
836
|
-
"snap_name": snap_name,
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
# For legends, also update legend_location for matplotlib compatibility
|
|
840
|
-
if element_type == "legend" and snap_name:
|
|
841
|
-
# Convert snap name to matplotlib loc format
|
|
842
|
-
loc_map = {
|
|
843
|
-
"upper left": "upper left",
|
|
844
|
-
"upper center": "upper center",
|
|
845
|
-
"upper right": "upper right",
|
|
846
|
-
"center left": "center left",
|
|
847
|
-
"center": "center",
|
|
848
|
-
"center right": "center right",
|
|
849
|
-
"lower left": "lower left",
|
|
850
|
-
"lower center": "lower center",
|
|
851
|
-
"lower right": "lower right",
|
|
852
|
-
}
|
|
853
|
-
if snap_name in loc_map:
|
|
854
|
-
if "legend" not in style:
|
|
855
|
-
style["legend"] = {}
|
|
856
|
-
style["legend"]["location"] = loc_map[snap_name]
|
|
857
|
-
|
|
858
|
-
bundle.write_json("style.json", style)
|
|
859
|
-
|
|
860
|
-
return jsonify(
|
|
861
|
-
{
|
|
862
|
-
"success": True,
|
|
863
|
-
"element": element,
|
|
864
|
-
"position": position,
|
|
865
|
-
"snap_name": snap_name,
|
|
866
|
-
}
|
|
867
|
-
)
|
|
868
|
-
|
|
869
|
-
except Exception as e:
|
|
870
|
-
import traceback
|
|
871
|
-
|
|
872
|
-
return jsonify(
|
|
873
|
-
{
|
|
874
|
-
"success": False,
|
|
875
|
-
"error": str(e),
|
|
876
|
-
"traceback": traceback.format_exc(),
|
|
877
|
-
}
|
|
878
|
-
)
|
|
879
|
-
|
|
880
|
-
@app.route("/export", methods=["POST"])
|
|
881
|
-
def export_figure():
|
|
882
|
-
"""Export composed figure to various formats and update figure bundle."""
|
|
883
|
-
try:
|
|
884
|
-
data = request.get_json()
|
|
885
|
-
formats = data.get("formats", ["png", "svg"])
|
|
886
|
-
|
|
887
|
-
if not editor.panel_info:
|
|
888
|
-
return jsonify(
|
|
889
|
-
{"success": False, "error": "No panel info available"}
|
|
890
|
-
)
|
|
891
|
-
|
|
892
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
893
|
-
if not bundle_path:
|
|
894
|
-
return jsonify(
|
|
895
|
-
{"success": False, "error": "Bundle path not available"}
|
|
896
|
-
)
|
|
897
|
-
|
|
898
|
-
import io
|
|
899
|
-
from pathlib import Path
|
|
900
|
-
|
|
901
|
-
import matplotlib
|
|
902
|
-
|
|
903
|
-
from scitex.io import ZipBundle
|
|
904
|
-
|
|
905
|
-
matplotlib.use("Agg")
|
|
906
|
-
import matplotlib.pyplot as plt
|
|
907
|
-
import numpy as np
|
|
908
|
-
from PIL import Image
|
|
909
|
-
|
|
910
|
-
figure_name = Path(bundle_path).stem
|
|
911
|
-
dpi = data.get("dpi", 150)
|
|
912
|
-
|
|
913
|
-
with ZipBundle(bundle_path, mode="a") as bundle:
|
|
914
|
-
# Read spec for figure size and panel positions
|
|
915
|
-
try:
|
|
916
|
-
spec = bundle.read_json("spec.json")
|
|
917
|
-
except:
|
|
918
|
-
spec = {}
|
|
919
|
-
|
|
920
|
-
# Get figure dimensions
|
|
921
|
-
fig_width_mm = 180
|
|
922
|
-
fig_height_mm = 120
|
|
923
|
-
if "figure" in spec:
|
|
924
|
-
fig_info = spec.get("figure", {})
|
|
925
|
-
styles = fig_info.get("styles", {})
|
|
926
|
-
size = styles.get("size", {})
|
|
927
|
-
fig_width_mm = size.get("width_mm", 180)
|
|
928
|
-
fig_height_mm = size.get("height_mm", 120)
|
|
929
|
-
|
|
930
|
-
# Convert mm to inches
|
|
931
|
-
fig_width_in = fig_width_mm / 25.4
|
|
932
|
-
fig_height_in = fig_height_mm / 25.4
|
|
933
|
-
|
|
934
|
-
# Create figure with white background
|
|
935
|
-
fig = plt.figure(
|
|
936
|
-
figsize=(fig_width_in, fig_height_in),
|
|
937
|
-
dpi=dpi,
|
|
938
|
-
facecolor="white",
|
|
939
|
-
)
|
|
940
|
-
|
|
941
|
-
# Get panels from spec or editor.panel_info
|
|
942
|
-
panels_spec = spec.get("panels", [])
|
|
943
|
-
|
|
944
|
-
# Compose panels onto figure
|
|
945
|
-
for panel_spec in panels_spec:
|
|
946
|
-
panel_id = panel_spec.get("id", "")
|
|
947
|
-
plot_name = panel_spec.get("plot", "")
|
|
948
|
-
|
|
949
|
-
# Get position and size from spec
|
|
950
|
-
pos = panel_spec.get("position", {})
|
|
951
|
-
size = panel_spec.get("size", {})
|
|
952
|
-
|
|
953
|
-
x_mm = pos.get("x_mm", 0)
|
|
954
|
-
y_mm = pos.get("y_mm", 0)
|
|
955
|
-
w_mm = size.get("width_mm", 60)
|
|
956
|
-
h_mm = size.get("height_mm", 40)
|
|
957
|
-
|
|
958
|
-
# Convert to figure coordinates (0-1)
|
|
959
|
-
x_frac = x_mm / fig_width_mm
|
|
960
|
-
y_frac = 1 - (y_mm + h_mm) / fig_height_mm # Flip Y
|
|
961
|
-
w_frac = w_mm / fig_width_mm
|
|
962
|
-
h_frac = h_mm / fig_height_mm
|
|
963
|
-
|
|
964
|
-
# Try to read panel image from pltz exports
|
|
965
|
-
img_loaded = False
|
|
966
|
-
for plot_path in [
|
|
967
|
-
f"{panel_id}.plot",
|
|
968
|
-
plot_name.replace(".d", ""),
|
|
969
|
-
]:
|
|
970
|
-
if img_loaded:
|
|
971
|
-
break
|
|
972
|
-
try:
|
|
973
|
-
# Read pltz as nested bundle
|
|
974
|
-
plot_bytes = bundle.read_bytes(plot_path)
|
|
975
|
-
import tempfile
|
|
976
|
-
|
|
977
|
-
with tempfile.NamedTemporaryFile(
|
|
978
|
-
suffix=".plot", delete=False
|
|
979
|
-
) as tmp:
|
|
980
|
-
tmp.write(plot_bytes)
|
|
981
|
-
tmp_path = tmp.name
|
|
982
|
-
try:
|
|
983
|
-
with ZipBundle(tmp_path, mode="r") as plot_bundle:
|
|
984
|
-
# Try various preview paths
|
|
985
|
-
for preview_path in [
|
|
986
|
-
"exports/preview.png",
|
|
987
|
-
"preview.png",
|
|
988
|
-
f"exports/{panel_id}.png",
|
|
989
|
-
]:
|
|
990
|
-
try:
|
|
991
|
-
img_data = plot_bundle.read_bytes(
|
|
992
|
-
preview_path
|
|
993
|
-
)
|
|
994
|
-
img = Image.open(io.BytesIO(img_data))
|
|
995
|
-
img_array = np.array(img)
|
|
996
|
-
|
|
997
|
-
# Create axes and add image
|
|
998
|
-
ax = fig.add_axes(
|
|
999
|
-
[x_frac, y_frac, w_frac, h_frac]
|
|
1000
|
-
)
|
|
1001
|
-
ax.imshow(img_array)
|
|
1002
|
-
ax.axis("off")
|
|
1003
|
-
img_loaded = True
|
|
1004
|
-
break
|
|
1005
|
-
except:
|
|
1006
|
-
continue
|
|
1007
|
-
finally:
|
|
1008
|
-
import os
|
|
1009
|
-
|
|
1010
|
-
os.unlink(tmp_path)
|
|
1011
|
-
except Exception as e:
|
|
1012
|
-
print(f"Could not load plot {plot_path}: {e}")
|
|
1013
|
-
continue
|
|
1014
|
-
|
|
1015
|
-
exported = {}
|
|
1016
|
-
|
|
1017
|
-
for fmt in formats:
|
|
1018
|
-
buf = io.BytesIO()
|
|
1019
|
-
if fmt in ["png", "jpeg", "jpg"]:
|
|
1020
|
-
fig.savefig(
|
|
1021
|
-
buf,
|
|
1022
|
-
format="png" if fmt == "png" else "jpeg",
|
|
1023
|
-
dpi=dpi,
|
|
1024
|
-
bbox_inches="tight",
|
|
1025
|
-
facecolor="white",
|
|
1026
|
-
pad_inches=0.02,
|
|
1027
|
-
)
|
|
1028
|
-
elif fmt == "svg":
|
|
1029
|
-
fig.savefig(
|
|
1030
|
-
buf, format="svg", bbox_inches="tight", pad_inches=0.02
|
|
1031
|
-
)
|
|
1032
|
-
elif fmt == "pdf":
|
|
1033
|
-
fig.savefig(
|
|
1034
|
-
buf, format="pdf", bbox_inches="tight", pad_inches=0.02
|
|
1035
|
-
)
|
|
1036
|
-
else:
|
|
1037
|
-
continue
|
|
1038
|
-
|
|
1039
|
-
buf.seek(0)
|
|
1040
|
-
content = buf.read()
|
|
1041
|
-
|
|
1042
|
-
# Save to exports/ directory in bundle
|
|
1043
|
-
export_path = f"exports/{figure_name}.{fmt}"
|
|
1044
|
-
bundle.write_bytes(export_path, content)
|
|
1045
|
-
exported[fmt] = export_path
|
|
1046
|
-
|
|
1047
|
-
plt.close(fig)
|
|
1048
|
-
|
|
1049
|
-
return jsonify(
|
|
1050
|
-
{
|
|
1051
|
-
"success": True,
|
|
1052
|
-
"exported": exported,
|
|
1053
|
-
"bundle_path": str(bundle_path),
|
|
1054
|
-
}
|
|
1055
|
-
)
|
|
1056
|
-
|
|
1057
|
-
except Exception as e:
|
|
1058
|
-
import traceback
|
|
1059
|
-
|
|
1060
|
-
return jsonify(
|
|
1061
|
-
{
|
|
1062
|
-
"success": False,
|
|
1063
|
-
"error": str(e),
|
|
1064
|
-
"traceback": traceback.format_exc(),
|
|
1065
|
-
}
|
|
1066
|
-
)
|
|
1067
|
-
|
|
1068
|
-
@app.route("/download/<fmt>")
|
|
1069
|
-
def download_figure(fmt):
|
|
1070
|
-
"""Download figure in specified format."""
|
|
1071
|
-
try:
|
|
1072
|
-
import io
|
|
1073
|
-
from pathlib import Path
|
|
1074
|
-
|
|
1075
|
-
from flask import send_file
|
|
1076
|
-
|
|
1077
|
-
mime_types = {
|
|
1078
|
-
"png": "image/png",
|
|
1079
|
-
"jpeg": "image/jpeg",
|
|
1080
|
-
"jpg": "image/jpeg",
|
|
1081
|
-
"svg": "image/svg+xml",
|
|
1082
|
-
"pdf": "application/pdf",
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
if fmt not in mime_types:
|
|
1086
|
-
return f"Unsupported format: {fmt}", 400
|
|
1087
|
-
|
|
1088
|
-
# For figure bundles, download the composed figure
|
|
1089
|
-
if editor.panel_info:
|
|
1090
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
1091
|
-
figure_dir = editor.panel_info.get("figure_dir")
|
|
1092
|
-
figure_name = (
|
|
1093
|
-
Path(bundle_path).stem
|
|
1094
|
-
if bundle_path
|
|
1095
|
-
else (
|
|
1096
|
-
Path(figure_dir).stem.replace(".figure", "")
|
|
1097
|
-
if figure_dir
|
|
1098
|
-
else "figure"
|
|
1099
|
-
)
|
|
1100
|
-
)
|
|
1101
|
-
|
|
1102
|
-
if bundle_path or figure_dir:
|
|
1103
|
-
import matplotlib
|
|
1104
|
-
import numpy as np
|
|
1105
|
-
from PIL import Image
|
|
1106
|
-
|
|
1107
|
-
from scitex.io import ZipBundle
|
|
1108
|
-
|
|
1109
|
-
matplotlib.use("Agg")
|
|
1110
|
-
import json as json_module
|
|
1111
|
-
|
|
1112
|
-
import matplotlib.pyplot as plt
|
|
1113
|
-
|
|
1114
|
-
# Always compose on-demand to ensure current panel state
|
|
1115
|
-
# (existing exports in bundle may be stale or blank)
|
|
1116
|
-
# Read spec.json and layout.json for position overrides
|
|
1117
|
-
spec = {}
|
|
1118
|
-
layout_overrides = {}
|
|
1119
|
-
if bundle_path:
|
|
1120
|
-
try:
|
|
1121
|
-
with ZipBundle(bundle_path, mode="r") as bundle:
|
|
1122
|
-
spec = bundle.read_json("spec.json")
|
|
1123
|
-
try:
|
|
1124
|
-
layout_overrides = bundle.read_json(
|
|
1125
|
-
"layout.json"
|
|
1126
|
-
)
|
|
1127
|
-
except:
|
|
1128
|
-
pass
|
|
1129
|
-
except:
|
|
1130
|
-
pass
|
|
1131
|
-
elif figure_dir:
|
|
1132
|
-
spec_path = Path(figure_dir) / "spec.json"
|
|
1133
|
-
if spec_path.exists():
|
|
1134
|
-
with open(spec_path) as f:
|
|
1135
|
-
spec = json_module.load(f)
|
|
1136
|
-
layout_path = Path(figure_dir) / "layout.json"
|
|
1137
|
-
if layout_path.exists():
|
|
1138
|
-
with open(layout_path) as f:
|
|
1139
|
-
layout_overrides = json_module.load(f)
|
|
1140
|
-
|
|
1141
|
-
# Also check in-memory layout overrides (most current)
|
|
1142
|
-
if editor.panel_info and editor.panel_info.get("layout"):
|
|
1143
|
-
layout_overrides = editor.panel_info.get("layout", {})
|
|
1144
|
-
|
|
1145
|
-
# Get figure dimensions
|
|
1146
|
-
fig_width_mm = 180
|
|
1147
|
-
fig_height_mm = 120
|
|
1148
|
-
if "figure" in spec:
|
|
1149
|
-
fig_info = spec.get("figure", {})
|
|
1150
|
-
styles = fig_info.get("styles", {})
|
|
1151
|
-
size = styles.get("size", {})
|
|
1152
|
-
fig_width_mm = size.get("width_mm", 180)
|
|
1153
|
-
fig_height_mm = size.get("height_mm", 120)
|
|
1154
|
-
|
|
1155
|
-
fig_width_in = fig_width_mm / 25.4
|
|
1156
|
-
fig_height_in = fig_height_mm / 25.4
|
|
1157
|
-
|
|
1158
|
-
dpi = 150 if fmt in ["jpeg", "jpg"] else 300
|
|
1159
|
-
fig = plt.figure(
|
|
1160
|
-
figsize=(fig_width_in, fig_height_in),
|
|
1161
|
-
dpi=dpi,
|
|
1162
|
-
facecolor="white",
|
|
1163
|
-
)
|
|
1164
|
-
|
|
1165
|
-
# Compose panels
|
|
1166
|
-
panels_spec = spec.get("panels", [])
|
|
1167
|
-
panel_paths = editor.panel_info.get("panel_paths", [])
|
|
1168
|
-
panel_is_zip = editor.panel_info.get("panel_is_zip", [])
|
|
1169
|
-
|
|
1170
|
-
for panel_spec in panels_spec:
|
|
1171
|
-
panel_id = panel_spec.get("id", "")
|
|
1172
|
-
pos = panel_spec.get("position", {})
|
|
1173
|
-
size = panel_spec.get("size", {})
|
|
1174
|
-
|
|
1175
|
-
# Skip overview/auxiliary panels (only compose main panels A-Z)
|
|
1176
|
-
panel_id_lower = panel_id.lower()
|
|
1177
|
-
if any(
|
|
1178
|
-
skip in panel_id_lower
|
|
1179
|
-
for skip in ["overview", "thumb", "preview", "aux"]
|
|
1180
|
-
):
|
|
1181
|
-
continue
|
|
1182
|
-
|
|
1183
|
-
# Find panel path first (needed to check layout_overrides)
|
|
1184
|
-
panel_path = None
|
|
1185
|
-
is_zip = False
|
|
1186
|
-
panel_name = None
|
|
1187
|
-
for idx, pp in enumerate(panel_paths):
|
|
1188
|
-
pp_name = Path(pp).stem.replace(".plot", "")
|
|
1189
|
-
# Match exact name, or name contains panel_id pattern
|
|
1190
|
-
# e.g., "panel_A_twinx" matches panel_id "A"
|
|
1191
|
-
if (
|
|
1192
|
-
pp_name == panel_id
|
|
1193
|
-
or pp_name.startswith(f"panel_{panel_id}_")
|
|
1194
|
-
or pp_name.startswith(f"panel_{panel_id}.")
|
|
1195
|
-
or pp_name == f"panel_{panel_id}"
|
|
1196
|
-
or pp_name == panel_id
|
|
1197
|
-
or f"_{panel_id}_" in pp_name
|
|
1198
|
-
or pp_name.endswith(f"_{panel_id}")
|
|
1199
|
-
):
|
|
1200
|
-
panel_path = pp
|
|
1201
|
-
panel_name = Path(
|
|
1202
|
-
pp
|
|
1203
|
-
).name # e.g., "panel_A_twinx.plot"
|
|
1204
|
-
is_zip = (
|
|
1205
|
-
panel_is_zip[idx]
|
|
1206
|
-
if idx < len(panel_is_zip)
|
|
1207
|
-
else False
|
|
1208
|
-
)
|
|
1209
|
-
break
|
|
1210
|
-
|
|
1211
|
-
if not panel_path:
|
|
1212
|
-
print(
|
|
1213
|
-
f"Could not find panel path for id={panel_id}, available: {[Path(p).stem for p in panel_paths]}"
|
|
1214
|
-
)
|
|
1215
|
-
continue
|
|
1216
|
-
|
|
1217
|
-
# Check for layout overrides (from layout.json or in-memory)
|
|
1218
|
-
override = layout_overrides.get(panel_name, {})
|
|
1219
|
-
override_pos = override.get("position", {})
|
|
1220
|
-
override_size = override.get("size", {})
|
|
1221
|
-
|
|
1222
|
-
# Use override positions if available, otherwise use spec
|
|
1223
|
-
x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
|
|
1224
|
-
y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
|
|
1225
|
-
w_mm = override_size.get(
|
|
1226
|
-
"width_mm", size.get("width_mm", 60)
|
|
1227
|
-
)
|
|
1228
|
-
h_mm = override_size.get(
|
|
1229
|
-
"height_mm", size.get("height_mm", 40)
|
|
1230
|
-
)
|
|
1231
|
-
|
|
1232
|
-
x_frac = x_mm / fig_width_mm
|
|
1233
|
-
y_frac = 1 - (y_mm + h_mm) / fig_height_mm
|
|
1234
|
-
w_frac = w_mm / fig_width_mm
|
|
1235
|
-
h_frac = h_mm / fig_height_mm
|
|
1236
|
-
|
|
1237
|
-
# Load panel preview image
|
|
1238
|
-
try:
|
|
1239
|
-
img_loaded = False
|
|
1240
|
-
# Exclusion patterns for preview selection
|
|
1241
|
-
exclude_patterns = [
|
|
1242
|
-
"hitmap",
|
|
1243
|
-
"overview",
|
|
1244
|
-
"thumb",
|
|
1245
|
-
"preview",
|
|
1246
|
-
]
|
|
1247
|
-
|
|
1248
|
-
if is_zip:
|
|
1249
|
-
with ZipBundle(panel_path, mode="r") as plot_bundle:
|
|
1250
|
-
# Find PNG in exports (exclude hitmap, overview, thumbnails)
|
|
1251
|
-
import zipfile
|
|
1252
|
-
|
|
1253
|
-
with zipfile.ZipFile(panel_path, "r") as zf:
|
|
1254
|
-
png_files = [
|
|
1255
|
-
n
|
|
1256
|
-
for n in zf.namelist()
|
|
1257
|
-
if n.endswith(".png")
|
|
1258
|
-
and "exports/" in n
|
|
1259
|
-
and not any(
|
|
1260
|
-
p in n.lower()
|
|
1261
|
-
for p in exclude_patterns
|
|
1262
|
-
)
|
|
1263
|
-
]
|
|
1264
|
-
if png_files:
|
|
1265
|
-
# Use first matching PNG
|
|
1266
|
-
preview_path = png_files[0]
|
|
1267
|
-
# Extract the path relative to .d directory
|
|
1268
|
-
if ".plot/" in preview_path:
|
|
1269
|
-
preview_path = preview_path.split(
|
|
1270
|
-
".plot/"
|
|
1271
|
-
)[-1]
|
|
1272
|
-
try:
|
|
1273
|
-
img_data = plot_bundle.read_bytes(
|
|
1274
|
-
preview_path
|
|
1275
|
-
)
|
|
1276
|
-
img = Image.open(
|
|
1277
|
-
io.BytesIO(img_data)
|
|
1278
|
-
)
|
|
1279
|
-
ax = fig.add_axes(
|
|
1280
|
-
[x_frac, y_frac, w_frac, h_frac]
|
|
1281
|
-
)
|
|
1282
|
-
ax.imshow(np.array(img))
|
|
1283
|
-
ax.axis("off")
|
|
1284
|
-
img_loaded = True
|
|
1285
|
-
except Exception as e:
|
|
1286
|
-
print(
|
|
1287
|
-
f"Could not read {preview_path}: {e}"
|
|
1288
|
-
)
|
|
1289
|
-
else:
|
|
1290
|
-
# Directory-based pltz
|
|
1291
|
-
plot_dir = Path(panel_path)
|
|
1292
|
-
exports_dir = plot_dir / "exports"
|
|
1293
|
-
if exports_dir.exists():
|
|
1294
|
-
for png_file in exports_dir.glob("*.png"):
|
|
1295
|
-
name_lower = png_file.name.lower()
|
|
1296
|
-
if not any(
|
|
1297
|
-
p in name_lower
|
|
1298
|
-
for p in exclude_patterns
|
|
1299
|
-
):
|
|
1300
|
-
img = Image.open(png_file)
|
|
1301
|
-
ax = fig.add_axes(
|
|
1302
|
-
[x_frac, y_frac, w_frac, h_frac]
|
|
1303
|
-
)
|
|
1304
|
-
ax.imshow(np.array(img))
|
|
1305
|
-
ax.axis("off")
|
|
1306
|
-
img_loaded = True
|
|
1307
|
-
break
|
|
1308
|
-
if not img_loaded:
|
|
1309
|
-
print(f"No preview found for panel {panel_id}")
|
|
1310
|
-
except Exception as e:
|
|
1311
|
-
print(f"Could not load panel {panel_id}: {e}")
|
|
1312
|
-
|
|
1313
|
-
# Draw panel letter
|
|
1314
|
-
if (
|
|
1315
|
-
panel_id and len(panel_id) <= 2
|
|
1316
|
-
): # Only for short IDs like A, B, C...
|
|
1317
|
-
# Position letter at top-left corner of panel
|
|
1318
|
-
letter_x = x_frac + 0.01
|
|
1319
|
-
letter_y = y_frac + h_frac - 0.02
|
|
1320
|
-
fig.text(
|
|
1321
|
-
letter_x,
|
|
1322
|
-
letter_y,
|
|
1323
|
-
panel_id,
|
|
1324
|
-
fontsize=14,
|
|
1325
|
-
fontweight="bold",
|
|
1326
|
-
color="black",
|
|
1327
|
-
ha="left",
|
|
1328
|
-
va="top",
|
|
1329
|
-
transform=fig.transFigure,
|
|
1330
|
-
bbox=dict(
|
|
1331
|
-
boxstyle="square,pad=0.1",
|
|
1332
|
-
facecolor="white",
|
|
1333
|
-
edgecolor="none",
|
|
1334
|
-
alpha=0.8,
|
|
1335
|
-
),
|
|
1336
|
-
)
|
|
1337
|
-
|
|
1338
|
-
buf = io.BytesIO()
|
|
1339
|
-
fig.savefig(
|
|
1340
|
-
buf,
|
|
1341
|
-
format=fmt if fmt != "jpg" else "jpeg",
|
|
1342
|
-
dpi=dpi,
|
|
1343
|
-
bbox_inches="tight",
|
|
1344
|
-
facecolor="white",
|
|
1345
|
-
pad_inches=0.02,
|
|
1346
|
-
)
|
|
1347
|
-
plt.close(fig)
|
|
1348
|
-
buf.seek(0)
|
|
1349
|
-
|
|
1350
|
-
return send_file(
|
|
1351
|
-
buf,
|
|
1352
|
-
mimetype=mime_types[fmt],
|
|
1353
|
-
as_attachment=True,
|
|
1354
|
-
download_name=f"{figure_name}.{fmt}",
|
|
1355
|
-
)
|
|
1356
|
-
|
|
1357
|
-
# For single pltz files, render from csv_data
|
|
1358
|
-
import matplotlib
|
|
1359
|
-
|
|
1360
|
-
from ._renderer import render_preview_with_bboxes
|
|
1361
|
-
|
|
1362
|
-
matplotlib.use("Agg")
|
|
1363
|
-
import matplotlib.pyplot as plt
|
|
1364
|
-
|
|
1365
|
-
figure_name = "figure"
|
|
1366
|
-
if editor.json_path:
|
|
1367
|
-
figure_name = Path(editor.json_path).stem
|
|
1368
|
-
|
|
1369
|
-
img_data, _, _ = render_preview_with_bboxes(
|
|
1370
|
-
editor.csv_data,
|
|
1371
|
-
editor.current_overrides,
|
|
1372
|
-
metadata=editor.metadata,
|
|
1373
|
-
dark_mode=False,
|
|
1374
|
-
)
|
|
1375
|
-
|
|
1376
|
-
if fmt == "png":
|
|
1377
|
-
import base64
|
|
1378
|
-
|
|
1379
|
-
content = base64.b64decode(img_data)
|
|
1380
|
-
buf = io.BytesIO(content)
|
|
1381
|
-
return send_file(
|
|
1382
|
-
buf,
|
|
1383
|
-
mimetype=mime_types[fmt],
|
|
1384
|
-
as_attachment=True,
|
|
1385
|
-
download_name=f"{figure_name}.{fmt}",
|
|
1386
|
-
)
|
|
1387
|
-
|
|
1388
|
-
# For other formats, re-render
|
|
1389
|
-
from ._plotter import plot_from_csv
|
|
1390
|
-
|
|
1391
|
-
fig, ax = plt.subplots(figsize=(8, 6))
|
|
1392
|
-
plot_from_csv(ax, editor.csv_data, editor.current_overrides)
|
|
1393
|
-
|
|
1394
|
-
buf = io.BytesIO()
|
|
1395
|
-
dpi = 150 if fmt in ["jpeg", "jpg"] else 300
|
|
1396
|
-
fig.savefig(
|
|
1397
|
-
buf,
|
|
1398
|
-
format=fmt if fmt != "jpg" else "jpeg",
|
|
1399
|
-
dpi=dpi,
|
|
1400
|
-
bbox_inches="tight",
|
|
1401
|
-
facecolor="white" if fmt in ["jpeg", "jpg"] else None,
|
|
1402
|
-
)
|
|
1403
|
-
plt.close(fig)
|
|
1404
|
-
buf.seek(0)
|
|
1405
|
-
|
|
1406
|
-
return send_file(
|
|
1407
|
-
buf,
|
|
1408
|
-
mimetype=mime_types[fmt],
|
|
1409
|
-
as_attachment=True,
|
|
1410
|
-
download_name=f"{figure_name}.{fmt}",
|
|
1411
|
-
)
|
|
1412
|
-
|
|
1413
|
-
except Exception as e:
|
|
1414
|
-
import traceback
|
|
1415
|
-
|
|
1416
|
-
return f"Error: {str(e)}\n{traceback.format_exc()}", 500
|
|
1417
|
-
|
|
1418
|
-
@app.route("/download_figz")
|
|
1419
|
-
def download_figz():
|
|
1420
|
-
"""Download as figure bundle (re-editable format)."""
|
|
1421
|
-
try:
|
|
1422
|
-
if not editor.panel_info:
|
|
1423
|
-
return "No panel info available", 404
|
|
1424
|
-
|
|
1425
|
-
bundle_path = editor.panel_info.get("bundle_path")
|
|
1426
|
-
if not bundle_path:
|
|
1427
|
-
return "Bundle path not available", 404
|
|
1428
|
-
|
|
1429
|
-
from pathlib import Path
|
|
1430
|
-
|
|
1431
|
-
from flask import send_file
|
|
1432
|
-
|
|
1433
|
-
# Send the figz file directly (it's already a pltz-compatible format)
|
|
1434
|
-
return send_file(
|
|
1435
|
-
bundle_path,
|
|
1436
|
-
mimetype="application/zip",
|
|
1437
|
-
as_attachment=True,
|
|
1438
|
-
download_name=Path(bundle_path).name,
|
|
1439
|
-
)
|
|
1440
|
-
|
|
1441
|
-
except Exception as e:
|
|
1442
|
-
return str(e), 500
|
|
1443
|
-
|
|
1444
|
-
@app.route("/shutdown", methods=["POST"])
|
|
1445
|
-
def shutdown():
|
|
1446
|
-
"""Shutdown the server."""
|
|
1447
|
-
func = request.environ.get("werkzeug.server.shutdown")
|
|
1448
|
-
if func is None:
|
|
1449
|
-
raise RuntimeError("Not running with Werkzeug Server")
|
|
1450
|
-
func()
|
|
1451
|
-
return jsonify({"status": "shutdown"})
|
|
1452
|
-
|
|
1453
|
-
@app.route("/stats")
|
|
1454
|
-
def stats():
|
|
1455
|
-
"""Return statistical test results from figure metadata."""
|
|
1456
|
-
stats_data = editor.metadata.get("stats", [])
|
|
1457
|
-
stats_summary = editor.metadata.get("stats_summary", None)
|
|
1458
|
-
return jsonify(
|
|
1459
|
-
{
|
|
1460
|
-
"stats": stats_data,
|
|
1461
|
-
"stats_summary": stats_summary,
|
|
1462
|
-
"has_stats": len(stats_data) > 0,
|
|
1463
|
-
}
|
|
1464
|
-
)
|
|
1465
|
-
|
|
1466
|
-
# Open browser after short delay
|
|
1467
|
-
def open_browser():
|
|
1468
|
-
import time
|
|
1469
|
-
|
|
1470
|
-
time.sleep(0.5)
|
|
1471
|
-
webbrowser.open(f"http://127.0.0.1:{self.port}")
|
|
1472
|
-
|
|
1473
|
-
threading.Thread(target=open_browser, daemon=True).start()
|
|
1474
|
-
|
|
1475
|
-
print(f"Starting SciTeX Figure Editor at http://127.0.0.1:{self.port}")
|
|
1476
|
-
print("Press Ctrl+C to stop")
|
|
1477
|
-
|
|
1478
|
-
# Note: use_reloader=False because the reloader re-runs the entire script
|
|
1479
|
-
# which causes infinite loops when the demo generates figures
|
|
1480
|
-
# Templates are rebuilt on each page refresh anyway
|
|
1481
|
-
app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
def _extract_bboxes_from_metadata(
|
|
1485
|
-
metadata: Dict[str, Any],
|
|
1486
|
-
display_width: Optional[float] = None,
|
|
1487
|
-
display_height: Optional[float] = None,
|
|
1488
|
-
) -> Dict[str, Any]:
|
|
1489
|
-
"""Extract element bounding boxes from pltz metadata.
|
|
1490
|
-
|
|
1491
|
-
Builds bboxes from selectable_regions in the metadata for click detection.
|
|
1492
|
-
This allows the editor to highlight elements when clicked.
|
|
1493
|
-
|
|
1494
|
-
Coordinate system (new layered format):
|
|
1495
|
-
- selectable_regions bbox_px: Already in final image space (figure_px)
|
|
1496
|
-
- Display size: Actual displayed image size (PNG pixels or SVG viewBox)
|
|
1497
|
-
- Scale = display_size / figure_px (usually 1:1, but may differ for scaled display)
|
|
1498
|
-
|
|
1499
|
-
Parameters
|
|
1500
|
-
----------
|
|
1501
|
-
metadata : dict
|
|
1502
|
-
The pltz JSON metadata containing selectable_regions
|
|
1503
|
-
display_width : float, optional
|
|
1504
|
-
Actual display image width (from PNG size or SVG viewBox)
|
|
1505
|
-
display_height : float, optional
|
|
1506
|
-
Actual display image height (from PNG size or SVG viewBox)
|
|
1507
|
-
|
|
1508
|
-
Returns
|
|
1509
|
-
-------
|
|
1510
|
-
dict
|
|
1511
|
-
Mapping of element IDs to their bounding box coordinates (in display pixels)
|
|
1512
|
-
"""
|
|
1513
|
-
bboxes = {}
|
|
1514
|
-
selectable = metadata.get("selectable_regions", {})
|
|
1515
|
-
|
|
1516
|
-
# Figure dimensions from new layered format (bbox_px are in this space)
|
|
1517
|
-
figure_px = metadata.get("figure_px", [])
|
|
1518
|
-
if isinstance(figure_px, list) and len(figure_px) >= 2:
|
|
1519
|
-
fig_width = figure_px[0]
|
|
1520
|
-
fig_height = figure_px[1]
|
|
1521
|
-
else:
|
|
1522
|
-
# Fallback for old format: try hit_regions.path_data.figure
|
|
1523
|
-
hit_regions = metadata.get("hit_regions", {})
|
|
1524
|
-
path_data = hit_regions.get("path_data", {})
|
|
1525
|
-
orig_fig = path_data.get("figure", {})
|
|
1526
|
-
fig_width = orig_fig.get("width_px", 944)
|
|
1527
|
-
fig_height = orig_fig.get("height_px", 803)
|
|
1528
|
-
|
|
1529
|
-
# Use actual display dimensions if provided, else use figure_px
|
|
1530
|
-
if display_width is None:
|
|
1531
|
-
display_width = fig_width
|
|
1532
|
-
if display_height is None:
|
|
1533
|
-
display_height = fig_height
|
|
1534
|
-
|
|
1535
|
-
# Scale factor: display / figure_px
|
|
1536
|
-
# Usually 1:1 since display is the same PNG, but may differ for scaled display
|
|
1537
|
-
scale_x = display_width / fig_width if fig_width > 0 else 1
|
|
1538
|
-
scale_y = display_height / fig_height if fig_height > 0 else 1
|
|
1539
|
-
|
|
1540
|
-
# Helper to convert coords to display pixels
|
|
1541
|
-
def to_display_bbox(bbox, is_list=True):
|
|
1542
|
-
"""Convert bbox to display pixels (apply scaling if display != figure_px).
|
|
1543
|
-
|
|
1544
|
-
Parameters
|
|
1545
|
-
----------
|
|
1546
|
-
bbox : list or dict
|
|
1547
|
-
Bbox coordinates [x0, y0, x1, y1] or dict with keys
|
|
1548
|
-
is_list : bool
|
|
1549
|
-
Whether bbox is a list (True) or dict (False)
|
|
1550
|
-
"""
|
|
1551
|
-
if is_list:
|
|
1552
|
-
x0, y0, x1, y1 = bbox[0], bbox[1], bbox[2], bbox[3]
|
|
1553
|
-
else:
|
|
1554
|
-
x0 = bbox.get("x0", 0)
|
|
1555
|
-
y0 = bbox.get("y0", 0)
|
|
1556
|
-
x1 = bbox.get("x1", bbox.get("x0", 0) + bbox.get("width", 0))
|
|
1557
|
-
y1 = bbox.get("y1", bbox.get("y0", 0) + bbox.get("height", 0))
|
|
1558
|
-
|
|
1559
|
-
# Scale to display coords (usually 1:1)
|
|
1560
|
-
disp_x0 = x0 * scale_x
|
|
1561
|
-
disp_x1 = x1 * scale_x
|
|
1562
|
-
disp_y0 = y0 * scale_y
|
|
1563
|
-
disp_y1 = y1 * scale_y
|
|
1564
|
-
|
|
1565
|
-
return {
|
|
1566
|
-
"x0": disp_x0,
|
|
1567
|
-
"y0": disp_y0,
|
|
1568
|
-
"x1": disp_x1,
|
|
1569
|
-
"y1": disp_y1,
|
|
1570
|
-
"x": disp_x0,
|
|
1571
|
-
"y": disp_y0,
|
|
1572
|
-
"width": disp_x1 - disp_x0,
|
|
1573
|
-
"height": disp_y1 - disp_y0,
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
# Extract from selectable_regions.axes
|
|
1577
|
-
axes_regions = selectable.get("axes", [])
|
|
1578
|
-
for ax_idx, ax in enumerate(axes_regions):
|
|
1579
|
-
ax_key = f"ax_{ax_idx:02d}"
|
|
1580
|
-
|
|
1581
|
-
# Title
|
|
1582
|
-
title = ax.get("title", {})
|
|
1583
|
-
if title and "bbox_px" in title:
|
|
1584
|
-
bbox_disp = to_display_bbox(title["bbox_px"])
|
|
1585
|
-
bboxes[f"{ax_key}_title"] = {
|
|
1586
|
-
**bbox_disp,
|
|
1587
|
-
"type": "title",
|
|
1588
|
-
"text": title.get("text", ""),
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
# X label
|
|
1592
|
-
xlabel = ax.get("xlabel", {})
|
|
1593
|
-
if xlabel and "bbox_px" in xlabel:
|
|
1594
|
-
bbox_disp = to_display_bbox(xlabel["bbox_px"])
|
|
1595
|
-
bboxes[f"{ax_key}_xlabel"] = {
|
|
1596
|
-
**bbox_disp,
|
|
1597
|
-
"type": "xlabel",
|
|
1598
|
-
"text": xlabel.get("text", ""),
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
# Y label
|
|
1602
|
-
ylabel = ax.get("ylabel", {})
|
|
1603
|
-
if ylabel and "bbox_px" in ylabel:
|
|
1604
|
-
bbox_disp = to_display_bbox(ylabel["bbox_px"])
|
|
1605
|
-
bboxes[f"{ax_key}_ylabel"] = {
|
|
1606
|
-
**bbox_disp,
|
|
1607
|
-
"type": "ylabel",
|
|
1608
|
-
"text": ylabel.get("text", ""),
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
# Legend
|
|
1612
|
-
legend = ax.get("legend", {})
|
|
1613
|
-
if legend and "bbox_px" in legend:
|
|
1614
|
-
bbox_disp = to_display_bbox(legend["bbox_px"])
|
|
1615
|
-
bboxes[f"{ax_key}_legend"] = {
|
|
1616
|
-
**bbox_disp,
|
|
1617
|
-
"type": "legend",
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
# X-axis spine
|
|
1621
|
-
xaxis = ax.get("xaxis", {})
|
|
1622
|
-
if xaxis:
|
|
1623
|
-
spine = xaxis.get("spine", {})
|
|
1624
|
-
if spine and "bbox_px" in spine:
|
|
1625
|
-
bbox_disp = to_display_bbox(spine["bbox_px"])
|
|
1626
|
-
bboxes[f"{ax_key}_xaxis_spine"] = {
|
|
1627
|
-
**bbox_disp,
|
|
1628
|
-
"type": "xaxis",
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
# Y-axis spine
|
|
1632
|
-
yaxis = ax.get("yaxis", {})
|
|
1633
|
-
if yaxis:
|
|
1634
|
-
spine = yaxis.get("spine", {})
|
|
1635
|
-
if spine and "bbox_px" in spine:
|
|
1636
|
-
bbox_disp = to_display_bbox(spine["bbox_px"])
|
|
1637
|
-
bboxes[f"{ax_key}_yaxis_spine"] = {
|
|
1638
|
-
**bbox_disp,
|
|
1639
|
-
"type": "yaxis",
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
# Extract traces from artists (top-level in new format, or hit_regions.path_data in old)
|
|
1643
|
-
artists = metadata.get("artists", [])
|
|
1644
|
-
if not artists:
|
|
1645
|
-
# Fallback for old format
|
|
1646
|
-
hit_regions = metadata.get("hit_regions", {})
|
|
1647
|
-
path_data = hit_regions.get("path_data", {})
|
|
1648
|
-
artists = path_data.get("artists", [])
|
|
1649
|
-
|
|
1650
|
-
for artist in artists:
|
|
1651
|
-
artist_id = artist.get("id", 0)
|
|
1652
|
-
artist_type = artist.get("type", "line")
|
|
1653
|
-
bbox_px = artist.get("bbox_px", {})
|
|
1654
|
-
if bbox_px:
|
|
1655
|
-
bbox_disp = to_display_bbox(bbox_px, is_list=False)
|
|
1656
|
-
trace_entry = {
|
|
1657
|
-
**bbox_disp,
|
|
1658
|
-
"type": artist_type,
|
|
1659
|
-
"label": artist.get("label", f"Trace {artist_id}"),
|
|
1660
|
-
"element_type": artist_type,
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
# Include scaled path points for line proximity detection
|
|
1664
|
-
path_px = artist.get("path_px", [])
|
|
1665
|
-
if path_px:
|
|
1666
|
-
scaled_points = [
|
|
1667
|
-
[pt[0] * scale_x, pt[1] * scale_y] for pt in path_px if len(pt) >= 2
|
|
1668
|
-
]
|
|
1669
|
-
trace_entry["points"] = scaled_points
|
|
1670
|
-
|
|
1671
|
-
bboxes[f"trace_{artist_id}"] = trace_entry
|
|
1672
|
-
|
|
1673
|
-
# Add metadata for JavaScript to understand the coordinate system
|
|
1674
|
-
bboxes["_meta"] = {
|
|
1675
|
-
"display_width": display_width,
|
|
1676
|
-
"display_height": display_height,
|
|
1677
|
-
"figure_px_width": fig_width,
|
|
1678
|
-
"figure_px_height": fig_height,
|
|
1679
|
-
"scale_x": scale_x,
|
|
1680
|
-
"scale_y": scale_y,
|
|
1681
|
-
# Note: With new layered format, bbox_px are already in final image space
|
|
1682
|
-
# so scale is typically 1:1 (unless display is resized)
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
return bboxes
|
|
2
|
+
# Timestamp: "2026-01-24 (ywatanabe)"
|
|
3
|
+
# File: /home/ywatanabe/proj/scitex-python/src/scitex/canvas/editor/flask_editor/_core.py
|
|
4
|
+
|
|
5
|
+
"""Core WebEditor class for Flask-based figure editing.
|
|
6
|
+
|
|
7
|
+
This module re-exports the WebEditor class and helper functions from the
|
|
8
|
+
_core package for backward compatibility. The actual implementation is
|
|
9
|
+
in the _core/ subpackage.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._core import (
|
|
13
|
+
WebEditor,
|
|
14
|
+
_extract_bboxes_from_metadata,
|
|
15
|
+
compose_panels_to_figure,
|
|
16
|
+
export_composed_figure,
|
|
17
|
+
extract_bboxes_from_metadata,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"WebEditor",
|
|
22
|
+
"extract_bboxes_from_metadata",
|
|
23
|
+
"_extract_bboxes_from_metadata",
|
|
24
|
+
"export_composed_figure",
|
|
25
|
+
"compose_panels_to_figure",
|
|
26
|
+
]
|
|
1686
27
|
|
|
1687
28
|
|
|
1688
29
|
# EOF
|