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.
Files changed (107) hide show
  1. scitex/__init__.py +68 -61
  2. scitex/_mcp_tools/introspect.py +42 -23
  3. scitex/_mcp_tools/template.py +24 -0
  4. scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +30 -1550
  5. scitex/ai/classification/timeseries/_sliding_window_core.py +467 -0
  6. scitex/ai/classification/timeseries/_sliding_window_plotting.py +369 -0
  7. scitex/audio/__init__.py +2 -2
  8. scitex/audio/_tts.py +18 -10
  9. scitex/audio/engines/base.py +17 -10
  10. scitex/audio/engines/elevenlabs_engine.py +1 -1
  11. scitex/canvas/editor/flask_editor/_core/__init__.py +27 -0
  12. scitex/canvas/editor/flask_editor/_core/_bbox_extraction.py +200 -0
  13. scitex/canvas/editor/flask_editor/_core/_editor.py +173 -0
  14. scitex/canvas/editor/flask_editor/_core/_export_helpers.py +353 -0
  15. scitex/canvas/editor/flask_editor/_core/_routes_basic.py +190 -0
  16. scitex/canvas/editor/flask_editor/_core/_routes_export.py +332 -0
  17. scitex/canvas/editor/flask_editor/_core/_routes_panels.py +252 -0
  18. scitex/canvas/editor/flask_editor/_core/_routes_save.py +218 -0
  19. scitex/canvas/editor/flask_editor/_core.py +25 -1684
  20. scitex/cli/introspect.py +112 -74
  21. scitex/cli/main.py +2 -0
  22. scitex/cli/plt.py +357 -0
  23. scitex/cli/repro.py +15 -8
  24. scitex/cli/resource.py +15 -8
  25. scitex/cli/scholar/__init__.py +15 -8
  26. scitex/cli/social.py +6 -6
  27. scitex/cli/stats.py +15 -8
  28. scitex/cli/template.py +129 -12
  29. scitex/cli/tex.py +15 -8
  30. scitex/cli/writer.py +15 -8
  31. scitex/cloud/__init__.py +41 -2
  32. scitex/config/_env_registry.py +84 -19
  33. scitex/context/__init__.py +22 -0
  34. scitex/dev/__init__.py +20 -1
  35. scitex/gen/__init__.py +50 -14
  36. scitex/gen/_list_packages.py +4 -4
  37. scitex/introspect/__init__.py +16 -9
  38. scitex/introspect/_core.py +7 -8
  39. scitex/{gen/_inspect_module.py → introspect/_list_api.py} +43 -54
  40. scitex/introspect/_mcp/__init__.py +10 -6
  41. scitex/introspect/_mcp/handlers.py +37 -12
  42. scitex/introspect/_members.py +7 -3
  43. scitex/introspect/_signature.py +3 -3
  44. scitex/introspect/_source.py +2 -2
  45. scitex/io/_save.py +1 -2
  46. scitex/logging/_formatters.py +19 -9
  47. scitex/mcp_server.py +1 -1
  48. scitex/os/__init__.py +4 -0
  49. scitex/{gen → os}/_check_host.py +4 -5
  50. scitex/plt/__init__.py +11 -14
  51. scitex/session/__init__.py +26 -7
  52. scitex/session/_decorator.py +1 -1
  53. scitex/sh/__init__.py +7 -4
  54. scitex/social/__init__.py +10 -8
  55. scitex/stats/_mcp/_handlers/__init__.py +31 -0
  56. scitex/stats/_mcp/_handlers/_corrections.py +113 -0
  57. scitex/stats/_mcp/_handlers/_descriptive.py +78 -0
  58. scitex/stats/_mcp/_handlers/_effect_size.py +106 -0
  59. scitex/stats/_mcp/_handlers/_format.py +94 -0
  60. scitex/stats/_mcp/_handlers/_normality.py +110 -0
  61. scitex/stats/_mcp/_handlers/_posthoc.py +224 -0
  62. scitex/stats/_mcp/_handlers/_power.py +247 -0
  63. scitex/stats/_mcp/_handlers/_recommend.py +102 -0
  64. scitex/stats/_mcp/_handlers/_run_test.py +279 -0
  65. scitex/stats/_mcp/_handlers/_stars.py +48 -0
  66. scitex/stats/_mcp/handlers.py +19 -1171
  67. scitex/stats/auto/_stat_style.py +175 -0
  68. scitex/stats/auto/_style_definitions.py +411 -0
  69. scitex/stats/auto/_styles.py +22 -620
  70. scitex/stats/descriptive/__init__.py +11 -8
  71. scitex/stats/descriptive/_ci.py +39 -0
  72. scitex/stats/power/_power.py +15 -4
  73. scitex/str/__init__.py +2 -1
  74. scitex/str/_title_case.py +63 -0
  75. scitex/template/__init__.py +25 -10
  76. scitex/template/_code_templates.py +147 -0
  77. scitex/template/_mcp/handlers.py +81 -0
  78. scitex/template/_mcp/tool_schemas.py +55 -0
  79. scitex/template/_templates/__init__.py +51 -0
  80. scitex/template/_templates/audio.py +233 -0
  81. scitex/template/_templates/canvas.py +312 -0
  82. scitex/template/_templates/capture.py +268 -0
  83. scitex/template/_templates/config.py +43 -0
  84. scitex/template/_templates/diagram.py +294 -0
  85. scitex/template/_templates/io.py +107 -0
  86. scitex/template/_templates/module.py +53 -0
  87. scitex/template/_templates/plt.py +202 -0
  88. scitex/template/_templates/scholar.py +267 -0
  89. scitex/template/_templates/session.py +130 -0
  90. scitex/template/_templates/session_minimal.py +43 -0
  91. scitex/template/_templates/session_plot.py +67 -0
  92. scitex/template/_templates/session_stats.py +77 -0
  93. scitex/template/_templates/stats.py +323 -0
  94. scitex/template/_templates/writer.py +296 -0
  95. scitex/ui/_backends/_email.py +10 -2
  96. scitex/ui/_backends/_webhook.py +5 -1
  97. scitex/web/_search_pubmed.py +10 -6
  98. {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/METADATA +1 -1
  99. {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/RECORD +105 -64
  100. scitex/gen/_ci.py +0 -12
  101. scitex/gen/_title_case.py +0 -89
  102. /scitex/{gen → context}/_detect_environment.py +0 -0
  103. /scitex/{gen → context}/_get_notebook_path.py +0 -0
  104. /scitex/{gen/_shell.py → sh/_shell_legacy.py} +0 -0
  105. {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/WHEEL +0 -0
  106. {scitex-2.15.1.dist-info → scitex-2.15.2.dist-info}/entry_points.txt +0 -0
  107. {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
- # -*- coding: utf-8 -*-
3
- # File: ./src/scitex/vis/editor/flask_editor/core.py
4
- """Core WebEditor class for Flask-based figure editing."""
5
-
6
- import base64
7
- import copy
8
- import json
9
- import threading
10
- import webbrowser
11
- from pathlib import Path
12
- from typing import Any, Dict, Optional
13
-
14
- from ._utils import check_port_available, find_available_port, kill_process_on_port
15
- from .templates import build_html_template
16
-
17
-
18
- class WebEditor:
19
- """
20
- Browser-based figure editor using Flask.
21
-
22
- Features:
23
- - Displays existing PNG from plot bundle (no re-rendering)
24
- - Hitmap-based element selection for precise clicking
25
- - Property editors with sliders and color pickers
26
- - Save to .manual.json
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