scitex 2.7.3__py3-none-any.whl → 2.8.1__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 (160) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/dev/plt/__init__.py +0 -0
  3. scitex/dev/plt/plot_mpl_axhline.py +0 -0
  4. scitex/dev/plt/plot_mpl_axhspan.py +0 -0
  5. scitex/dev/plt/plot_mpl_axvline.py +0 -0
  6. scitex/dev/plt/plot_mpl_axvspan.py +0 -0
  7. scitex/dev/plt/plot_mpl_bar.py +0 -0
  8. scitex/dev/plt/plot_mpl_barh.py +0 -0
  9. scitex/dev/plt/plot_mpl_boxplot.py +0 -0
  10. scitex/dev/plt/plot_mpl_contour.py +0 -0
  11. scitex/dev/plt/plot_mpl_contourf.py +0 -0
  12. scitex/dev/plt/plot_mpl_errorbar.py +0 -0
  13. scitex/dev/plt/plot_mpl_eventplot.py +0 -0
  14. scitex/dev/plt/plot_mpl_fill.py +0 -0
  15. scitex/dev/plt/plot_mpl_fill_between.py +0 -0
  16. scitex/dev/plt/plot_mpl_hexbin.py +0 -0
  17. scitex/dev/plt/plot_mpl_hist.py +0 -0
  18. scitex/dev/plt/plot_mpl_hist2d.py +0 -0
  19. scitex/dev/plt/plot_mpl_imshow.py +0 -0
  20. scitex/dev/plt/plot_mpl_pcolormesh.py +0 -0
  21. scitex/dev/plt/plot_mpl_pie.py +0 -0
  22. scitex/dev/plt/plot_mpl_plot.py +0 -0
  23. scitex/dev/plt/plot_mpl_quiver.py +0 -0
  24. scitex/dev/plt/plot_mpl_scatter.py +0 -0
  25. scitex/dev/plt/plot_mpl_stackplot.py +0 -0
  26. scitex/dev/plt/plot_mpl_stem.py +0 -0
  27. scitex/dev/plt/plot_mpl_step.py +0 -0
  28. scitex/dev/plt/plot_mpl_violinplot.py +0 -0
  29. scitex/dev/plt/plot_sns_barplot.py +0 -0
  30. scitex/dev/plt/plot_sns_boxplot.py +0 -0
  31. scitex/dev/plt/plot_sns_heatmap.py +0 -0
  32. scitex/dev/plt/plot_sns_histplot.py +0 -0
  33. scitex/dev/plt/plot_sns_kdeplot.py +0 -0
  34. scitex/dev/plt/plot_sns_lineplot.py +0 -0
  35. scitex/dev/plt/plot_sns_scatterplot.py +0 -0
  36. scitex/dev/plt/plot_sns_stripplot.py +0 -0
  37. scitex/dev/plt/plot_sns_swarmplot.py +0 -0
  38. scitex/dev/plt/plot_sns_violinplot.py +0 -0
  39. scitex/dev/plt/plot_stx_bar.py +0 -0
  40. scitex/dev/plt/plot_stx_barh.py +0 -0
  41. scitex/dev/plt/plot_stx_box.py +0 -0
  42. scitex/dev/plt/plot_stx_boxplot.py +0 -0
  43. scitex/dev/plt/plot_stx_conf_mat.py +0 -0
  44. scitex/dev/plt/plot_stx_contour.py +0 -0
  45. scitex/dev/plt/plot_stx_ecdf.py +0 -0
  46. scitex/dev/plt/plot_stx_errorbar.py +0 -0
  47. scitex/dev/plt/plot_stx_fill_between.py +0 -0
  48. scitex/dev/plt/plot_stx_fillv.py +0 -0
  49. scitex/dev/plt/plot_stx_heatmap.py +0 -0
  50. scitex/dev/plt/plot_stx_image.py +0 -0
  51. scitex/dev/plt/plot_stx_imshow.py +0 -0
  52. scitex/dev/plt/plot_stx_joyplot.py +0 -0
  53. scitex/dev/plt/plot_stx_kde.py +0 -0
  54. scitex/dev/plt/plot_stx_line.py +0 -0
  55. scitex/dev/plt/plot_stx_mean_ci.py +0 -0
  56. scitex/dev/plt/plot_stx_mean_std.py +0 -0
  57. scitex/dev/plt/plot_stx_median_iqr.py +0 -0
  58. scitex/dev/plt/plot_stx_raster.py +0 -0
  59. scitex/dev/plt/plot_stx_rectangle.py +0 -0
  60. scitex/dev/plt/plot_stx_scatter.py +0 -0
  61. scitex/dev/plt/plot_stx_shaded_line.py +0 -0
  62. scitex/dev/plt/plot_stx_violin.py +0 -0
  63. scitex/dev/plt/plot_stx_violinplot.py +0 -0
  64. scitex/diagram/README.md +197 -0
  65. scitex/diagram/__init__.py +48 -0
  66. scitex/diagram/_compile.py +312 -0
  67. scitex/diagram/_diagram.py +355 -0
  68. scitex/diagram/_presets.py +173 -0
  69. scitex/diagram/_schema.py +182 -0
  70. scitex/diagram/_split.py +278 -0
  71. scitex/fig/editor/__init__.py +5 -2
  72. scitex/fig/editor/_dearpygui_editor.py +1 -1
  73. scitex/fig/editor/_mpl_editor.py +1 -1
  74. scitex/fig/editor/_qt_editor.py +1 -1
  75. scitex/fig/editor/_tkinter_editor.py +1 -1
  76. scitex/fig/editor/edit/__init__.py +50 -0
  77. scitex/fig/editor/edit/backend_detector.py +109 -0
  78. scitex/fig/editor/edit/bundle_resolver.py +240 -0
  79. scitex/fig/editor/edit/editor_launcher.py +239 -0
  80. scitex/fig/editor/edit/manual_handler.py +53 -0
  81. scitex/fig/editor/edit/panel_loader.py +232 -0
  82. scitex/fig/editor/edit/path_resolver.py +67 -0
  83. scitex/fig/editor/flask_editor/_bbox.py +23 -0
  84. scitex/fig/editor/flask_editor/_core.py +908 -103
  85. scitex/fig/editor/flask_editor/_renderer.py +74 -0
  86. scitex/fig/editor/flask_editor/static/css/base/reset.css +41 -0
  87. scitex/fig/editor/flask_editor/static/css/base/typography.css +16 -0
  88. scitex/fig/editor/flask_editor/static/css/base/variables.css +85 -0
  89. scitex/fig/editor/flask_editor/static/css/components/buttons.css +217 -0
  90. scitex/fig/editor/flask_editor/static/css/components/context-menu.css +93 -0
  91. scitex/fig/editor/flask_editor/static/css/components/dropdown.css +57 -0
  92. scitex/fig/editor/flask_editor/static/css/components/forms.css +112 -0
  93. scitex/fig/editor/flask_editor/static/css/components/modal.css +59 -0
  94. scitex/fig/editor/flask_editor/static/css/components/sections.css +212 -0
  95. scitex/fig/editor/flask_editor/static/css/features/canvas.css +176 -0
  96. scitex/fig/editor/flask_editor/static/css/features/element-inspector.css +190 -0
  97. scitex/fig/editor/flask_editor/static/css/features/loading.css +59 -0
  98. scitex/fig/editor/flask_editor/static/css/features/overlay.css +45 -0
  99. scitex/fig/editor/flask_editor/static/css/features/panel-grid.css +95 -0
  100. scitex/fig/editor/flask_editor/static/css/features/selection.css +101 -0
  101. scitex/fig/editor/flask_editor/static/css/features/statistics.css +138 -0
  102. scitex/fig/editor/flask_editor/static/css/index.css +31 -0
  103. scitex/fig/editor/flask_editor/static/css/layout/container.css +7 -0
  104. scitex/fig/editor/flask_editor/static/css/layout/controls.css +56 -0
  105. scitex/fig/editor/flask_editor/static/css/layout/preview.css +78 -0
  106. scitex/fig/editor/flask_editor/static/js/alignment/axis.js +314 -0
  107. scitex/fig/editor/flask_editor/static/js/alignment/basic.js +107 -0
  108. scitex/fig/editor/flask_editor/static/js/alignment/distribute.js +54 -0
  109. scitex/fig/editor/flask_editor/static/js/canvas/canvas.js +172 -0
  110. scitex/fig/editor/flask_editor/static/js/canvas/dragging.js +258 -0
  111. scitex/fig/editor/flask_editor/static/js/canvas/resize.js +48 -0
  112. scitex/fig/editor/flask_editor/static/js/canvas/selection.js +71 -0
  113. scitex/fig/editor/flask_editor/static/js/core/api.js +288 -0
  114. scitex/fig/editor/flask_editor/static/js/core/state.js +143 -0
  115. scitex/fig/editor/flask_editor/static/js/core/utils.js +245 -0
  116. scitex/fig/editor/flask_editor/static/js/dev/element-inspector.js +992 -0
  117. scitex/fig/editor/flask_editor/static/js/editor/bbox.js +339 -0
  118. scitex/fig/editor/flask_editor/static/js/editor/element-drag.js +286 -0
  119. scitex/fig/editor/flask_editor/static/js/editor/overlay.js +371 -0
  120. scitex/fig/editor/flask_editor/static/js/editor/preview.js +293 -0
  121. scitex/fig/editor/flask_editor/static/js/main.js +426 -0
  122. scitex/fig/editor/flask_editor/static/js/shortcuts/context-menu.js +152 -0
  123. scitex/fig/editor/flask_editor/static/js/shortcuts/keyboard.js +265 -0
  124. scitex/fig/editor/flask_editor/static/js/ui/controls.js +184 -0
  125. scitex/fig/editor/flask_editor/static/js/ui/download.js +57 -0
  126. scitex/fig/editor/flask_editor/static/js/ui/help.js +100 -0
  127. scitex/fig/editor/flask_editor/static/js/ui/theme.js +34 -0
  128. scitex/fig/editor/flask_editor/templates/__init__.py +95 -5
  129. scitex/fig/editor/flask_editor/templates/_html.py +27 -9
  130. scitex/fig/editor/flask_editor/templates/_scripts.py +1928 -131
  131. scitex/fig/editor/flask_editor/templates/_styles.py +363 -51
  132. scitex/fig/io/_bundle.py +97 -12
  133. scitex/io/__init__.py +12 -0
  134. scitex/io/_bundle.py +69 -10
  135. scitex/io/_zip_bundle.py +439 -0
  136. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +0 -0
  137. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +0 -0
  138. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +0 -0
  139. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +0 -0
  140. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +0 -0
  141. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +0 -0
  142. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +0 -0
  143. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +0 -0
  144. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +0 -0
  145. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +0 -0
  146. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +0 -0
  147. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +0 -0
  148. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +0 -0
  149. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +0 -0
  150. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +0 -0
  151. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +0 -0
  152. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +0 -0
  153. scitex/plt/io/_layered_bundle.py +0 -0
  154. scitex/schema/_plot.py +0 -0
  155. {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/METADATA +1 -1
  156. {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/RECORD +78 -22
  157. scitex/fig/editor/_edit.py +0 -751
  158. {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/WHEEL +0 -0
  159. {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/entry_points.txt +0 -0
  160. {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/licenses/LICENSE +0 -0
@@ -92,9 +92,193 @@ class WebEditor:
92
92
  print(f"Warning: Port {self._requested_port} may still be in use")
93
93
  self.port = self._requested_port
94
94
 
95
- app = Flask(__name__)
95
+ # Configure Flask with static folder path
96
+ import os
97
+ static_folder = os.path.join(os.path.dirname(__file__), 'static')
98
+ app = Flask(__name__, static_folder=static_folder, static_url_path='/static')
96
99
  editor = self
97
100
 
101
+ def _export_composed_figure(editor, formats=["png", "svg"], dpi=150):
102
+ """Helper to compose and export figure to bundle."""
103
+ from scitex.io import ZipBundle
104
+ from PIL import Image
105
+ import numpy as np
106
+ import matplotlib
107
+ matplotlib.use('Agg')
108
+ import matplotlib.pyplot as plt
109
+ import json as json_module
110
+ import io
111
+ import zipfile
112
+
113
+ if not editor.panel_info:
114
+ return {"success": False, "error": "No panel info"}
115
+
116
+ bundle_path = editor.panel_info.get("bundle_path")
117
+ figz_dir = editor.panel_info.get("figz_dir")
118
+
119
+ if not bundle_path and not figz_dir:
120
+ return {"success": False, "error": "No bundle path"}
121
+
122
+ figure_name = Path(bundle_path).stem if bundle_path else (
123
+ Path(figz_dir).stem.replace(".figz.d", "") if figz_dir else "figure"
124
+ )
125
+
126
+ # Read spec.json for layout and layout.json for position overrides
127
+ spec = {}
128
+ layout_overrides = {}
129
+ if bundle_path:
130
+ try:
131
+ with ZipBundle(bundle_path, mode="r") as bundle:
132
+ spec = bundle.read_json("spec.json")
133
+ try:
134
+ layout_overrides = bundle.read_json("layout.json")
135
+ except:
136
+ pass
137
+ except:
138
+ pass
139
+ elif figz_dir:
140
+ spec_path = Path(figz_dir) / "spec.json"
141
+ if spec_path.exists():
142
+ with open(spec_path) as f:
143
+ spec = json_module.load(f)
144
+ layout_path = Path(figz_dir) / "layout.json"
145
+ if layout_path.exists():
146
+ with open(layout_path) as f:
147
+ layout_overrides = json_module.load(f)
148
+
149
+ # Also check in-memory layout overrides
150
+ if editor.panel_info and editor.panel_info.get("layout"):
151
+ layout_overrides = editor.panel_info.get("layout", {})
152
+
153
+ # Get figure dimensions
154
+ fig_width_mm = 180
155
+ fig_height_mm = 120
156
+ if "figure" in spec:
157
+ fig_info = spec.get("figure", {})
158
+ styles = fig_info.get("styles", {})
159
+ size = styles.get("size", {})
160
+ fig_width_mm = size.get("width_mm", 180)
161
+ fig_height_mm = size.get("height_mm", 120)
162
+
163
+ fig_width_in = fig_width_mm / 25.4
164
+ fig_height_in = fig_height_mm / 25.4
165
+
166
+ fig = plt.figure(figsize=(fig_width_in, fig_height_in), dpi=dpi, facecolor='white')
167
+
168
+ # Compose panels
169
+ panels_spec = spec.get("panels", [])
170
+ panel_paths = editor.panel_info.get("panel_paths", [])
171
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [])
172
+
173
+ for panel_spec in panels_spec:
174
+ panel_id = panel_spec.get("id", "")
175
+ pos = panel_spec.get("position", {})
176
+ size = panel_spec.get("size", {})
177
+
178
+ # Skip overview/auxiliary panels (only compose main panels A-Z)
179
+ panel_id_lower = panel_id.lower()
180
+ if any(skip in panel_id_lower for skip in ['overview', 'thumb', 'preview', 'aux']):
181
+ continue
182
+
183
+ # Find panel path first (needed to check layout_overrides)
184
+ panel_path = None
185
+ is_zip = False
186
+ panel_name = None
187
+ for idx, pp in enumerate(panel_paths):
188
+ pp_name = Path(pp).stem.replace(".pltz", "")
189
+ if (pp_name == panel_id or
190
+ pp_name.startswith(f"panel_{panel_id}_") or
191
+ pp_name == f"panel_{panel_id}" or
192
+ f"_{panel_id}_" in pp_name):
193
+ panel_path = pp
194
+ panel_name = Path(pp).name # e.g., "panel_A_twinx.pltz"
195
+ is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else False
196
+ break
197
+
198
+ if not panel_path:
199
+ continue
200
+
201
+ # Check for layout overrides (from layout.json or in-memory)
202
+ override = layout_overrides.get(panel_name, {})
203
+ override_pos = override.get("position", {})
204
+ override_size = override.get("size", {})
205
+
206
+ # Use override positions if available, otherwise use spec
207
+ x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
208
+ y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
209
+ w_mm = override_size.get("width_mm", size.get("width_mm", 60))
210
+ h_mm = override_size.get("height_mm", size.get("height_mm", 40))
211
+
212
+ x_frac = x_mm / fig_width_mm
213
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm
214
+ w_frac = w_mm / fig_width_mm
215
+ h_frac = h_mm / fig_height_mm
216
+
217
+ # Load panel preview
218
+ try:
219
+ # Exclusion patterns for preview selection
220
+ exclude_patterns = ['hitmap', 'overview', 'thumb', 'preview']
221
+
222
+ if is_zip:
223
+ with ZipBundle(panel_path, mode="r") as pltz_bundle:
224
+ with zipfile.ZipFile(panel_path, 'r') as zf:
225
+ png_files = [n for n in zf.namelist()
226
+ if n.endswith('.png')
227
+ and 'exports/' in n
228
+ and not any(p in n.lower() for p in exclude_patterns)]
229
+ if png_files:
230
+ preview_path = png_files[0]
231
+ if '.pltz.d/' in preview_path:
232
+ preview_path = preview_path.split('.pltz.d/')[-1]
233
+ img_data = pltz_bundle.read_bytes(preview_path)
234
+ img = Image.open(io.BytesIO(img_data))
235
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
236
+ ax.imshow(np.array(img))
237
+ ax.axis('off')
238
+ else:
239
+ pltz_dir = Path(panel_path)
240
+ exports_dir = pltz_dir / "exports"
241
+ if exports_dir.exists():
242
+ for png_file in exports_dir.glob("*.png"):
243
+ name_lower = png_file.name.lower()
244
+ if not any(p in name_lower for p in exclude_patterns):
245
+ img = Image.open(png_file)
246
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
247
+ ax.imshow(np.array(img))
248
+ ax.axis('off')
249
+ break
250
+ except Exception as e:
251
+ print(f"Could not load panel {panel_id}: {e}")
252
+
253
+ # Draw panel letter
254
+ if panel_id and len(panel_id) <= 2: # Only for short IDs like A, B, C...
255
+ # Position letter at top-left corner of panel
256
+ letter_x = x_frac + 0.01
257
+ letter_y = y_frac + h_frac - 0.02
258
+ fig.text(letter_x, letter_y, panel_id,
259
+ fontsize=14, fontweight='bold', color='black',
260
+ ha='left', va='top',
261
+ transform=fig.transFigure,
262
+ bbox=dict(boxstyle='square,pad=0.1',
263
+ facecolor='white', edgecolor='none', alpha=0.8))
264
+
265
+ exported = {}
266
+
267
+ # Save to bundle
268
+ if bundle_path:
269
+ with ZipBundle(bundle_path, mode="a") as bundle:
270
+ for fmt in formats:
271
+ buf = io.BytesIO()
272
+ fig.savefig(buf, format=fmt, dpi=dpi, bbox_inches="tight",
273
+ facecolor="white", pad_inches=0.02)
274
+ buf.seek(0)
275
+ export_path = f"exports/{figure_name}.{fmt}"
276
+ bundle.write_bytes(export_path, buf.read())
277
+ exported[fmt] = export_path
278
+
279
+ plt.close(fig)
280
+ return {"success": True, "exported": exported}
281
+
98
282
  @app.route("/")
99
283
  def index():
100
284
  # Rebuild template each time for hot reload support
@@ -147,63 +331,94 @@ class WebEditor:
147
331
 
148
332
  @app.route("/panels")
149
333
  def panels():
150
- """Return all panel images with bboxes for interactive grid view (figz bundles only)."""
151
- from PIL import Image
334
+ """Return all panel images with bboxes for interactive grid view (figz bundles only).
335
+
336
+ Uses smart load_panel_data helper for transparent zip/directory handling.
337
+ Returns layout info from figz spec.json for unified canvas positioning.
338
+ """
152
339
  from ._bbox import extract_bboxes_from_metadata, extract_bboxes_from_geometry_px
340
+ from ..edit import load_panel_data
341
+ import json as json_module
153
342
 
154
343
  if not editor.panel_info:
155
344
  return jsonify({"error": "Not a multi-panel figz bundle"}), 400
156
345
 
157
- figz_dir = Path(editor.panel_info["figz_dir"])
158
346
  panel_names = editor.panel_info["panels"]
347
+ panel_paths = editor.panel_info.get("panel_paths", [])
348
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [False] * len(panel_names))
349
+ figz_dir = Path(editor.panel_info["figz_dir"])
350
+
351
+ if not panel_paths:
352
+ panel_paths = [str(figz_dir / name) for name in panel_names]
353
+
354
+ # Load figz spec.json to get panel layout
355
+ figz_layout = {}
356
+ spec_path = figz_dir / "spec.json"
357
+ if spec_path.exists():
358
+ with open(spec_path) as f:
359
+ figz_spec = json_module.load(f)
360
+ for panel_spec in figz_spec.get("panels", []):
361
+ panel_id = panel_spec.get("id", "")
362
+ figz_layout[panel_id] = {
363
+ "position": panel_spec.get("position", {}),
364
+ "size": panel_spec.get("size", {}),
365
+ }
366
+
159
367
  panel_images = []
160
368
 
161
- for panel_name in panel_names:
162
- panel_dir = figz_dir / panel_name
163
- panel_data = {"name": panel_name.replace(".pltz.d", ""), "image": None, "bboxes": None, "img_size": None}
164
-
165
- # Find PNG in exports/ or root
166
- png_path = None
167
- exports_dir = panel_dir / "exports"
168
- if exports_dir.exists():
169
- for f in exports_dir.glob("*.png"):
170
- if "_hitmap" not in f.name and "_overview" not in f.name:
171
- png_path = f
172
- break
173
- if not png_path:
174
- for f in panel_dir.glob("*.png"):
175
- if "_hitmap" not in f.name and "_overview" not in f.name:
176
- png_path = f
177
- break
178
-
179
- if png_path and png_path.exists():
180
- with open(png_path, "rb") as f:
181
- panel_data["image"] = base64.b64encode(f.read()).decode("utf-8")
182
- img = Image.open(png_path)
183
- panel_data["width"], panel_data["height"] = img.size
184
- panel_data["img_size"] = {"width": img.size[0], "height": img.size[1]}
185
- img.close()
369
+ for idx, panel_name in enumerate(panel_names):
370
+ panel_path = panel_paths[idx]
371
+ is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else None
372
+ display_name = panel_name.replace(".pltz.d", "").replace(".pltz", "")
186
373
 
187
- # Try to load geometry_px.json from cache (has precise pixel coordinates)
188
- geometry_path = panel_dir / "cache" / "geometry_px.json"
189
- if geometry_path.exists():
190
- import json
191
- with open(geometry_path) as f:
192
- geometry_data = json.load(f)
193
- panel_data["bboxes"] = extract_bboxes_from_geometry_px(
194
- geometry_data,
195
- panel_data["img_size"]["width"],
196
- panel_data["img_size"]["height"]
197
- )
374
+ # Use smart helper to load panel data
375
+ loaded = load_panel_data(panel_path, is_zip=is_zip)
376
+
377
+ panel_data = {"name": display_name, "image": None, "bboxes": None, "img_size": None}
378
+
379
+ # Add layout info from figz spec
380
+ if display_name in figz_layout:
381
+ panel_data["layout"] = figz_layout[display_name]
382
+
383
+ if loaded:
384
+ # Get image data
385
+ if loaded.get("is_zip"):
386
+ png_bytes = loaded.get("png_bytes")
387
+ if png_bytes:
388
+ panel_data["image"] = base64.b64encode(png_bytes).decode("utf-8")
198
389
  else:
199
- # Fall back to spec.json extraction
200
- spec_path = panel_dir / "spec.json"
201
- if spec_path.exists():
202
- import json
203
- with open(spec_path) as f:
204
- panel_metadata = json.load(f)
390
+ png_path = loaded.get("png_path")
391
+ if png_path and png_path.exists():
392
+ with open(png_path, "rb") as f:
393
+ panel_data["image"] = base64.b64encode(f.read()).decode("utf-8")
394
+
395
+ # Get image size
396
+ img_size = loaded.get("img_size")
397
+ if img_size:
398
+ panel_data["img_size"] = img_size
399
+ panel_data["width"] = img_size["width"]
400
+ panel_data["height"] = img_size["height"]
401
+ elif loaded.get("png_path"):
402
+ from PIL import Image
403
+ img = Image.open(loaded["png_path"])
404
+ panel_data["img_size"] = {"width": img.size[0], "height": img.size[1]}
405
+ panel_data["width"], panel_data["height"] = img.size
406
+ img.close()
407
+
408
+ # Extract bboxes - prefer geometry_px.json
409
+ if panel_data.get("img_size"):
410
+ geometry_data = loaded.get("geometry_data")
411
+ metadata = loaded.get("metadata", {})
412
+
413
+ if geometry_data:
414
+ panel_data["bboxes"] = extract_bboxes_from_geometry_px(
415
+ geometry_data,
416
+ panel_data["img_size"]["width"],
417
+ panel_data["img_size"]["height"]
418
+ )
419
+ elif metadata:
205
420
  panel_data["bboxes"] = extract_bboxes_from_metadata(
206
- panel_metadata,
421
+ metadata,
207
422
  panel_data["img_size"]["width"],
208
423
  panel_data["img_size"]["height"]
209
424
  )
@@ -213,97 +428,92 @@ class WebEditor:
213
428
  return jsonify({
214
429
  "panels": panel_images,
215
430
  "count": len(panel_images),
431
+ "layout": figz_layout,
216
432
  })
217
433
 
218
434
  @app.route("/switch_panel/<int:panel_index>")
219
435
  def switch_panel(panel_index):
220
436
  """Switch to a different panel in the figz bundle.
221
437
 
222
- Loads the actual PNG from the panel's exports folder instead of re-rendering.
438
+ Uses smart load_panel_data helper for transparent zip/directory handling.
223
439
  """
224
- from PIL import Image
225
- from .._edit import _load_panel_data
226
440
  from ._bbox import extract_bboxes_from_metadata, extract_bboxes_from_geometry_px
441
+ from ..edit import load_panel_data
227
442
 
228
443
  if not editor.panel_info:
229
444
  return jsonify({"error": "Not a multi-panel figz bundle"}), 400
230
445
 
231
446
  panels = editor.panel_info["panels"]
447
+ panel_paths = editor.panel_info.get("panel_paths", [])
448
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [False] * len(panels))
449
+
232
450
  if panel_index < 0 or panel_index >= len(panels):
233
451
  return jsonify({"error": f"Invalid panel index: {panel_index}"}), 400
234
452
 
235
- figz_dir = Path(editor.panel_info["figz_dir"])
236
453
  panel_name = panels[panel_index]
237
- panel_dir = figz_dir / panel_name
454
+ panel_path = panel_paths[panel_index] if panel_paths else str(Path(editor.panel_info["figz_dir"]) / panel_name)
455
+ is_zip = panel_is_zip[panel_index] if panel_index < len(panel_is_zip) else None
238
456
 
239
- # Load the panel's data
240
457
  try:
241
- panel_data = _load_panel_data(panel_dir)
242
- if not panel_data:
243
- return jsonify({"error": f"Could not load panel: {panel_name}"}), 400
458
+ # Use smart helper to load panel data
459
+ loaded = load_panel_data(panel_path, is_zip=is_zip)
244
460
 
245
- # Update editor state
246
- editor.json_path = panel_data["json_path"]
247
- editor.metadata = panel_data["metadata"]
248
- editor.csv_data = panel_data.get("csv_data")
249
- editor.png_path = panel_data.get("png_path")
250
- editor.hitmap_path = panel_data.get("hitmap_path")
251
- editor.panel_info["current_index"] = panel_index
252
-
253
- # Re-extract defaults from new metadata
254
- from .._defaults import get_scitex_defaults, extract_defaults_from_metadata
255
- editor.scitex_defaults = get_scitex_defaults()
256
- editor.metadata_defaults = extract_defaults_from_metadata(editor.metadata)
257
- editor.current_overrides = copy.deepcopy(editor.scitex_defaults)
258
- editor.current_overrides.update(editor.metadata_defaults)
259
- editor.current_overrides.update(editor.manual_overrides)
461
+ if not loaded:
462
+ return jsonify({"error": f"Could not load panel: {panel_name}"}), 400
260
463
 
261
- # Load actual PNG from panel instead of re-rendering
464
+ # Get image data
262
465
  img_data = None
263
- img_size = {"width": 0, "height": 0}
264
- png_path = panel_data.get("png_path")
265
-
266
- if png_path and png_path.exists():
267
- with open(png_path, "rb") as f:
268
- img_data = base64.b64encode(f.read()).decode("utf-8")
269
- img = Image.open(png_path)
270
- img_size = {"width": img.size[0], "height": img.size[1]}
271
- img.close()
466
+ if loaded.get("is_zip"):
467
+ png_bytes = loaded.get("png_bytes")
468
+ if png_bytes:
469
+ img_data = base64.b64encode(png_bytes).decode("utf-8")
272
470
  else:
273
- # Fallback: look for any PNG in exports/
274
- exports_dir = panel_dir / "exports"
275
- if exports_dir.exists():
276
- for f in exports_dir.glob("*.png"):
277
- if "_hitmap" not in f.name and "_overview" not in f.name:
278
- with open(f, "rb") as pf:
279
- img_data = base64.b64encode(pf.read()).decode("utf-8")
280
- img = Image.open(f)
281
- img_size = {"width": img.size[0], "height": img.size[1]}
282
- img.close()
283
- break
471
+ png_path = loaded.get("png_path")
472
+ if png_path and png_path.exists():
473
+ with open(png_path, "rb") as f:
474
+ img_data = base64.b64encode(f.read()).decode("utf-8")
284
475
 
285
476
  if not img_data:
286
477
  return jsonify({"error": f"No PNG found for panel: {panel_name}"}), 400
287
478
 
288
- # Extract bboxes - prefer geometry_px.json for precise coordinates
479
+ # Get image size
480
+ img_size = loaded.get("img_size", {"width": 0, "height": 0})
481
+ if not img_size and loaded.get("png_path"):
482
+ from PIL import Image
483
+ img = Image.open(loaded["png_path"])
484
+ img_size = {"width": img.size[0], "height": img.size[1]}
485
+ img.close()
486
+
487
+ # Extract bboxes - prefer geometry_px.json
289
488
  bboxes = {}
290
- geometry_path = panel_dir / "cache" / "geometry_px.json"
291
- if geometry_path.exists():
292
- with open(geometry_path) as f:
293
- geometry_data = json.load(f)
489
+ geometry_data = loaded.get("geometry_data")
490
+ metadata = loaded.get("metadata", {})
491
+
492
+ if geometry_data and img_size:
294
493
  bboxes = extract_bboxes_from_geometry_px(
295
494
  geometry_data,
296
495
  img_size["width"],
297
- img_size["height"],
496
+ img_size["height"]
298
497
  )
299
- else:
300
- # Fall back to metadata extraction
498
+ elif metadata and img_size:
301
499
  bboxes = extract_bboxes_from_metadata(
302
- editor.metadata,
500
+ metadata,
303
501
  img_size["width"],
304
- img_size["height"],
502
+ img_size["height"]
305
503
  )
306
504
 
505
+ # Update editor state
506
+ editor.metadata = metadata
507
+ editor.panel_info["current_index"] = panel_index
508
+
509
+ # Re-extract defaults from new metadata
510
+ from .._defaults import get_scitex_defaults, extract_defaults_from_metadata
511
+ editor.scitex_defaults = get_scitex_defaults()
512
+ editor.metadata_defaults = extract_defaults_from_metadata(metadata)
513
+ editor.current_overrides = copy.deepcopy(editor.scitex_defaults)
514
+ editor.current_overrides.update(editor.metadata_defaults)
515
+ editor.current_overrides.update(editor.manual_overrides)
516
+
307
517
  return jsonify({
308
518
  "success": True,
309
519
  "panel_name": panel_name,
@@ -368,7 +578,7 @@ class WebEditor:
368
578
  @app.route("/save", methods=["POST"])
369
579
  def save():
370
580
  """Save to .manual.json."""
371
- from .._edit import save_manual_overrides
581
+ from ..edit import save_manual_overrides
372
582
 
373
583
  try:
374
584
  manual_path = save_manual_overrides(
@@ -378,6 +588,601 @@ class WebEditor:
378
588
  except Exception as e:
379
589
  return jsonify({"status": "error", "message": str(e)}), 500
380
590
 
591
+ @app.route("/save_layout", methods=["POST"])
592
+ def save_layout():
593
+ """Save panel layout positions to figz bundle."""
594
+ try:
595
+ data = request.get_json()
596
+ layout = data.get("layout", {})
597
+
598
+ if not layout:
599
+ return jsonify({"success": False, "error": "No layout data provided"})
600
+
601
+ # Check if we have panel_info (figz bundle)
602
+ if not editor.panel_info:
603
+ return jsonify({"success": False, "error": "No panel info available (not a figz bundle)"})
604
+
605
+ bundle_path = editor.panel_info.get("bundle_path")
606
+ if not bundle_path:
607
+ return jsonify({"success": False, "error": "Bundle path not available"})
608
+
609
+ # Update layout in the figz bundle
610
+ from scitex.fig.io import ZipBundle
611
+
612
+ bundle = ZipBundle(bundle_path)
613
+
614
+ # Read existing layout or create new one
615
+ try:
616
+ existing_layout = bundle.read_json("layout.json")
617
+ except:
618
+ existing_layout = {}
619
+
620
+ # Update layout with new positions
621
+ for panel_name, pos in layout.items():
622
+ if panel_name not in existing_layout:
623
+ existing_layout[panel_name] = {}
624
+ if "position" not in existing_layout[panel_name]:
625
+ existing_layout[panel_name]["position"] = {}
626
+ if "size" not in existing_layout[panel_name]:
627
+ existing_layout[panel_name]["size"] = {}
628
+
629
+ # Update position
630
+ existing_layout[panel_name]["position"]["x_mm"] = pos.get("x_mm", 0)
631
+ existing_layout[panel_name]["position"]["y_mm"] = pos.get("y_mm", 0)
632
+
633
+ # Update size if provided
634
+ if "width_mm" in pos:
635
+ existing_layout[panel_name]["size"]["width_mm"] = pos["width_mm"]
636
+ if "height_mm" in pos:
637
+ existing_layout[panel_name]["size"]["height_mm"] = pos["height_mm"]
638
+
639
+ # Save updated layout
640
+ bundle.write_json("layout.json", existing_layout)
641
+
642
+ # Update in-memory panel_info
643
+ editor.panel_info["layout"] = existing_layout
644
+
645
+ # Auto-export composed figure to bundle
646
+ export_result = _export_composed_figure(editor, formats=["png", "svg"])
647
+
648
+ return jsonify({
649
+ "success": True,
650
+ "layout": existing_layout,
651
+ "exported": export_result.get("exported", {})
652
+ })
653
+
654
+ except Exception as e:
655
+ import traceback
656
+ return jsonify({
657
+ "success": False,
658
+ "error": str(e),
659
+ "traceback": traceback.format_exc()
660
+ })
661
+
662
+ @app.route("/save_element_position", methods=["POST"])
663
+ def save_element_position():
664
+ """Save element position (legend/panel_letter) to figz bundle.
665
+
666
+ ONLY legends and panel letters can be repositioned to maintain
667
+ scientific rigor. Data elements are never moved.
668
+ """
669
+ try:
670
+ data = request.get_json()
671
+ element = data.get("element", "")
672
+ panel = data.get("panel", "")
673
+ element_type = data.get("element_type", "")
674
+ position = data.get("position", {})
675
+ snap_name = data.get("snap_name")
676
+
677
+ # Validate element type (whitelist for scientific rigor)
678
+ ALLOWED_TYPES = ["legend", "panel_letter"]
679
+ if element_type not in ALLOWED_TYPES:
680
+ return jsonify({
681
+ "success": False,
682
+ "error": f"Element type '{element_type}' cannot be repositioned (scientific rigor)"
683
+ })
684
+
685
+ if not editor.panel_info:
686
+ return jsonify({"success": False, "error": "No panel info available"})
687
+
688
+ bundle_path = editor.panel_info.get("bundle_path")
689
+ if not bundle_path:
690
+ return jsonify({"success": False, "error": "Bundle path not available"})
691
+
692
+ from scitex.fig.io import ZipBundle
693
+ bundle = ZipBundle(bundle_path)
694
+
695
+ # Read or create style.json for element positions
696
+ try:
697
+ style = bundle.read_json("style.json")
698
+ except:
699
+ style = {}
700
+
701
+ # Initialize structure
702
+ if "elements" not in style:
703
+ style["elements"] = {}
704
+ if panel not in style["elements"]:
705
+ style["elements"][panel] = {}
706
+
707
+ # Save element position
708
+ style["elements"][panel][element] = {
709
+ "type": element_type,
710
+ "position": position,
711
+ "snap_name": snap_name,
712
+ }
713
+
714
+ # For legends, also update legend_location for matplotlib compatibility
715
+ if element_type == "legend" and snap_name:
716
+ # Convert snap name to matplotlib loc format
717
+ loc_map = {
718
+ "upper left": "upper left",
719
+ "upper center": "upper center",
720
+ "upper right": "upper right",
721
+ "center left": "center left",
722
+ "center": "center",
723
+ "center right": "center right",
724
+ "lower left": "lower left",
725
+ "lower center": "lower center",
726
+ "lower right": "lower right",
727
+ }
728
+ if snap_name in loc_map:
729
+ if "legend" not in style:
730
+ style["legend"] = {}
731
+ style["legend"]["location"] = loc_map[snap_name]
732
+
733
+ bundle.write_json("style.json", style)
734
+
735
+ return jsonify({
736
+ "success": True,
737
+ "element": element,
738
+ "position": position,
739
+ "snap_name": snap_name
740
+ })
741
+
742
+ except Exception as e:
743
+ import traceback
744
+ return jsonify({
745
+ "success": False,
746
+ "error": str(e),
747
+ "traceback": traceback.format_exc()
748
+ })
749
+
750
+ @app.route("/export", methods=["POST"])
751
+ def export_figure():
752
+ """Export composed figure to various formats and update figz bundle."""
753
+ try:
754
+ data = request.get_json()
755
+ formats = data.get("formats", ["png", "svg"])
756
+
757
+ if not editor.panel_info:
758
+ return jsonify({"success": False, "error": "No panel info available"})
759
+
760
+ bundle_path = editor.panel_info.get("bundle_path")
761
+ if not bundle_path:
762
+ return jsonify({"success": False, "error": "Bundle path not available"})
763
+
764
+ from scitex.io import ZipBundle
765
+ from pathlib import Path
766
+ import io
767
+ import matplotlib
768
+ matplotlib.use('Agg')
769
+ import matplotlib.pyplot as plt
770
+ from PIL import Image
771
+ import numpy as np
772
+
773
+ figure_name = Path(bundle_path).stem
774
+ dpi = data.get("dpi", 150)
775
+
776
+ with ZipBundle(bundle_path, mode="a") as bundle:
777
+ # Read spec for figure size and panel positions
778
+ try:
779
+ spec = bundle.read_json("spec.json")
780
+ except:
781
+ spec = {}
782
+
783
+ # Get figure dimensions
784
+ fig_width_mm = 180
785
+ fig_height_mm = 120
786
+ if "figure" in spec:
787
+ fig_info = spec.get("figure", {})
788
+ styles = fig_info.get("styles", {})
789
+ size = styles.get("size", {})
790
+ fig_width_mm = size.get("width_mm", 180)
791
+ fig_height_mm = size.get("height_mm", 120)
792
+
793
+ # Convert mm to inches
794
+ fig_width_in = fig_width_mm / 25.4
795
+ fig_height_in = fig_height_mm / 25.4
796
+
797
+ # Create figure with white background
798
+ fig = plt.figure(figsize=(fig_width_in, fig_height_in), dpi=dpi, facecolor='white')
799
+
800
+ # Get panels from spec or editor.panel_info
801
+ panels_spec = spec.get("panels", [])
802
+
803
+ # Compose panels onto figure
804
+ for panel_spec in panels_spec:
805
+ panel_id = panel_spec.get("id", "")
806
+ pltz_name = panel_spec.get("plot", "")
807
+
808
+ # Get position and size from spec
809
+ pos = panel_spec.get("position", {})
810
+ size = panel_spec.get("size", {})
811
+
812
+ x_mm = pos.get("x_mm", 0)
813
+ y_mm = pos.get("y_mm", 0)
814
+ w_mm = size.get("width_mm", 60)
815
+ h_mm = size.get("height_mm", 40)
816
+
817
+ # Convert to figure coordinates (0-1)
818
+ x_frac = x_mm / fig_width_mm
819
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm # Flip Y
820
+ w_frac = w_mm / fig_width_mm
821
+ h_frac = h_mm / fig_height_mm
822
+
823
+ # Try to read panel image from pltz exports
824
+ img_loaded = False
825
+ for pltz_path in [f"{panel_id}.pltz", pltz_name.replace(".d", "")]:
826
+ if img_loaded:
827
+ break
828
+ try:
829
+ # Read pltz as nested bundle
830
+ pltz_bytes = bundle.read_bytes(pltz_path)
831
+ import tempfile
832
+ with tempfile.NamedTemporaryFile(suffix=".pltz", delete=False) as tmp:
833
+ tmp.write(pltz_bytes)
834
+ tmp_path = tmp.name
835
+ try:
836
+ with ZipBundle(tmp_path, mode="r") as pltz_bundle:
837
+ # Try various preview paths
838
+ for preview_path in ["exports/preview.png", "preview.png", f"exports/{panel_id}.png"]:
839
+ try:
840
+ img_data = pltz_bundle.read_bytes(preview_path)
841
+ img = Image.open(io.BytesIO(img_data))
842
+ img_array = np.array(img)
843
+
844
+ # Create axes and add image
845
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
846
+ ax.imshow(img_array)
847
+ ax.axis('off')
848
+ img_loaded = True
849
+ break
850
+ except:
851
+ continue
852
+ finally:
853
+ import os
854
+ os.unlink(tmp_path)
855
+ except Exception as e:
856
+ print(f"Could not load pltz {pltz_path}: {e}")
857
+ continue
858
+
859
+ exported = {}
860
+
861
+ for fmt in formats:
862
+ buf = io.BytesIO()
863
+ if fmt in ["png", "jpeg", "jpg"]:
864
+ fig.savefig(buf, format="png" if fmt == "png" else "jpeg",
865
+ dpi=dpi, bbox_inches="tight", facecolor="white",
866
+ pad_inches=0.02)
867
+ elif fmt == "svg":
868
+ fig.savefig(buf, format="svg", bbox_inches="tight", pad_inches=0.02)
869
+ elif fmt == "pdf":
870
+ fig.savefig(buf, format="pdf", bbox_inches="tight", pad_inches=0.02)
871
+ else:
872
+ continue
873
+
874
+ buf.seek(0)
875
+ content = buf.read()
876
+
877
+ # Save to exports/ directory in bundle
878
+ export_path = f"exports/{figure_name}.{fmt}"
879
+ bundle.write_bytes(export_path, content)
880
+ exported[fmt] = export_path
881
+
882
+ plt.close(fig)
883
+
884
+ return jsonify({
885
+ "success": True,
886
+ "exported": exported,
887
+ "bundle_path": str(bundle_path)
888
+ })
889
+
890
+ except Exception as e:
891
+ import traceback
892
+ return jsonify({
893
+ "success": False,
894
+ "error": str(e),
895
+ "traceback": traceback.format_exc()
896
+ })
897
+
898
+ @app.route("/download/<fmt>")
899
+ def download_figure(fmt):
900
+ """Download figure in specified format."""
901
+ try:
902
+ from flask import send_file
903
+ import io
904
+ from pathlib import Path
905
+
906
+ mime_types = {
907
+ "png": "image/png",
908
+ "jpeg": "image/jpeg",
909
+ "jpg": "image/jpeg",
910
+ "svg": "image/svg+xml",
911
+ "pdf": "application/pdf",
912
+ }
913
+
914
+ if fmt not in mime_types:
915
+ return f"Unsupported format: {fmt}", 400
916
+
917
+ # For figz bundles, download the composed figure
918
+ if editor.panel_info:
919
+ bundle_path = editor.panel_info.get("bundle_path")
920
+ figz_dir = editor.panel_info.get("figz_dir")
921
+ figure_name = Path(bundle_path).stem if bundle_path else (
922
+ Path(figz_dir).stem.replace(".figz.d", "") if figz_dir else "figure"
923
+ )
924
+
925
+ if bundle_path or figz_dir:
926
+ from scitex.io import ZipBundle
927
+ from PIL import Image
928
+ import numpy as np
929
+ import matplotlib
930
+ matplotlib.use('Agg')
931
+ import matplotlib.pyplot as plt
932
+ import json as json_module
933
+
934
+ # Always compose on-demand to ensure current panel state
935
+ # (existing exports in bundle may be stale or blank)
936
+
937
+ # Read spec.json and layout.json for position overrides
938
+ spec = {}
939
+ layout_overrides = {}
940
+ if bundle_path:
941
+ try:
942
+ with ZipBundle(bundle_path, mode="r") as bundle:
943
+ spec = bundle.read_json("spec.json")
944
+ try:
945
+ layout_overrides = bundle.read_json("layout.json")
946
+ except:
947
+ pass
948
+ except:
949
+ pass
950
+ elif figz_dir:
951
+ spec_path = Path(figz_dir) / "spec.json"
952
+ if spec_path.exists():
953
+ with open(spec_path) as f:
954
+ spec = json_module.load(f)
955
+ layout_path = Path(figz_dir) / "layout.json"
956
+ if layout_path.exists():
957
+ with open(layout_path) as f:
958
+ layout_overrides = json_module.load(f)
959
+
960
+ # Also check in-memory layout overrides (most current)
961
+ if editor.panel_info and editor.panel_info.get("layout"):
962
+ layout_overrides = editor.panel_info.get("layout", {})
963
+
964
+ # Get figure dimensions
965
+ fig_width_mm = 180
966
+ fig_height_mm = 120
967
+ if "figure" in spec:
968
+ fig_info = spec.get("figure", {})
969
+ styles = fig_info.get("styles", {})
970
+ size = styles.get("size", {})
971
+ fig_width_mm = size.get("width_mm", 180)
972
+ fig_height_mm = size.get("height_mm", 120)
973
+
974
+ fig_width_in = fig_width_mm / 25.4
975
+ fig_height_in = fig_height_mm / 25.4
976
+
977
+ dpi = 150 if fmt in ["jpeg", "jpg"] else 300
978
+ fig = plt.figure(figsize=(fig_width_in, fig_height_in), dpi=dpi, facecolor='white')
979
+
980
+ # Compose panels
981
+ panels_spec = spec.get("panels", [])
982
+ panel_paths = editor.panel_info.get("panel_paths", [])
983
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [])
984
+
985
+ for panel_spec in panels_spec:
986
+ panel_id = panel_spec.get("id", "")
987
+ pos = panel_spec.get("position", {})
988
+ size = panel_spec.get("size", {})
989
+
990
+ # Skip overview/auxiliary panels (only compose main panels A-Z)
991
+ panel_id_lower = panel_id.lower()
992
+ if any(skip in panel_id_lower for skip in ['overview', 'thumb', 'preview', 'aux']):
993
+ continue
994
+
995
+ # Find panel path first (needed to check layout_overrides)
996
+ panel_path = None
997
+ is_zip = False
998
+ panel_name = None
999
+ for idx, pp in enumerate(panel_paths):
1000
+ pp_name = Path(pp).stem.replace(".pltz", "")
1001
+ # Match exact name, or name contains panel_id pattern
1002
+ # e.g., "panel_A_twinx" matches panel_id "A"
1003
+ if (pp_name == panel_id or
1004
+ pp_name.startswith(f"panel_{panel_id}_") or
1005
+ pp_name.startswith(f"panel_{panel_id}.") or
1006
+ pp_name == f"panel_{panel_id}" or
1007
+ pp_name == panel_id or
1008
+ f"_{panel_id}_" in pp_name or
1009
+ pp_name.endswith(f"_{panel_id}")):
1010
+ panel_path = pp
1011
+ panel_name = Path(pp).name # e.g., "panel_A_twinx.pltz"
1012
+ is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else False
1013
+ break
1014
+
1015
+ if not panel_path:
1016
+ print(f"Could not find panel path for id={panel_id}, available: {[Path(p).stem for p in panel_paths]}")
1017
+ continue
1018
+
1019
+ # Check for layout overrides (from layout.json or in-memory)
1020
+ override = layout_overrides.get(panel_name, {})
1021
+ override_pos = override.get("position", {})
1022
+ override_size = override.get("size", {})
1023
+
1024
+ # Use override positions if available, otherwise use spec
1025
+ x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
1026
+ y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
1027
+ w_mm = override_size.get("width_mm", size.get("width_mm", 60))
1028
+ h_mm = override_size.get("height_mm", size.get("height_mm", 40))
1029
+
1030
+ x_frac = x_mm / fig_width_mm
1031
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm
1032
+ w_frac = w_mm / fig_width_mm
1033
+ h_frac = h_mm / fig_height_mm
1034
+
1035
+ # Load panel preview image
1036
+ try:
1037
+ img_loaded = False
1038
+ # Exclusion patterns for preview selection
1039
+ exclude_patterns = ['hitmap', 'overview', 'thumb', 'preview']
1040
+
1041
+ if is_zip:
1042
+ with ZipBundle(panel_path, mode="r") as pltz_bundle:
1043
+ # Find PNG in exports (exclude hitmap, overview, thumbnails)
1044
+ import zipfile
1045
+ with zipfile.ZipFile(panel_path, 'r') as zf:
1046
+ png_files = [n for n in zf.namelist()
1047
+ if n.endswith('.png')
1048
+ and 'exports/' in n
1049
+ and not any(p in n.lower() for p in exclude_patterns)]
1050
+ if png_files:
1051
+ # Use first matching PNG
1052
+ preview_path = png_files[0]
1053
+ # Extract the path relative to .d directory
1054
+ if '.pltz.d/' in preview_path:
1055
+ preview_path = preview_path.split('.pltz.d/')[-1]
1056
+ try:
1057
+ img_data = pltz_bundle.read_bytes(preview_path)
1058
+ img = Image.open(io.BytesIO(img_data))
1059
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
1060
+ ax.imshow(np.array(img))
1061
+ ax.axis('off')
1062
+ img_loaded = True
1063
+ except Exception as e:
1064
+ print(f"Could not read {preview_path}: {e}")
1065
+ else:
1066
+ # Directory-based pltz
1067
+ pltz_dir = Path(panel_path)
1068
+ exports_dir = pltz_dir / "exports"
1069
+ if exports_dir.exists():
1070
+ for png_file in exports_dir.glob("*.png"):
1071
+ name_lower = png_file.name.lower()
1072
+ if not any(p in name_lower for p in exclude_patterns):
1073
+ img = Image.open(png_file)
1074
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
1075
+ ax.imshow(np.array(img))
1076
+ ax.axis('off')
1077
+ img_loaded = True
1078
+ break
1079
+ if not img_loaded:
1080
+ print(f"No preview found for panel {panel_id}")
1081
+ except Exception as e:
1082
+ print(f"Could not load panel {panel_id}: {e}")
1083
+
1084
+ # Draw panel letter
1085
+ if panel_id and len(panel_id) <= 2: # Only for short IDs like A, B, C...
1086
+ # Position letter at top-left corner of panel
1087
+ letter_x = x_frac + 0.01
1088
+ letter_y = y_frac + h_frac - 0.02
1089
+ fig.text(letter_x, letter_y, panel_id,
1090
+ fontsize=14, fontweight='bold', color='black',
1091
+ ha='left', va='top',
1092
+ transform=fig.transFigure,
1093
+ bbox=dict(boxstyle='square,pad=0.1',
1094
+ facecolor='white', edgecolor='none', alpha=0.8))
1095
+
1096
+ buf = io.BytesIO()
1097
+ fig.savefig(buf, format=fmt if fmt != "jpg" else "jpeg",
1098
+ dpi=dpi, bbox_inches="tight", facecolor="white",
1099
+ pad_inches=0.02)
1100
+ plt.close(fig)
1101
+ buf.seek(0)
1102
+
1103
+ return send_file(
1104
+ buf,
1105
+ mimetype=mime_types[fmt],
1106
+ as_attachment=True,
1107
+ download_name=f"{figure_name}.{fmt}"
1108
+ )
1109
+
1110
+ # For single pltz files, render from csv_data
1111
+ from ._renderer import render_preview_with_bboxes
1112
+ import matplotlib
1113
+ matplotlib.use('Agg')
1114
+ import matplotlib.pyplot as plt
1115
+
1116
+ figure_name = "figure"
1117
+ if editor.json_path:
1118
+ figure_name = Path(editor.json_path).stem
1119
+
1120
+ img_data, _, _ = render_preview_with_bboxes(
1121
+ editor.csv_data, editor.current_overrides,
1122
+ metadata=editor.metadata,
1123
+ dark_mode=False,
1124
+ )
1125
+
1126
+ if fmt == "png":
1127
+ import base64
1128
+ content = base64.b64decode(img_data)
1129
+ buf = io.BytesIO(content)
1130
+ return send_file(
1131
+ buf,
1132
+ mimetype=mime_types[fmt],
1133
+ as_attachment=True,
1134
+ download_name=f"{figure_name}.{fmt}"
1135
+ )
1136
+
1137
+ # For other formats, re-render
1138
+ from ._plotter import plot_from_csv
1139
+ fig, ax = plt.subplots(figsize=(8, 6))
1140
+ plot_from_csv(ax, editor.csv_data, editor.current_overrides)
1141
+
1142
+ buf = io.BytesIO()
1143
+ dpi = 150 if fmt in ["jpeg", "jpg"] else 300
1144
+ fig.savefig(buf, format=fmt if fmt != "jpg" else "jpeg",
1145
+ dpi=dpi, bbox_inches="tight",
1146
+ facecolor="white" if fmt in ["jpeg", "jpg"] else None)
1147
+ plt.close(fig)
1148
+ buf.seek(0)
1149
+
1150
+ return send_file(
1151
+ buf,
1152
+ mimetype=mime_types[fmt],
1153
+ as_attachment=True,
1154
+ download_name=f"{figure_name}.{fmt}"
1155
+ )
1156
+
1157
+ except Exception as e:
1158
+ import traceback
1159
+ return f"Error: {str(e)}\n{traceback.format_exc()}", 500
1160
+
1161
+ @app.route("/download_figz")
1162
+ def download_figz():
1163
+ """Download as figz bundle (re-editable format)."""
1164
+ try:
1165
+ if not editor.panel_info:
1166
+ return "No panel info available", 404
1167
+
1168
+ bundle_path = editor.panel_info.get("bundle_path")
1169
+ if not bundle_path:
1170
+ return "Bundle path not available", 404
1171
+
1172
+ from flask import send_file
1173
+ from pathlib import Path
1174
+
1175
+ # Send the figz file directly (it's already a pltz-compatible format)
1176
+ return send_file(
1177
+ bundle_path,
1178
+ mimetype="application/zip",
1179
+ as_attachment=True,
1180
+ download_name=Path(bundle_path).name
1181
+ )
1182
+
1183
+ except Exception as e:
1184
+ return str(e), 500
1185
+
381
1186
  @app.route("/shutdown", methods=["POST"])
382
1187
  def shutdown():
383
1188
  """Shutdown the server."""