figrecipe 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. figrecipe/__init__.py +361 -93
  2. figrecipe/_dev/__init__.py +120 -0
  3. figrecipe/_dev/demo_plotters/__init__.py +195 -0
  4. figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
  5. figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
  6. figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
  7. figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
  8. figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
  9. figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
  10. figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
  11. figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
  12. figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
  13. figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
  14. figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
  15. figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
  16. figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
  17. figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
  18. figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
  19. figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
  20. figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
  21. figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
  22. figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
  23. figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
  24. figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
  25. figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
  26. figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
  27. figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
  29. figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
  30. figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
  31. figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
  32. figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
  33. figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
  34. figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
  35. figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
  36. figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
  37. figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
  38. figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
  39. figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
  40. figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
  41. figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
  42. figrecipe/_dev/demo_plotters/plot_step.py +27 -0
  43. figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
  44. figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
  45. figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
  46. figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
  47. figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
  48. figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
  49. figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
  50. figrecipe/_editor/__init__.py +230 -0
  51. figrecipe/_editor/_bbox.py +978 -0
  52. figrecipe/_editor/_flask_app.py +1229 -0
  53. figrecipe/_editor/_hitmap.py +937 -0
  54. figrecipe/_editor/_overrides.py +318 -0
  55. figrecipe/_editor/_renderer.py +349 -0
  56. figrecipe/_editor/_templates/__init__.py +75 -0
  57. figrecipe/_editor/_templates/_html.py +406 -0
  58. figrecipe/_editor/_templates/_scripts.py +2778 -0
  59. figrecipe/_editor/_templates/_styles.py +1326 -0
  60. figrecipe/_params/_DECORATION_METHODS.py +27 -0
  61. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  62. figrecipe/_params/__init__.py +9 -0
  63. figrecipe/_recorder.py +126 -73
  64. figrecipe/_reproducer.py +658 -41
  65. figrecipe/_seaborn.py +14 -9
  66. figrecipe/_serializer.py +2 -2
  67. figrecipe/_signatures/README.md +68 -0
  68. figrecipe/_signatures/__init__.py +12 -2
  69. figrecipe/_signatures/_loader.py +515 -56
  70. figrecipe/_utils/__init__.py +6 -4
  71. figrecipe/_utils/_crop.py +10 -4
  72. figrecipe/_utils/_image_diff.py +37 -33
  73. figrecipe/_utils/_numpy_io.py +0 -1
  74. figrecipe/_utils/_units.py +11 -3
  75. figrecipe/_validator.py +12 -3
  76. figrecipe/_wrappers/_axes.py +860 -46
  77. figrecipe/_wrappers/_figure.py +115 -18
  78. figrecipe/plt.py +0 -1
  79. figrecipe/pyplot.py +2 -1
  80. figrecipe/styles/__init__.py +9 -10
  81. figrecipe/styles/_style_applier.py +332 -28
  82. figrecipe/styles/_style_loader.py +172 -44
  83. figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
  84. figrecipe/styles/presets/SCITEX.yaml +176 -0
  85. figrecipe-0.6.0.dist-info/METADATA +394 -0
  86. figrecipe-0.6.0.dist-info/RECORD +90 -0
  87. figrecipe-0.5.0.dist-info/METADATA +0 -336
  88. figrecipe-0.5.0.dist-info/RECORD +0 -26
  89. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
  90. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1229 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Flask-based GUI editor for figure styling.
5
+
6
+ This module provides a web-based interface for interactively adjusting
7
+ figure styles with hitmap-based element selection.
8
+
9
+ Style Override Architecture
10
+ ---------------------------
11
+ Styles are managed in layers with separate storage:
12
+
13
+ 1. Base style (from preset like SCITEX)
14
+ 2. Programmatic style (from code)
15
+ 3. Manual overrides (from GUI editor)
16
+
17
+ Manual overrides are stored separately in `.overrides.json` files,
18
+ allowing restoration to original programmatic styles.
19
+ """
20
+
21
+ # Force Agg backend before any pyplot import to avoid Tkinter threading issues
22
+ import matplotlib
23
+
24
+ matplotlib.use("Agg")
25
+
26
+ import webbrowser
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Optional
29
+
30
+ from .._wrappers import RecordingFigure
31
+ from ._overrides import (
32
+ create_overrides_from_style,
33
+ load_overrides,
34
+ save_overrides,
35
+ )
36
+
37
+
38
+ class FigureEditor:
39
+ """
40
+ Browser-based figure style editor using Flask.
41
+
42
+ Features:
43
+ - Real-time figure preview with style overrides
44
+ - Hitmap-based element selection
45
+ - Full style property editing (dimensions, fonts, lines, colors, etc.)
46
+ - Dark/light theme toggle
47
+ - Download in PNG/SVG/PDF formats
48
+ - Separate storage of manual overrides (can restore to original)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ fig: RecordingFigure,
54
+ recipe_path: Optional[Path] = None,
55
+ style: Optional[Dict[str, Any]] = None,
56
+ port: int = 5050,
57
+ static_png_path: Optional[Path] = None,
58
+ hitmap_base64: Optional[str] = None,
59
+ color_map: Optional[Dict] = None,
60
+ ):
61
+ """
62
+ Initialize figure editor.
63
+
64
+ Parameters
65
+ ----------
66
+ fig : RecordingFigure
67
+ Figure to edit.
68
+ recipe_path : Path, optional
69
+ Path to recipe file (if loaded from file).
70
+ style : dict, optional
71
+ Initial style configuration (programmatic).
72
+ port : int
73
+ Flask server port.
74
+ static_png_path : Path, optional
75
+ Path to pre-rendered static PNG (source of truth for initial display).
76
+ hitmap_base64 : str, optional
77
+ Pre-generated hitmap as base64.
78
+ color_map : dict, optional
79
+ Pre-generated color map for hitmap.
80
+ """
81
+ self.fig = fig
82
+ self.recipe_path = Path(recipe_path) if recipe_path else None
83
+ self.port = port
84
+ self.dark_mode = False
85
+
86
+ # Pre-rendered static PNG (source of truth)
87
+ self._static_png_path = static_png_path
88
+ self._initial_base64 = None
89
+ if static_png_path and static_png_path.exists():
90
+ import base64
91
+
92
+ with open(static_png_path, "rb") as f:
93
+ self._initial_base64 = base64.b64encode(f.read()).decode("utf-8")
94
+
95
+ # Initialize style overrides system
96
+ self._init_style_overrides(style)
97
+
98
+ # Pre-generated hitmap and color_map
99
+ self._hitmap_base64 = hitmap_base64
100
+ self._color_map = color_map
101
+
102
+ def _init_style_overrides(self, programmatic_style: Optional[Dict[str, Any]]):
103
+ """Initialize the layered style override system."""
104
+ # Try to load existing overrides
105
+ if self.recipe_path:
106
+ existing = load_overrides(self.recipe_path)
107
+ if existing:
108
+ self.style_overrides = existing
109
+ # Update programmatic style if provided
110
+ if programmatic_style:
111
+ self.style_overrides.programmatic_style = programmatic_style
112
+ return
113
+
114
+ # Get base style from global preset (always ensure we have a base style)
115
+ base_style = {}
116
+ style_name = "SCITEX" # Default
117
+ try:
118
+ from ..styles._style_loader import (
119
+ _CURRENT_STYLE_NAME,
120
+ _STYLE_CACHE,
121
+ load_style,
122
+ to_subplots_kwargs,
123
+ )
124
+
125
+ # If no style is loaded, load the default SCITEX style
126
+ if _STYLE_CACHE is None:
127
+ load_style("SCITEX")
128
+
129
+ # Get the style cache (now guaranteed to exist)
130
+ from ..styles._style_loader import _STYLE_CACHE
131
+
132
+ if _STYLE_CACHE is not None:
133
+ base_style = to_subplots_kwargs(_STYLE_CACHE)
134
+ style_name = _CURRENT_STYLE_NAME or "SCITEX"
135
+ except Exception:
136
+ pass
137
+
138
+ # Store the style name for UI display
139
+ self._style_name = style_name
140
+
141
+ # Create new overrides
142
+ self.style_overrides = create_overrides_from_style(
143
+ base_style=base_style,
144
+ programmatic_style=programmatic_style or {},
145
+ )
146
+
147
+ @property
148
+ def style(self) -> Dict[str, Any]:
149
+ """Get the original style (without manual overrides)."""
150
+ return self.style_overrides.get_original_style()
151
+
152
+ @property
153
+ def overrides(self) -> Dict[str, Any]:
154
+ """Get current manual overrides."""
155
+ return self.style_overrides.manual_overrides
156
+
157
+ @overrides.setter
158
+ def overrides(self, value: Dict[str, Any]):
159
+ """Set manual overrides."""
160
+ self.style_overrides.manual_overrides = value
161
+
162
+ def get_effective_style(self) -> Dict[str, Any]:
163
+ """Get the final merged style."""
164
+ return self.style_overrides.get_effective_style()
165
+
166
+ def run(self, open_browser: bool = True) -> Dict[str, Any]:
167
+ """
168
+ Run the editor server.
169
+
170
+ Parameters
171
+ ----------
172
+ open_browser : bool
173
+ Whether to open browser automatically.
174
+
175
+ Returns
176
+ -------
177
+ dict
178
+ Final style overrides after editing session.
179
+ """
180
+ from flask import Flask, jsonify, render_template_string, request, send_file
181
+
182
+ from ._bbox import extract_bboxes
183
+ from ._hitmap import generate_hitmap, hitmap_to_base64
184
+ from ._renderer import render_download
185
+ from ._templates import build_html_template
186
+
187
+ # Use specified port strictly (no fallback)
188
+
189
+ # Defer hitmap generation until first request (lazy loading)
190
+ # This makes the editor start immediately
191
+ self._hitmap_generated = self._hitmap_base64 is not None
192
+
193
+ # Create Flask app
194
+ app = Flask(__name__)
195
+ editor = self
196
+
197
+ @app.route("/")
198
+ def index():
199
+ """Main editor page."""
200
+ # Always render with effective style (base + programmatic + manual)
201
+ # to ensure YAML style settings are applied
202
+ base64_img, bboxes, img_size = _render_with_overrides(
203
+ editor.fig,
204
+ editor.get_effective_style(),
205
+ editor.dark_mode,
206
+ )
207
+
208
+ # Get style name (default to SCITEX if not set)
209
+ style_name = getattr(editor, "_style_name", "SCITEX")
210
+
211
+ # Build HTML template
212
+ html = build_html_template(
213
+ image_base64=base64_img,
214
+ bboxes=bboxes,
215
+ color_map=editor._color_map,
216
+ style=editor.style,
217
+ overrides=editor.get_effective_style(),
218
+ img_size=img_size,
219
+ style_name=style_name,
220
+ )
221
+
222
+ return render_template_string(html)
223
+
224
+ @app.route("/preview")
225
+ def preview():
226
+ """Get current preview image."""
227
+ # Always render with effective style (base + programmatic + manual)
228
+ base64_img, bboxes, img_size = _render_with_overrides(
229
+ editor.fig,
230
+ editor.get_effective_style(),
231
+ editor.dark_mode,
232
+ )
233
+
234
+ return jsonify(
235
+ {
236
+ "image": base64_img,
237
+ "bboxes": bboxes,
238
+ "img_size": {"width": img_size[0], "height": img_size[1]},
239
+ }
240
+ )
241
+
242
+ @app.route("/update", methods=["POST"])
243
+ def update():
244
+ """Update preview with new style overrides."""
245
+ data = request.get_json() or {}
246
+
247
+ # Update manual overrides
248
+ editor.overrides.update(data.get("overrides", {}))
249
+ editor.dark_mode = data.get("dark_mode", editor.dark_mode)
250
+
251
+ # Re-render with effective style (base + programmatic + manual)
252
+ base64_img, bboxes, img_size = _render_with_overrides(
253
+ editor.fig,
254
+ editor.get_effective_style(),
255
+ editor.dark_mode,
256
+ )
257
+
258
+ return jsonify(
259
+ {
260
+ "image": base64_img,
261
+ "bboxes": bboxes,
262
+ "img_size": {"width": img_size[0], "height": img_size[1]},
263
+ }
264
+ )
265
+
266
+ @app.route("/hitmap")
267
+ def hitmap():
268
+ """Get hitmap image and color map (lazy generation on first request)."""
269
+ # Generate hitmap on first request if not already done
270
+ if not editor._hitmap_generated:
271
+ print("Generating hitmap (first request)...")
272
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
273
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
274
+ editor._hitmap_generated = True
275
+ print("Hitmap ready.")
276
+
277
+ return jsonify(
278
+ {
279
+ "image": editor._hitmap_base64,
280
+ "color_map": editor._color_map,
281
+ }
282
+ )
283
+
284
+ def _to_json_serializable(obj):
285
+ """Convert numpy arrays and other non-serializable objects to JSON-safe types."""
286
+ import numpy as np
287
+
288
+ if isinstance(obj, np.ndarray):
289
+ return obj.tolist()
290
+ elif isinstance(obj, (np.integer, np.floating)):
291
+ return obj.item()
292
+ elif isinstance(obj, dict):
293
+ return {k: _to_json_serializable(v) for k, v in obj.items()}
294
+ elif isinstance(obj, (list, tuple)):
295
+ return [_to_json_serializable(item) for item in obj]
296
+ return obj
297
+
298
+ @app.route("/calls")
299
+ def get_calls():
300
+ """Get all recorded calls with their signatures."""
301
+ from .._signatures import get_signature
302
+
303
+ calls_data = {}
304
+ if hasattr(editor.fig, "record"):
305
+ for ax_key, ax_record in editor.fig.record.axes.items():
306
+ for call in ax_record.calls:
307
+ call_id = call.id
308
+ func_name = call.function
309
+ sig = get_signature(func_name)
310
+
311
+ calls_data[call_id] = {
312
+ "function": func_name,
313
+ "ax_key": ax_key,
314
+ "args": _to_json_serializable(call.args),
315
+ "kwargs": _to_json_serializable(call.kwargs),
316
+ "signature": {
317
+ "args": sig.get("args", []),
318
+ "kwargs": {
319
+ k: v
320
+ for k, v in sig.get("kwargs", {}).items()
321
+ if k != "**kwargs"
322
+ },
323
+ },
324
+ }
325
+
326
+ return jsonify(calls_data)
327
+
328
+ @app.route("/call/<call_id>")
329
+ def get_call(call_id):
330
+ """Get recorded call data by call_id."""
331
+ from .._signatures import get_signature
332
+
333
+ if hasattr(editor.fig, "record"):
334
+ for ax_key, ax_record in editor.fig.record.axes.items():
335
+ for call in ax_record.calls:
336
+ if call.id == call_id:
337
+ sig = get_signature(call.function)
338
+ return jsonify(
339
+ {
340
+ "call_id": call_id,
341
+ "function": call.function,
342
+ "ax_key": ax_key,
343
+ "args": call.args,
344
+ "kwargs": call.kwargs,
345
+ "signature": {
346
+ "args": sig.get("args", []),
347
+ "kwargs": {
348
+ k: v
349
+ for k, v in sig.get("kwargs", {}).items()
350
+ if k != "**kwargs"
351
+ },
352
+ },
353
+ }
354
+ )
355
+
356
+ return jsonify({"error": f"Call {call_id} not found"}), 404
357
+
358
+ @app.route("/style")
359
+ def get_style():
360
+ """Get current style configuration."""
361
+ return jsonify(
362
+ {
363
+ "base_style": editor.style_overrides.base_style,
364
+ "programmatic_style": editor.style_overrides.programmatic_style,
365
+ "manual_overrides": editor.style_overrides.manual_overrides,
366
+ "effective_style": editor.get_effective_style(),
367
+ "has_overrides": editor.style_overrides.has_manual_overrides(),
368
+ "manual_timestamp": editor.style_overrides.manual_timestamp,
369
+ }
370
+ )
371
+
372
+ @app.route("/theme")
373
+ def get_theme():
374
+ """Get current theme YAML content for display."""
375
+ import io as yaml_io
376
+
377
+ from ruamel.yaml import YAML
378
+
379
+ style = editor.get_effective_style()
380
+ style_name = style.get("_name", "SCITEX")
381
+
382
+ # Serialize to YAML
383
+ yaml = YAML()
384
+ yaml.default_flow_style = False
385
+ yaml.indent(mapping=2, sequence=4, offset=2)
386
+ stream = yaml_io.StringIO()
387
+ yaml.dump(style, stream)
388
+ yaml_content = stream.getvalue()
389
+
390
+ return jsonify(
391
+ {
392
+ "name": style_name,
393
+ "content": yaml_content,
394
+ }
395
+ )
396
+
397
+ @app.route("/list_themes")
398
+ def list_themes():
399
+ """List available theme presets."""
400
+ from ..styles._style_loader import list_presets
401
+
402
+ presets = list_presets()
403
+ current = editor.get_effective_style().get("_name", "SCITEX")
404
+
405
+ return jsonify(
406
+ {
407
+ "themes": presets,
408
+ "current": current,
409
+ }
410
+ )
411
+
412
+ @app.route("/switch_theme", methods=["POST"])
413
+ def switch_theme():
414
+ """Switch to a different theme preset by reproducing the figure."""
415
+ from .._reproducer import reproduce_from_record
416
+ from ..styles._style_loader import load_preset
417
+
418
+ data = request.get_json() or {}
419
+ theme_name = data.get("theme")
420
+
421
+ if not theme_name:
422
+ return jsonify({"error": "No theme specified"}), 400
423
+
424
+ try:
425
+ # Load the new preset
426
+ new_style = load_preset(theme_name)
427
+
428
+ if new_style is None:
429
+ return jsonify({"error": f"Theme '{theme_name}' not found"}), 404
430
+
431
+ # Update the base style
432
+ editor.style_overrides.base_style = dict(new_style)
433
+ editor.style_overrides.base_style["_name"] = theme_name
434
+
435
+ # Reproduce the figure from the record
436
+ if hasattr(editor.fig, "record") and editor.fig.record is not None:
437
+ # Update the record's style to use new theme
438
+ old_style = editor.fig.record.style
439
+ editor.fig.record.style = dict(new_style)
440
+
441
+ # Reproduce figure with new style
442
+ new_fig, new_ax = reproduce_from_record(editor.fig.record)
443
+ editor.fig = new_fig
444
+
445
+ # Restore original style in record for future reference
446
+ editor.fig.record.style = old_style
447
+
448
+ # Apply behavior settings from new theme directly to figure
449
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
450
+ behavior = new_style.get("behavior", {})
451
+ for ax in mpl_fig.get_axes():
452
+ # Apply spine visibility
453
+ hide_top = behavior.get("hide_top_spine", True)
454
+ hide_right = behavior.get("hide_right_spine", True)
455
+ ax.spines["top"].set_visible(not hide_top)
456
+ ax.spines["right"].set_visible(not hide_right)
457
+
458
+ # Apply grid setting
459
+ if behavior.get("grid", False):
460
+ ax.grid(True, alpha=0.3)
461
+ else:
462
+ ax.grid(False)
463
+
464
+ # Re-render with new theme
465
+ base64_img, bboxes, img_size = _render_with_overrides(
466
+ editor.fig,
467
+ editor.get_effective_style(),
468
+ editor.dark_mode,
469
+ )
470
+
471
+ # Get updated form values from new style
472
+ form_values = _get_form_values_from_style(editor.get_effective_style())
473
+
474
+ return jsonify(
475
+ {
476
+ "success": True,
477
+ "theme": theme_name,
478
+ "image": base64_img,
479
+ "bboxes": bboxes,
480
+ "img_size": {"width": img_size[0], "height": img_size[1]},
481
+ "values": form_values,
482
+ }
483
+ )
484
+
485
+ except Exception as e:
486
+ import traceback
487
+
488
+ traceback.print_exc()
489
+ return jsonify({"error": f"Failed to switch theme: {str(e)}"}), 500
490
+
491
+ @app.route("/save", methods=["POST"])
492
+ def save():
493
+ """Save style overrides (stored separately from recipe)."""
494
+ data = request.get_json() or {}
495
+ editor.style_overrides.update_manual_overrides(data.get("overrides", {}))
496
+
497
+ # Save to .overrides.json file
498
+ if editor.recipe_path:
499
+ path = save_overrides(editor.style_overrides, editor.recipe_path)
500
+ return jsonify(
501
+ {
502
+ "success": True,
503
+ "path": str(path),
504
+ "has_overrides": editor.style_overrides.has_manual_overrides(),
505
+ "timestamp": editor.style_overrides.manual_timestamp,
506
+ }
507
+ )
508
+
509
+ return jsonify(
510
+ {
511
+ "success": True,
512
+ "overrides": editor.overrides,
513
+ "has_overrides": editor.style_overrides.has_manual_overrides(),
514
+ }
515
+ )
516
+
517
+ @app.route("/restore", methods=["POST"])
518
+ def restore():
519
+ """Restore to original style (clear manual overrides)."""
520
+ editor.style_overrides.clear_manual_overrides()
521
+
522
+ # Use pre-rendered static PNG (source of truth)
523
+ if editor._initial_base64 and not editor.dark_mode:
524
+ base64_img = editor._initial_base64
525
+ import base64 as b64
526
+ import io
527
+
528
+ from PIL import Image
529
+
530
+ img_data = b64.b64decode(base64_img)
531
+ img = Image.open(io.BytesIO(img_data))
532
+ img_size = img.size
533
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
534
+ original_dpi = mpl_fig.dpi
535
+ mpl_fig.set_dpi(150)
536
+ mpl_fig.canvas.draw()
537
+ bboxes = extract_bboxes(mpl_fig, img_size[0], img_size[1])
538
+ mpl_fig.set_dpi(original_dpi)
539
+ else:
540
+ # Fallback: re-render with reproduce pipeline
541
+ base64_img, bboxes, img_size = _render_with_overrides(
542
+ editor.fig,
543
+ None,
544
+ editor.dark_mode,
545
+ )
546
+
547
+ return jsonify(
548
+ {
549
+ "success": True,
550
+ "image": base64_img,
551
+ "bboxes": bboxes,
552
+ "img_size": {"width": img_size[0], "height": img_size[1]},
553
+ "original_style": editor.style,
554
+ }
555
+ )
556
+
557
+ @app.route("/diff")
558
+ def get_diff():
559
+ """Get differences between original and manual overrides."""
560
+ return jsonify(
561
+ {
562
+ "diff": editor.style_overrides.get_diff(),
563
+ "has_overrides": editor.style_overrides.has_manual_overrides(),
564
+ }
565
+ )
566
+
567
+ @app.route("/update_label", methods=["POST"])
568
+ def update_label():
569
+ """Update axis labels (title, xlabel, ylabel, suptitle).
570
+
571
+ These are editable text elements that don't affect data integrity.
572
+ """
573
+ data = request.get_json() or {}
574
+ label_type = data.get("label_type") # title, xlabel, ylabel, suptitle
575
+ text = data.get("text", "")
576
+ ax_index = data.get("ax_index", 0) # For multi-axes figures
577
+
578
+ if not label_type:
579
+ return jsonify({"error": "Missing label_type"}), 400
580
+
581
+ # Get the underlying matplotlib figure
582
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
583
+ axes = mpl_fig.get_axes()
584
+
585
+ if not axes:
586
+ return jsonify({"error": "No axes found"}), 400
587
+
588
+ # Get target axes (default to first)
589
+ ax = axes[min(ax_index, len(axes) - 1)]
590
+
591
+ try:
592
+ if label_type == "title":
593
+ ax.set_title(text)
594
+ elif label_type == "xlabel":
595
+ ax.set_xlabel(text)
596
+ elif label_type == "ylabel":
597
+ ax.set_ylabel(text)
598
+ elif label_type == "suptitle":
599
+ if text:
600
+ mpl_fig.suptitle(text)
601
+ else:
602
+ # Clear suptitle by setting to empty string
603
+ if mpl_fig._suptitle:
604
+ mpl_fig._suptitle.set_text("")
605
+ else:
606
+ return jsonify({"error": f"Unknown label_type: {label_type}"}), 400
607
+
608
+ # Track override
609
+ editor.style_overrides.manual_overrides[f"label_{label_type}"] = text
610
+
611
+ # Re-render
612
+ base64_img, bboxes, img_size = _render_with_overrides(
613
+ editor.fig,
614
+ editor.get_effective_style(),
615
+ editor.dark_mode,
616
+ )
617
+
618
+ return jsonify(
619
+ {
620
+ "success": True,
621
+ "image": base64_img,
622
+ "bboxes": bboxes,
623
+ "img_size": {"width": img_size[0], "height": img_size[1]},
624
+ }
625
+ )
626
+
627
+ except Exception as e:
628
+ import traceback
629
+
630
+ traceback.print_exc()
631
+ return jsonify({"error": f"Update failed: {str(e)}"}), 500
632
+
633
+ @app.route("/get_labels")
634
+ def get_labels():
635
+ """Get current axis labels (title, xlabel, ylabel, suptitle)."""
636
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
637
+ axes = mpl_fig.get_axes()
638
+
639
+ labels = {
640
+ "title": "",
641
+ "xlabel": "",
642
+ "ylabel": "",
643
+ "suptitle": "",
644
+ }
645
+
646
+ if axes:
647
+ ax = axes[0] # Use first axes for now
648
+ labels["title"] = ax.get_title()
649
+ labels["xlabel"] = ax.get_xlabel()
650
+ labels["ylabel"] = ax.get_ylabel()
651
+
652
+ if mpl_fig._suptitle:
653
+ labels["suptitle"] = mpl_fig._suptitle.get_text()
654
+
655
+ return jsonify(labels)
656
+
657
+ @app.route("/update_axis_type", methods=["POST"])
658
+ def update_axis_type():
659
+ """Update axis type (numerical vs categorical).
660
+
661
+ Numerical: linear scale with auto ticks
662
+ Categorical: discrete labels at integer positions
663
+ """
664
+ data = request.get_json() or {}
665
+ axis = data.get("axis") # "x" or "y"
666
+ axis_type = data.get("type") # "numerical" or "categorical"
667
+ labels = data.get("labels", []) # For categorical: list of labels
668
+ ax_index = data.get("ax_index", 0)
669
+
670
+ if not axis or not axis_type:
671
+ return jsonify({"error": "Missing axis or type"}), 400
672
+
673
+ # Get the underlying matplotlib figure
674
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
675
+ axes_list = mpl_fig.get_axes()
676
+
677
+ if not axes_list:
678
+ return jsonify({"error": "No axes found"}), 400
679
+
680
+ ax = axes_list[min(ax_index, len(axes_list) - 1)]
681
+
682
+ try:
683
+ if axis == "x":
684
+ if axis_type == "categorical" and labels:
685
+ # Set categorical x-axis
686
+ positions = list(range(len(labels)))
687
+ ax.set_xticks(positions)
688
+ ax.set_xticklabels(labels)
689
+ else:
690
+ # Reset to numerical
691
+ ax.xaxis.set_major_locator(matplotlib.ticker.AutoLocator())
692
+ ax.xaxis.set_major_formatter(
693
+ matplotlib.ticker.ScalarFormatter()
694
+ )
695
+ elif axis == "y":
696
+ if axis_type == "categorical" and labels:
697
+ # Set categorical y-axis
698
+ positions = list(range(len(labels)))
699
+ ax.set_yticks(positions)
700
+ ax.set_yticklabels(labels)
701
+ else:
702
+ # Reset to numerical
703
+ ax.yaxis.set_major_locator(matplotlib.ticker.AutoLocator())
704
+ ax.yaxis.set_major_formatter(
705
+ matplotlib.ticker.ScalarFormatter()
706
+ )
707
+
708
+ # Track override
709
+ key = f"axis_{axis}_type"
710
+ editor.style_overrides.manual_overrides[key] = axis_type
711
+ if labels:
712
+ editor.style_overrides.manual_overrides[f"axis_{axis}_labels"] = (
713
+ labels
714
+ )
715
+
716
+ # Re-render
717
+ base64_img, bboxes, img_size = _render_with_overrides(
718
+ editor.fig,
719
+ editor.get_effective_style(),
720
+ editor.dark_mode,
721
+ )
722
+
723
+ return jsonify(
724
+ {
725
+ "success": True,
726
+ "image": base64_img,
727
+ "bboxes": bboxes,
728
+ "img_size": {"width": img_size[0], "height": img_size[1]},
729
+ }
730
+ )
731
+
732
+ except Exception as e:
733
+ import traceback
734
+
735
+ traceback.print_exc()
736
+ return jsonify({"error": f"Update failed: {str(e)}"}), 500
737
+
738
+ @app.route("/get_axis_info")
739
+ def get_axis_info():
740
+ """Get current axis type info (numerical vs categorical)."""
741
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
742
+ axes_list = mpl_fig.get_axes()
743
+
744
+ info = {
745
+ "x_type": "numerical",
746
+ "y_type": "numerical",
747
+ "x_labels": [],
748
+ "y_labels": [],
749
+ }
750
+
751
+ if axes_list:
752
+ ax = axes_list[0]
753
+
754
+ # Check if x-axis has custom tick labels
755
+ x_ticklabels = [t.get_text() for t in ax.get_xticklabels()]
756
+ if x_ticklabels and any(t for t in x_ticklabels):
757
+ info["x_type"] = "categorical"
758
+ info["x_labels"] = x_ticklabels
759
+
760
+ # Check if y-axis has custom tick labels
761
+ y_ticklabels = [t.get_text() for t in ax.get_yticklabels()]
762
+ if y_ticklabels and any(t for t in y_ticklabels):
763
+ info["y_type"] = "categorical"
764
+ info["y_labels"] = y_ticklabels
765
+
766
+ return jsonify(info)
767
+
768
+ @app.route("/update_legend_position", methods=["POST"])
769
+ def update_legend_position():
770
+ """Update legend position, visibility, or custom xy coordinates.
771
+
772
+ For custom positioning, uses bbox_to_anchor with axes coordinates.
773
+ """
774
+ data = request.get_json() or {}
775
+ loc = data.get("loc") # 'best', 'upper right', 'custom', etc.
776
+ x = data.get("x") # For custom: 0-1+ (axes coordinates)
777
+ y = data.get("y") # For custom: 0-1+ (axes coordinates)
778
+ visible = data.get("visible") # True/False for show/hide
779
+ ax_index = data.get("ax_index", 0)
780
+
781
+ # Get the underlying matplotlib figure
782
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
783
+ axes_list = mpl_fig.get_axes()
784
+
785
+ if not axes_list:
786
+ return jsonify({"error": "No axes found"}), 400
787
+
788
+ ax = axes_list[min(ax_index, len(axes_list) - 1)]
789
+ legend = ax.get_legend()
790
+
791
+ if legend is None:
792
+ return jsonify({"error": "No legend found on this axes"}), 400
793
+
794
+ try:
795
+ # Handle visibility toggle
796
+ if visible is not None:
797
+ legend.set_visible(visible)
798
+ editor.style_overrides.manual_overrides["legend_visible"] = visible
799
+
800
+ # Handle position update only if loc is provided
801
+ if loc is not None:
802
+ if loc == "custom" and x is not None and y is not None:
803
+ # Custom positioning with bbox_to_anchor
804
+ legend.set_bbox_to_anchor((float(x), float(y)))
805
+ legend._loc = 2 # upper left as reference point
806
+ else:
807
+ # Standard location string
808
+ loc_map = {
809
+ "best": 0,
810
+ "upper right": 1,
811
+ "upper left": 2,
812
+ "lower left": 3,
813
+ "lower right": 4,
814
+ "right": 5,
815
+ "center left": 6,
816
+ "center right": 7,
817
+ "lower center": 8,
818
+ "upper center": 9,
819
+ "center": 10,
820
+ }
821
+ loc_code = loc_map.get(loc, 0)
822
+ legend._loc = loc_code
823
+ # Clear bbox_to_anchor when using standard loc
824
+ legend.set_bbox_to_anchor(None)
825
+
826
+ # Track override
827
+ editor.style_overrides.manual_overrides["legend_loc"] = loc
828
+ if loc == "custom":
829
+ editor.style_overrides.manual_overrides["legend_x"] = x
830
+ editor.style_overrides.manual_overrides["legend_y"] = y
831
+
832
+ # Re-render
833
+ base64_img, bboxes, img_size = _render_with_overrides(
834
+ editor.fig,
835
+ editor.get_effective_style(),
836
+ editor.dark_mode,
837
+ )
838
+
839
+ return jsonify(
840
+ {
841
+ "success": True,
842
+ "image": base64_img,
843
+ "bboxes": bboxes,
844
+ "img_size": {"width": img_size[0], "height": img_size[1]},
845
+ }
846
+ )
847
+
848
+ except Exception as e:
849
+ import traceback
850
+
851
+ traceback.print_exc()
852
+ return jsonify({"error": f"Update failed: {str(e)}"}), 500
853
+
854
+ @app.route("/get_legend_info")
855
+ def get_legend_info():
856
+ """Get current legend position info."""
857
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
858
+ axes_list = mpl_fig.get_axes()
859
+
860
+ info = {
861
+ "has_legend": False,
862
+ "visible": True,
863
+ "loc": "best",
864
+ "x": None,
865
+ "y": None,
866
+ }
867
+
868
+ if axes_list:
869
+ ax = axes_list[0]
870
+ legend = ax.get_legend()
871
+
872
+ if legend is not None:
873
+ info["has_legend"] = True
874
+ info["visible"] = legend.get_visible()
875
+
876
+ # Get location code and convert to string
877
+ loc_code = legend._loc
878
+ loc_names = {
879
+ 0: "best",
880
+ 1: "upper right",
881
+ 2: "upper left",
882
+ 3: "lower left",
883
+ 4: "lower right",
884
+ 5: "right",
885
+ 6: "center left",
886
+ 7: "center right",
887
+ 8: "lower center",
888
+ 9: "upper center",
889
+ 10: "center",
890
+ }
891
+ info["loc"] = loc_names.get(loc_code, "best")
892
+
893
+ # Check for bbox_to_anchor (custom position)
894
+ bbox = legend.get_bbox_to_anchor()
895
+ if bbox is not None:
896
+ # Get coordinates from bbox
897
+ try:
898
+ bounds = bbox.bounds
899
+ if bounds[0] != 0 or bounds[1] != 0:
900
+ info["loc"] = "custom"
901
+ info["x"] = bounds[0]
902
+ info["y"] = bounds[1]
903
+ except Exception:
904
+ pass
905
+
906
+ return jsonify(info)
907
+
908
+ @app.route("/update_call", methods=["POST"])
909
+ def update_call():
910
+ """Update a call's kwargs and re-render.
911
+
912
+ Only display kwargs are editable (orientation, colors, etc.).
913
+ Data (x, y arrays) remains read-only for scientific integrity.
914
+ """
915
+ from .._reproducer import reproduce_from_record
916
+
917
+ data = request.get_json() or {}
918
+ call_id = data.get("call_id")
919
+ param = data.get("param")
920
+ value = data.get("value")
921
+
922
+ if not call_id or not param:
923
+ return jsonify({"error": "Missing call_id or param"}), 400
924
+
925
+ # Find and update the call in the record
926
+ updated = False
927
+ if hasattr(editor.fig, "record"):
928
+ for ax_key, ax_record in editor.fig.record.axes.items():
929
+ for call in ax_record.calls:
930
+ if call.id == call_id:
931
+ # Track the override in style_overrides
932
+ editor.style_overrides.set_call_override(
933
+ call_id, param, value
934
+ )
935
+
936
+ # Update the kwarg in the record
937
+ if value is None or value == "" or value == "null":
938
+ call.kwargs.pop(param, None)
939
+ else:
940
+ call.kwargs[param] = value
941
+ updated = True
942
+ break
943
+ if updated:
944
+ break
945
+
946
+ if not updated:
947
+ return jsonify({"error": f"Call {call_id} not found"}), 404
948
+
949
+ # Re-reproduce the figure from the updated record
950
+ try:
951
+ new_fig, new_axes = reproduce_from_record(editor.fig.record)
952
+
953
+ # Apply style overrides to the new figure
954
+ effective_style = editor.get_effective_style()
955
+ base64_img, bboxes, img_size = _render_with_overrides(
956
+ new_fig,
957
+ effective_style if effective_style else None,
958
+ editor.dark_mode,
959
+ )
960
+
961
+ # Update editor's figure reference
962
+ editor.fig = new_fig
963
+
964
+ # Reload hitmap and color map
965
+ from ._hitmap import hitmap_to_base64
966
+
967
+ hitmap_img, color_map = generate_hitmap(
968
+ new_fig, img_size[0], img_size[1]
969
+ )
970
+ editor._color_map = color_map
971
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
972
+ editor._hitmap_generated = True
973
+
974
+ except Exception as e:
975
+ import traceback
976
+
977
+ traceback.print_exc()
978
+ return jsonify({"error": f"Re-render failed: {str(e)}"}), 500
979
+
980
+ return jsonify(
981
+ {
982
+ "success": True,
983
+ "image": base64_img,
984
+ "bboxes": bboxes,
985
+ "img_size": {"width": img_size[0], "height": img_size[1]},
986
+ "call_id": call_id,
987
+ "param": param,
988
+ "value": value,
989
+ "has_call_overrides": editor.style_overrides.has_call_overrides(),
990
+ }
991
+ )
992
+
993
+ @app.route("/download/<fmt>")
994
+ def download(fmt: str):
995
+ """Download figure in specified format.
996
+
997
+ Note: Downloads always use light mode for scientific document compatibility.
998
+ Transparent backgrounds are preserved.
999
+ """
1000
+ import io
1001
+
1002
+ fmt = fmt.lower()
1003
+ if fmt not in ("png", "svg", "pdf"):
1004
+ return jsonify({"error": f"Unsupported format: {fmt}"}), 400
1005
+
1006
+ # Use effective style (base + programmatic + manual)
1007
+ effective_style = editor.get_effective_style()
1008
+ # Always use light mode for scientific documents (dark_mode=False)
1009
+ content = render_download(
1010
+ editor.fig,
1011
+ fmt=fmt,
1012
+ dpi=300,
1013
+ overrides=effective_style if effective_style else None,
1014
+ dark_mode=False, # Scientific documents require light mode
1015
+ )
1016
+
1017
+ mimetype = {
1018
+ "png": "image/png",
1019
+ "svg": "image/svg+xml",
1020
+ "pdf": "application/pdf",
1021
+ }[fmt]
1022
+
1023
+ filename = f"figure.{fmt}"
1024
+ if editor.recipe_path:
1025
+ filename = f"{editor.recipe_path.stem}.{fmt}"
1026
+
1027
+ return send_file(
1028
+ io.BytesIO(content),
1029
+ mimetype=mimetype,
1030
+ as_attachment=True,
1031
+ download_name=filename,
1032
+ )
1033
+
1034
+ @app.route("/shutdown", methods=["POST"])
1035
+ def shutdown():
1036
+ """Shutdown the server."""
1037
+ func = request.environ.get("werkzeug.server.shutdown")
1038
+ if func:
1039
+ func()
1040
+ return jsonify({"success": True})
1041
+
1042
+ # Start server
1043
+ url = f"http://127.0.0.1:{self.port}"
1044
+ print(f"Figure Editor running at {url}")
1045
+ print("Press Ctrl+C to stop and return overrides")
1046
+
1047
+ if open_browser:
1048
+ webbrowser.open(url)
1049
+
1050
+ try:
1051
+ app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
1052
+ except KeyboardInterrupt:
1053
+ print("\nEditor closed")
1054
+
1055
+ return self.overrides
1056
+
1057
+
1058
+ def _get_form_values_from_style(style: Dict[str, Any]) -> Dict[str, Any]:
1059
+ """Extract form field values from a style dictionary.
1060
+
1061
+ Maps style dictionary values to HTML form input IDs.
1062
+
1063
+ Parameters
1064
+ ----------
1065
+ style : dict
1066
+ Style configuration dictionary
1067
+
1068
+ Returns
1069
+ -------
1070
+ dict
1071
+ Mapping of form input IDs to values
1072
+ """
1073
+ values = {}
1074
+
1075
+ # Axes dimensions
1076
+ if "axes" in style:
1077
+ values["axes_width_mm"] = style["axes"].get("width_mm", 80)
1078
+ values["axes_height_mm"] = style["axes"].get("height_mm", 55)
1079
+ values["axes_thickness_mm"] = style["axes"].get("thickness_mm", 0.2)
1080
+
1081
+ # Margins
1082
+ if "margins" in style:
1083
+ values["margins_left_mm"] = style["margins"].get("left_mm", 12)
1084
+ values["margins_right_mm"] = style["margins"].get("right_mm", 3)
1085
+ values["margins_bottom_mm"] = style["margins"].get("bottom_mm", 10)
1086
+ values["margins_top_mm"] = style["margins"].get("top_mm", 3)
1087
+
1088
+ # Spacing
1089
+ if "spacing" in style:
1090
+ values["spacing_horizontal_mm"] = style["spacing"].get("horizontal_mm", 8)
1091
+ values["spacing_vertical_mm"] = style["spacing"].get("vertical_mm", 8)
1092
+
1093
+ # Fonts
1094
+ if "fonts" in style:
1095
+ values["fonts_family"] = style["fonts"].get("family", "Arial")
1096
+ values["fonts_axis_label_pt"] = style["fonts"].get("axis_label_pt", 7)
1097
+ values["fonts_tick_label_pt"] = style["fonts"].get("tick_label_pt", 6)
1098
+ values["fonts_title_pt"] = style["fonts"].get("title_pt", 8)
1099
+ values["fonts_legend_pt"] = style["fonts"].get("legend_pt", 6)
1100
+
1101
+ # Ticks
1102
+ if "ticks" in style:
1103
+ values["ticks_length_mm"] = style["ticks"].get("length_mm", 1.0)
1104
+ values["ticks_thickness_mm"] = style["ticks"].get("thickness_mm", 0.2)
1105
+ values["ticks_direction"] = style["ticks"].get("direction", "out")
1106
+
1107
+ # Lines
1108
+ if "lines" in style:
1109
+ values["lines_trace_mm"] = style["lines"].get("trace_mm", 0.2)
1110
+
1111
+ # Markers
1112
+ if "markers" in style:
1113
+ values["markers_size_mm"] = style["markers"].get("size_mm", 0.8)
1114
+
1115
+ # Output
1116
+ if "output" in style:
1117
+ values["output_dpi"] = style["output"].get("dpi", 300)
1118
+
1119
+ # Behavior
1120
+ if "behavior" in style:
1121
+ values["behavior_hide_top_spine"] = style["behavior"].get(
1122
+ "hide_top_spine", True
1123
+ )
1124
+ values["behavior_hide_right_spine"] = style["behavior"].get(
1125
+ "hide_right_spine", True
1126
+ )
1127
+ values["behavior_grid"] = style["behavior"].get("grid", False)
1128
+
1129
+ # Legend
1130
+ if "legend" in style:
1131
+ values["legend_frameon"] = style["legend"].get("frameon", True)
1132
+
1133
+ return values
1134
+
1135
+
1136
+ def _render_with_overrides(
1137
+ fig, overrides: Optional[Dict[str, Any]], dark_mode: bool = False
1138
+ ):
1139
+ """
1140
+ Re-render figure with overrides applied directly.
1141
+
1142
+ Applies style overrides directly to the existing figure for reliable rendering.
1143
+ """
1144
+ import base64
1145
+ import io
1146
+ import warnings
1147
+
1148
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
1149
+ from PIL import Image
1150
+
1151
+ from ._bbox import extract_bboxes
1152
+ from ._renderer import _apply_dark_mode, _apply_overrides
1153
+
1154
+ # Get the underlying matplotlib figure
1155
+ new_fig = fig.fig if hasattr(fig, "fig") else fig
1156
+
1157
+ # Safety check: validate figure size before rendering
1158
+ fig_width, fig_height = new_fig.get_size_inches()
1159
+ dpi = 150
1160
+ pixel_width = fig_width * dpi
1161
+ pixel_height = fig_height * dpi
1162
+
1163
+ # Sanity check: prevent enormous figures (max 10000x10000 pixels)
1164
+ MAX_PIXELS = 10000
1165
+ if pixel_width > MAX_PIXELS or pixel_height > MAX_PIXELS:
1166
+ # Reset to reasonable size
1167
+ new_fig.set_size_inches(
1168
+ min(fig_width, MAX_PIXELS / dpi), min(fig_height, MAX_PIXELS / dpi)
1169
+ )
1170
+
1171
+ # Switch to Agg backend to avoid Tkinter thread issues
1172
+ new_fig.set_canvas(FigureCanvasAgg(new_fig))
1173
+
1174
+ # Disable constrained_layout if present (can cause rendering issues on repeated calls)
1175
+ # Store original state to restore later
1176
+ layout_engine = new_fig.get_layout_engine()
1177
+ if layout_engine is not None and hasattr(layout_engine, "__class__"):
1178
+ layout_name = layout_engine.__class__.__name__
1179
+ if "Constrained" in layout_name:
1180
+ new_fig.set_layout_engine("none")
1181
+
1182
+ # Apply overrides directly to existing figure
1183
+ if overrides:
1184
+ _apply_overrides(new_fig, overrides)
1185
+
1186
+ # Apply dark mode if requested
1187
+ if dark_mode:
1188
+ _apply_dark_mode(new_fig)
1189
+
1190
+ # Validate axes bounds before rendering (prevent infinite/invalid extents)
1191
+ for ax in new_fig.get_axes():
1192
+ xlim = ax.get_xlim()
1193
+ ylim = ax.get_ylim()
1194
+ # Check for invalid limits (inf, nan, or extremely large)
1195
+ if any(not (-1e10 < v < 1e10) for v in xlim + ylim):
1196
+ ax.set_xlim(-1, 1)
1197
+ ax.set_ylim(-1, 1)
1198
+
1199
+ # Save to PNG using same params as static save
1200
+ # Catch constrained_layout warnings and handle gracefully
1201
+ buf = io.BytesIO()
1202
+ with warnings.catch_warnings():
1203
+ warnings.filterwarnings("ignore", "constrained_layout not applied")
1204
+ try:
1205
+ new_fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
1206
+ except (MemoryError, ValueError):
1207
+ # Fall back to saving without bbox_inches="tight"
1208
+ buf = io.BytesIO()
1209
+ new_fig.savefig(buf, format="png", dpi=150)
1210
+ buf.seek(0)
1211
+ png_bytes = buf.read()
1212
+ base64_str = base64.b64encode(png_bytes).decode("utf-8")
1213
+
1214
+ # Get image size
1215
+ buf.seek(0)
1216
+ img = Image.open(buf)
1217
+ img_size = img.size
1218
+
1219
+ # Extract bboxes
1220
+ original_dpi = new_fig.dpi
1221
+ new_fig.set_dpi(150)
1222
+ new_fig.canvas.draw()
1223
+ bboxes = extract_bboxes(new_fig, img_size[0], img_size[1])
1224
+ new_fig.set_dpi(original_dpi)
1225
+
1226
+ return base64_str, bboxes, img_size
1227
+
1228
+
1229
+ __all__ = ["FigureEditor"]