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
figrecipe/__init__.py CHANGED
@@ -50,20 +50,29 @@ Inspecting a recipe:
50
50
  """
51
51
 
52
52
  from pathlib import Path
53
- from typing import Any, Dict, List, Literal, Optional, Tuple, Union
53
+ from typing import Any, Dict, List, Optional, Tuple, Union
54
54
 
55
- import matplotlib.pyplot as plt
56
55
  from matplotlib.axes import Axes
57
56
  from matplotlib.figure import Figure
57
+ from numpy.typing import NDArray
58
58
 
59
- from ._recorder import Recorder, FigureRecord, CallRecord
59
+ from ._recorder import CallRecord, FigureRecord
60
+ from ._reproducer import get_recipe_info
61
+ from ._reproducer import reproduce as _reproduce
62
+ from ._serializer import load_recipe
63
+ from ._utils._numpy_io import DataFormat
64
+ from ._utils._units import (
65
+ inch_to_mm,
66
+ mm_to_inch,
67
+ mm_to_pt,
68
+ mm_to_scatter_size,
69
+ normalize_color,
70
+ pt_to_mm,
71
+ )
72
+ from ._validator import ValidationResult
60
73
  from ._wrappers import RecordingAxes, RecordingFigure
61
74
  from ._wrappers._figure import create_recording_subplots
62
- from ._serializer import save_recipe, load_recipe, recipe_to_dict
63
- from ._reproducer import reproduce as _reproduce, get_recipe_info
64
- from ._utils._numpy_io import DataFormat
65
- from ._utils._units import mm_to_inch, mm_to_pt, inch_to_mm, pt_to_mm, mm_to_scatter_size, normalize_color
66
- from .styles._style_applier import list_available_fonts, check_font
75
+ from .styles._style_applier import check_font, list_available_fonts
67
76
 
68
77
  # Notebook display format flag (set once per session)
69
78
  _notebook_format_set = False
@@ -82,16 +91,20 @@ def _enable_notebook_svg():
82
91
  try:
83
92
  # Method 1: matplotlib_inline (IPython 7.0+, JupyterLab)
84
93
  from matplotlib_inline.backend_inline import set_matplotlib_formats
85
- set_matplotlib_formats('svg')
94
+
95
+ set_matplotlib_formats("svg")
86
96
  _notebook_format_set = True
87
97
  except (ImportError, Exception):
88
98
  try:
89
99
  # Method 2: IPython config (older IPython)
90
100
  from IPython import get_ipython
101
+
91
102
  ipython = get_ipython()
92
- if ipython is not None and hasattr(ipython, 'kernel'):
103
+ if ipython is not None and hasattr(ipython, "kernel"):
93
104
  # Only run in actual Jupyter kernel, not IPython console
94
- ipython.run_line_magic('config', "InlineBackend.figure_formats = ['svg']")
105
+ ipython.run_line_magic(
106
+ "config", "InlineBackend.figure_formats = ['svg']"
107
+ )
95
108
  _notebook_format_set = True
96
109
  except Exception:
97
110
  pass # Not in Jupyter environment or method not available
@@ -122,6 +135,7 @@ def _get_sns():
122
135
  global _sns_recorder
123
136
  if _sns_recorder is None:
124
137
  from ._seaborn import get_seaborn_recorder
138
+
125
139
  _sns_recorder = get_seaborn_recorder()
126
140
  return _sns_recorder
127
141
 
@@ -146,6 +160,8 @@ __all__ = [
146
160
  "load",
147
161
  "extract_data",
148
162
  "validate",
163
+ # GUI Editor
164
+ "edit",
149
165
  # Style system
150
166
  "load_style",
151
167
  "unload_style",
@@ -174,6 +190,8 @@ __all__ = [
174
190
  "ValidationResult",
175
191
  # Image utilities
176
192
  "crop",
193
+ # Panel labels
194
+ "panel_label",
177
195
  # Version
178
196
  "__version__",
179
197
  ]
@@ -230,6 +248,7 @@ def load_style(style="SCITEX", dark=False):
230
248
  40
231
249
  """
232
250
  from .styles import load_style as _load_style
251
+
233
252
  return _load_style(style, dark=dark)
234
253
 
235
254
 
@@ -248,6 +267,7 @@ def unload_style():
248
267
  >>> fig, ax = fr.subplots() # Vanilla matplotlib
249
268
  """
250
269
  from .styles import unload_style as _unload_style
270
+
251
271
  _unload_style()
252
272
 
253
273
 
@@ -266,6 +286,7 @@ def list_presets():
266
286
  ['MINIMAL', 'PRESENTATION', 'SCIENTIFIC']
267
287
  """
268
288
  from .styles import list_presets as _list_presets
289
+
269
290
  return _list_presets()
270
291
 
271
292
 
@@ -293,9 +314,10 @@ def apply_style(ax, style=None):
293
314
  >>> ax.plot(x, y, lw=trace_lw)
294
315
  """
295
316
  from .styles import apply_style_mm, get_style, to_subplots_kwargs
317
+
296
318
  if style is None:
297
319
  style = to_subplots_kwargs(get_style())
298
- elif hasattr(style, 'to_subplots_kwargs'):
320
+ elif hasattr(style, "to_subplots_kwargs"):
299
321
  style = style.to_subplots_kwargs()
300
322
  return apply_style_mm(ax, style)
301
323
 
@@ -305,10 +327,12 @@ class _StyleProxy:
305
327
 
306
328
  def __getattr__(self, name):
307
329
  from .styles import STYLE
330
+
308
331
  return getattr(STYLE, name)
309
332
 
310
333
  def to_subplots_kwargs(self):
311
334
  from .styles import to_subplots_kwargs
335
+
312
336
  return to_subplots_kwargs()
313
337
 
314
338
 
@@ -331,7 +355,7 @@ def subplots(
331
355
  style: Optional[Dict[str, Any]] = None,
332
356
  apply_style_mm: bool = True,
333
357
  **kwargs,
334
- ) -> Tuple[RecordingFigure, Union[RecordingAxes, List[RecordingAxes]]]:
358
+ ) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
335
359
  """Create a figure with recording-enabled axes.
336
360
 
337
361
  This is a drop-in replacement for plt.subplots() that wraps the
@@ -380,8 +404,8 @@ def subplots(
380
404
  -------
381
405
  fig : RecordingFigure
382
406
  Wrapped figure object.
383
- axes : RecordingAxes or list of RecordingAxes
384
- Wrapped axes (single for 1x1, list otherwise).
407
+ axes : RecordingAxes or ndarray
408
+ Wrapped axes (single for 1x1, numpy array otherwise matching matplotlib).
385
409
 
386
410
  Examples
387
411
  --------
@@ -408,6 +432,7 @@ def subplots(
408
432
  """
409
433
  # Get global style for default values (if loaded)
410
434
  from .styles._style_loader import _STYLE_CACHE
435
+
411
436
  global_style = _STYLE_CACHE
412
437
 
413
438
  # Helper to get value with priority: explicit > global style > hardcoded default
@@ -418,7 +443,11 @@ def subplots(
418
443
  try:
419
444
  val = global_style
420
445
  for key in style_path:
421
- val = val.get(key) if isinstance(val, dict) else getattr(val, key, None)
446
+ val = (
447
+ val.get(key)
448
+ if isinstance(val, dict)
449
+ else getattr(val, key, None)
450
+ )
422
451
  if val is None:
423
452
  break
424
453
  if val is not None:
@@ -428,98 +457,115 @@ def subplots(
428
457
  return default
429
458
 
430
459
  # Check if mm-based layout is requested (explicit OR from global style)
431
- has_explicit_mm = any([
432
- axes_width_mm is not None,
433
- axes_height_mm is not None,
434
- margin_left_mm is not None,
435
- margin_right_mm is not None,
436
- margin_bottom_mm is not None,
437
- margin_top_mm is not None,
438
- space_w_mm is not None,
439
- space_h_mm is not None,
440
- ])
460
+ has_explicit_mm = any(
461
+ [
462
+ axes_width_mm is not None,
463
+ axes_height_mm is not None,
464
+ margin_left_mm is not None,
465
+ margin_right_mm is not None,
466
+ margin_bottom_mm is not None,
467
+ margin_top_mm is not None,
468
+ space_w_mm is not None,
469
+ space_h_mm is not None,
470
+ ]
471
+ )
441
472
 
442
473
  # Also use mm layout if global style has mm values
443
474
  has_style_mm = False
444
475
  if global_style is not None:
445
476
  try:
446
477
  has_style_mm = (
447
- global_style.get('axes', {}).get('width_mm') is not None or
448
- getattr(getattr(global_style, 'axes', None), 'width_mm', None) is not None
478
+ global_style.get("axes", {}).get("width_mm") is not None
479
+ or getattr(getattr(global_style, "axes", None), "width_mm", None)
480
+ is not None
449
481
  )
450
482
  except (KeyError, AttributeError):
451
483
  pass
452
484
 
453
485
  use_mm_layout = has_explicit_mm or has_style_mm
454
486
 
455
- if use_mm_layout and 'figsize' not in kwargs:
487
+ if use_mm_layout and "figsize" not in kwargs:
456
488
  # Get mm values: explicit params > global style > hardcoded defaults
457
- aw = _get_mm(axes_width_mm, ['axes', 'width_mm'], 40)
458
- ah = _get_mm(axes_height_mm, ['axes', 'height_mm'], 28)
459
- ml = _get_mm(margin_left_mm, ['margins', 'left_mm'], 15)
460
- mr = _get_mm(margin_right_mm, ['margins', 'right_mm'], 5)
461
- mb = _get_mm(margin_bottom_mm, ['margins', 'bottom_mm'], 12)
462
- mt = _get_mm(margin_top_mm, ['margins', 'top_mm'], 8)
463
- sw = _get_mm(space_w_mm, ['spacing', 'horizontal_mm'], 8)
464
- sh = _get_mm(space_h_mm, ['spacing', 'vertical_mm'], 10)
489
+ aw = _get_mm(axes_width_mm, ["axes", "width_mm"], 40)
490
+ ah = _get_mm(axes_height_mm, ["axes", "height_mm"], 28)
491
+ ml = _get_mm(margin_left_mm, ["margins", "left_mm"], 15)
492
+ mr = _get_mm(margin_right_mm, ["margins", "right_mm"], 5)
493
+ mb = _get_mm(margin_bottom_mm, ["margins", "bottom_mm"], 12)
494
+ mt = _get_mm(margin_top_mm, ["margins", "top_mm"], 8)
495
+ sw = _get_mm(space_w_mm, ["spacing", "horizontal_mm"], 8)
496
+ sh = _get_mm(space_h_mm, ["spacing", "vertical_mm"], 10)
465
497
 
466
498
  # Calculate total figure size
467
499
  total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
468
500
  total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
469
501
 
470
502
  # Convert to inches and set figsize
471
- kwargs['figsize'] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
503
+ kwargs["figsize"] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
472
504
 
473
505
  # Store mm metadata for recording (will be extracted by create_recording_subplots)
474
506
  mm_layout = {
475
- 'axes_width_mm': aw,
476
- 'axes_height_mm': ah,
477
- 'margin_left_mm': ml,
478
- 'margin_right_mm': mr,
479
- 'margin_bottom_mm': mb,
480
- 'margin_top_mm': mt,
481
- 'space_w_mm': sw,
482
- 'space_h_mm': sh,
507
+ "axes_width_mm": aw,
508
+ "axes_height_mm": ah,
509
+ "margin_left_mm": ml,
510
+ "margin_right_mm": mr,
511
+ "margin_bottom_mm": mb,
512
+ "margin_top_mm": mt,
513
+ "space_w_mm": sw,
514
+ "space_h_mm": sh,
483
515
  }
484
516
  else:
485
517
  mm_layout = None
486
518
 
487
519
  # Apply DPI from global style if not explicitly provided
488
- if 'dpi' not in kwargs and global_style is not None:
520
+ if "dpi" not in kwargs and global_style is not None:
489
521
  # Try figure.dpi first, then output.dpi
490
522
  style_dpi = None
491
523
  try:
492
- if hasattr(global_style, 'figure') and hasattr(global_style.figure, 'dpi'):
524
+ if hasattr(global_style, "figure") and hasattr(global_style.figure, "dpi"):
493
525
  style_dpi = global_style.figure.dpi
494
- elif hasattr(global_style, 'output') and hasattr(global_style.output, 'dpi'):
526
+ elif hasattr(global_style, "output") and hasattr(
527
+ global_style.output, "dpi"
528
+ ):
495
529
  style_dpi = global_style.output.dpi
496
530
  except (KeyError, AttributeError):
497
531
  pass
498
532
  if style_dpi is not None:
499
- kwargs['dpi'] = style_dpi
533
+ kwargs["dpi"] = style_dpi
500
534
 
501
535
  # Handle style parameter
502
536
  if style is not None:
503
- if hasattr(style, 'to_subplots_kwargs'):
537
+ if hasattr(style, "to_subplots_kwargs"):
504
538
  # Merge style kwargs (style values are overridden by explicit params)
505
539
  style_kwargs = style.to_subplots_kwargs()
506
540
  for key, value in style_kwargs.items():
507
541
  if key not in kwargs:
508
542
  kwargs[key] = value
509
543
 
510
- # Use constrained_layout by default for non-mm layouts (better auto-spacing)
511
- # Don't use it with mm-based layout since we manually control positioning
512
- if not use_mm_layout and 'constrained_layout' not in kwargs:
513
- kwargs['constrained_layout'] = True
544
+ # Check if style specifies constrained_layout
545
+ style_constrained = False
546
+ if global_style is not None:
547
+ from .styles._style_loader import to_subplots_kwargs
548
+
549
+ style_dict_check = to_subplots_kwargs(global_style)
550
+ style_constrained = style_dict_check.get("constrained_layout", False)
551
+
552
+ # Use constrained_layout if: style specifies it, or non-mm layout (better auto-spacing)
553
+ if "constrained_layout" not in kwargs:
554
+ if style_constrained:
555
+ kwargs["constrained_layout"] = True
556
+ elif not use_mm_layout:
557
+ kwargs["constrained_layout"] = True
514
558
 
515
559
  # Create the recording subplots
516
560
  fig, axes = create_recording_subplots(nrows, ncols, **kwargs)
517
561
 
518
562
  # Record constrained_layout setting for reproduction
519
- fig.record.constrained_layout = kwargs.get('constrained_layout', False)
563
+ fig.record.constrained_layout = kwargs.get("constrained_layout", False)
520
564
 
521
565
  # Store mm_layout metadata on figure for serialization
522
- if mm_layout is not None:
566
+ # Skip mm-based layout if constrained_layout is True (they're incompatible)
567
+ use_constrained = kwargs.get("constrained_layout", False)
568
+ if mm_layout is not None and not use_constrained:
523
569
  fig._mm_layout = mm_layout
524
570
 
525
571
  # Apply subplots_adjust to position axes correctly
@@ -547,12 +593,12 @@ def subplots(
547
593
 
548
594
  # Record layout in figure record for reproduction
549
595
  fig.record.layout = {
550
- 'left': left,
551
- 'right': right,
552
- 'bottom': bottom,
553
- 'top': top,
554
- 'wspace': wspace,
555
- 'hspace': hspace,
596
+ "left": left,
597
+ "right": right,
598
+ "bottom": bottom,
599
+ "top": top,
600
+ "wspace": wspace,
601
+ "hspace": hspace,
556
602
  }
557
603
 
558
604
  # Apply styling if requested and a style is actually loaded
@@ -562,25 +608,32 @@ def subplots(
562
608
  if style is not None:
563
609
  # Explicit style parameter provided
564
610
  should_apply_style = True
565
- style_dict = style.to_subplots_kwargs() if hasattr(style, 'to_subplots_kwargs') else style
611
+ style_dict = (
612
+ style.to_subplots_kwargs()
613
+ if hasattr(style, "to_subplots_kwargs")
614
+ else style
615
+ )
566
616
  elif apply_style_mm and global_style is not None:
567
617
  # Use global style if loaded and has meaningful values (not MATPLOTLIB)
568
618
  from .styles import to_subplots_kwargs
619
+
569
620
  style_dict = to_subplots_kwargs(global_style)
570
621
  # Only apply if style has essential mm values (skip MATPLOTLIB which has all None)
571
- if style_dict and style_dict.get('axes_thickness_mm') is not None:
622
+ if style_dict and style_dict.get("axes_thickness_mm") is not None:
572
623
  should_apply_style = True
573
624
 
574
625
  if should_apply_style and style_dict:
575
626
  from .styles import apply_style_mm as _apply_style
627
+
576
628
  if nrows == 1 and ncols == 1:
577
629
  _apply_style(axes._ax, style_dict)
578
630
  else:
579
631
  # Handle 2D array of axes
580
632
  import numpy as np
633
+
581
634
  axes_array = np.array(axes)
582
635
  for ax in axes_array.flat:
583
- _apply_style(ax._ax if hasattr(ax, '_ax') else ax, style_dict)
636
+ _apply_style(ax._ax if hasattr(ax, "_ax") else ax, style_dict)
584
637
 
585
638
  # Record style in figure record for reproduction
586
639
  fig.record.style = style_dict
@@ -676,52 +729,64 @@ def save(
676
729
  )
677
730
 
678
731
  # Determine image and YAML paths based on extension
679
- IMAGE_EXTENSIONS = {'.png', '.pdf', '.svg', '.jpg', '.jpeg', '.eps', '.tiff', '.tif'}
680
- YAML_EXTENSIONS = {'.yaml', '.yml'}
732
+ IMAGE_EXTENSIONS = {
733
+ ".png",
734
+ ".pdf",
735
+ ".svg",
736
+ ".jpg",
737
+ ".jpeg",
738
+ ".eps",
739
+ ".tiff",
740
+ ".tif",
741
+ }
742
+ YAML_EXTENSIONS = {".yaml", ".yml"}
681
743
 
682
744
  suffix_lower = path.suffix.lower()
683
745
 
684
746
  if suffix_lower in IMAGE_EXTENSIONS:
685
747
  # User provided image path
686
748
  image_path = path
687
- yaml_path = path.with_suffix('.yaml')
749
+ yaml_path = path.with_suffix(".yaml")
688
750
  img_format = suffix_lower[1:] # Remove leading dot
689
751
  elif suffix_lower in YAML_EXTENSIONS:
690
752
  # User provided YAML path
691
753
  yaml_path = path
692
754
  # Determine image format from style or default
693
755
  if image_format is not None:
694
- img_format = image_format.lower().lstrip('.')
756
+ img_format = image_format.lower().lstrip(".")
695
757
  else:
696
758
  # Check global style for preferred format
697
759
  from .styles._style_loader import _STYLE_CACHE
760
+
698
761
  if _STYLE_CACHE is not None:
699
762
  try:
700
763
  img_format = _STYLE_CACHE.output.format.lower()
701
764
  except (KeyError, AttributeError):
702
- img_format = 'png'
765
+ img_format = "png"
703
766
  else:
704
- img_format = 'png'
705
- image_path = path.with_suffix(f'.{img_format}')
767
+ img_format = "png"
768
+ image_path = path.with_suffix(f".{img_format}")
706
769
  else:
707
770
  # Unknown extension - treat as base name, add both extensions
708
- yaml_path = path.with_suffix('.yaml')
771
+ yaml_path = path.with_suffix(".yaml")
709
772
  if image_format is not None:
710
- img_format = image_format.lower().lstrip('.')
773
+ img_format = image_format.lower().lstrip(".")
711
774
  else:
712
775
  from .styles._style_loader import _STYLE_CACHE
776
+
713
777
  if _STYLE_CACHE is not None:
714
778
  try:
715
779
  img_format = _STYLE_CACHE.output.format.lower()
716
780
  except (KeyError, AttributeError):
717
- img_format = 'png'
781
+ img_format = "png"
718
782
  else:
719
- img_format = 'png'
720
- image_path = path.with_suffix(f'.{img_format}')
783
+ img_format = "png"
784
+ image_path = path.with_suffix(f".{img_format}")
721
785
 
722
786
  # Get DPI from style if not specified
723
787
  if dpi is None:
724
788
  from .styles._style_loader import _STYLE_CACHE
789
+
725
790
  if _STYLE_CACHE is not None:
726
791
  try:
727
792
  dpi = _STYLE_CACHE.output.dpi
@@ -733,31 +798,44 @@ def save(
733
798
  # Get transparency setting from style
734
799
  transparent = False
735
800
  from .styles._style_loader import _STYLE_CACHE
801
+
736
802
  if _STYLE_CACHE is not None:
737
803
  try:
738
804
  transparent = _STYLE_CACHE.output.transparent
739
805
  except (KeyError, AttributeError):
740
806
  pass
741
807
 
808
+ # Finalize tick configuration for all axes (avoids categorical axis interference)
809
+ from .styles._style_applier import finalize_ticks
810
+
811
+ for ax in fig.fig.get_axes():
812
+ finalize_ticks(ax)
813
+
742
814
  # Save the image
743
- fig.fig.savefig(image_path, dpi=dpi, bbox_inches='tight', transparent=transparent)
815
+ fig.fig.savefig(image_path, dpi=dpi, bbox_inches="tight", transparent=transparent)
744
816
 
745
817
  # Save the recipe
746
- saved_yaml = fig.save_recipe(yaml_path, include_data=include_data, data_format=data_format)
818
+ saved_yaml = fig.save_recipe(
819
+ yaml_path, include_data=include_data, data_format=data_format
820
+ )
747
821
 
748
822
  # Validate if requested
749
823
  if validate:
750
824
  from ._validator import validate_on_save
825
+
751
826
  result = validate_on_save(fig, saved_yaml, mse_threshold=validate_mse_threshold)
752
827
  status = "PASSED" if result.valid else "FAILED"
753
828
  if verbose:
754
- print(f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})")
829
+ print(
830
+ f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})"
831
+ )
755
832
  if not result.valid:
756
833
  msg = f"Reproducibility validation failed (MSE={result.mse:.1f}): {result.message}"
757
834
  if validate_error_level == "error":
758
835
  raise ValueError(msg)
759
836
  elif validate_error_level == "warning":
760
837
  import warnings
838
+
761
839
  warnings.warn(msg, UserWarning)
762
840
  # "debug" level: silent, just return the result
763
841
  return image_path, yaml_path, result
@@ -902,8 +980,17 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
902
980
 
903
981
  # Decoration functions to skip
904
982
  decoration_funcs = {
905
- "set_xlabel", "set_ylabel", "set_title", "set_xlim", "set_ylim",
906
- "legend", "grid", "axhline", "axvline", "text", "annotate",
983
+ "set_xlabel",
984
+ "set_ylabel",
985
+ "set_title",
986
+ "set_xlim",
987
+ "set_ylim",
988
+ "legend",
989
+ "grid",
990
+ "axhline",
991
+ "axvline",
992
+ "text",
993
+ "annotate",
907
994
  }
908
995
 
909
996
  for ax_key, ax_record in record.axes.items():
@@ -921,7 +1008,11 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
921
1008
  return np.array(data["data"])
922
1009
  if hasattr(data, "tolist"): # Already array-like
923
1010
  return np.array(data)
924
- return np.array(list(data) if hasattr(data, "__iter__") and not isinstance(data, str) else data)
1011
+ return np.array(
1012
+ list(data)
1013
+ if hasattr(data, "__iter__") and not isinstance(data, str)
1014
+ else data
1015
+ )
925
1016
 
926
1017
  # Extract positional arguments based on function type
927
1018
  if call.function in ("plot", "scatter", "fill_between"):
@@ -950,7 +1041,11 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
950
1041
  for key in ("c", "s", "yerr", "xerr", "weights", "bins"):
951
1042
  if key in call.kwargs:
952
1043
  val = call.kwargs[key]
953
- if isinstance(val, (list, tuple)) or hasattr(val, "__iter__") and not isinstance(val, str):
1044
+ if (
1045
+ isinstance(val, (list, tuple))
1046
+ or hasattr(val, "__iter__")
1047
+ and not isinstance(val, str)
1048
+ ):
954
1049
  call_data[key] = to_array(val)
955
1050
  else:
956
1051
  call_data[key] = val
@@ -961,10 +1056,6 @@ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
961
1056
  return result
962
1057
 
963
1058
 
964
- # Import ValidationResult for type hints
965
- from ._validator import ValidationResult, validate_recipe
966
-
967
-
968
1059
  def validate(
969
1060
  path: Union[str, Path],
970
1061
  mse_threshold: float = 100.0,
@@ -1003,11 +1094,13 @@ def validate(
1003
1094
  """
1004
1095
  # For standalone validation, we reproduce twice and compare
1005
1096
  # (This validates the recipe is self-consistent)
1006
- from ._reproducer import reproduce
1007
- from ._utils._image_diff import compare_images
1008
1097
  import tempfile
1098
+
1009
1099
  import numpy as np
1010
1100
 
1101
+ from ._reproducer import reproduce
1102
+ from ._utils._image_diff import compare_images
1103
+
1011
1104
  path = Path(path)
1012
1105
 
1013
1106
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -1040,7 +1133,9 @@ def validate(
1040
1133
  valid=valid,
1041
1134
  mse=mse if not np.isnan(mse) else float("inf"),
1042
1135
  psnr=diff["psnr"],
1043
- max_diff=diff["max_diff"] if not np.isnan(diff["max_diff"]) else float("inf"),
1136
+ max_diff=diff["max_diff"]
1137
+ if not np.isnan(diff["max_diff"])
1138
+ else float("inf"),
1044
1139
  size_original=diff["size1"],
1045
1140
  size_reproduced=diff["size2"],
1046
1141
  same_size=diff["same_size"],
@@ -1049,7 +1144,14 @@ def validate(
1049
1144
  )
1050
1145
 
1051
1146
 
1052
- def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=False, verbose=False):
1147
+ def crop(
1148
+ input_path,
1149
+ output_path=None,
1150
+ margin_mm=1.0,
1151
+ margin_px=None,
1152
+ overwrite=False,
1153
+ verbose=False,
1154
+ ):
1053
1155
  """Crop a figure image to its content area with a specified margin.
1054
1156
 
1055
1157
  Automatically detects background color (from corners) and crops to
@@ -1087,4 +1189,170 @@ def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=
1087
1189
  >>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
1088
1190
  """
1089
1191
  from ._utils._crop import crop as _crop
1192
+
1090
1193
  return _crop(input_path, output_path, margin_mm, margin_px, overwrite, verbose)
1194
+
1195
+
1196
+ def edit(
1197
+ source,
1198
+ style=None,
1199
+ port: int = 5050,
1200
+ open_browser: bool = True,
1201
+ ):
1202
+ """Launch interactive GUI editor for figure styling.
1203
+
1204
+ Opens a browser-based editor that allows interactive adjustment of
1205
+ figure styles using hitmap-based element selection.
1206
+
1207
+ Parameters
1208
+ ----------
1209
+ source : RecordingFigure, str, or Path
1210
+ Either a live RecordingFigure object or path to a .yaml recipe file.
1211
+ style : str or dict, optional
1212
+ Style preset name (e.g., 'SCITEX', 'SCITEX_DARK') or style dict.
1213
+ If None, uses the currently loaded global style.
1214
+ port : int, optional
1215
+ Flask server port (default: 5050). Auto-finds available port if occupied.
1216
+ open_browser : bool, optional
1217
+ Whether to open browser automatically (default: True).
1218
+
1219
+ Returns
1220
+ -------
1221
+ dict
1222
+ Final style overrides after editing session.
1223
+
1224
+ Examples
1225
+ --------
1226
+ Edit a live figure:
1227
+
1228
+ >>> import figrecipe as fr
1229
+ >>> fig, ax = fr.subplots()
1230
+ >>> ax.plot([1, 2, 3], [1, 4, 9], id='quadratic')
1231
+ >>> overrides = fr.edit(fig)
1232
+
1233
+ Edit a saved recipe:
1234
+
1235
+ >>> overrides = fr.edit('my_figure.yaml')
1236
+
1237
+ With explicit style:
1238
+
1239
+ >>> overrides = fr.edit(fig, style='SCITEX_DARK')
1240
+
1241
+ Notes
1242
+ -----
1243
+ Requires Flask to be installed. Install with:
1244
+ pip install figrecipe[editor]
1245
+ or:
1246
+ pip install flask pillow
1247
+ """
1248
+ from ._editor import edit as _edit
1249
+
1250
+ return _edit(source, style=style, port=port, open_browser=open_browser)
1251
+
1252
+
1253
+ def panel_label(
1254
+ ax,
1255
+ label: str,
1256
+ loc: str = "upper left",
1257
+ fontsize: Optional[float] = None,
1258
+ fontweight: str = "bold",
1259
+ offset: Tuple[float, float] = (-0.1, 1.05),
1260
+ **kwargs,
1261
+ ):
1262
+ """Add a panel label (A, B, C, ...) to an axes.
1263
+
1264
+ Panel labels are commonly used in multi-panel scientific figures to
1265
+ identify individual subplots. This function places a label at the
1266
+ specified location relative to the axes.
1267
+
1268
+ Parameters
1269
+ ----------
1270
+ ax : Axes or RecordingAxes
1271
+ The axes to label.
1272
+ label : str
1273
+ The label text (e.g., 'A', 'B', 'a)', '(1)').
1274
+ loc : str, optional
1275
+ Label location: 'upper left' (default), 'upper right',
1276
+ 'lower left', 'lower right', or 'outside'.
1277
+ fontsize : float, optional
1278
+ Font size in points. If None, uses title font size from style or 10.
1279
+ fontweight : str, optional
1280
+ Font weight: 'bold' (default), 'normal', etc.
1281
+ offset : tuple of float, optional
1282
+ (x, y) offset in axes coordinates. Default (-0.1, 1.05) places
1283
+ label slightly outside top-left corner.
1284
+ **kwargs
1285
+ Additional arguments passed to ax.text().
1286
+
1287
+ Returns
1288
+ -------
1289
+ Text
1290
+ The matplotlib Text object.
1291
+
1292
+ Examples
1293
+ --------
1294
+ >>> import figrecipe as fr
1295
+ >>> fig, axes = fr.subplots(nrows=2, ncols=2)
1296
+ >>> for i, ax in enumerate(axes.flat):
1297
+ ... fr.panel_label(ax, chr(65 + i)) # A, B, C, D
1298
+
1299
+ >>> # Custom styling
1300
+ >>> fr.panel_label(ax, 'a)', fontsize=12, fontweight='normal')
1301
+
1302
+ >>> # Outside position (default)
1303
+ >>> fr.panel_label(ax, 'A', loc='upper left')
1304
+ """
1305
+ # Get fontsize from style if available, otherwise default to 10pt
1306
+ if fontsize is None:
1307
+ try:
1308
+ from .styles._style_loader import _STYLE_CACHE
1309
+
1310
+ if _STYLE_CACHE is not None:
1311
+ fontsize = getattr(
1312
+ getattr(_STYLE_CACHE, "fonts", None), "panel_label_pt", 10
1313
+ )
1314
+ else:
1315
+ fontsize = 10
1316
+ except Exception:
1317
+ fontsize = 10
1318
+
1319
+ # Calculate position based on loc
1320
+ if loc == "upper left":
1321
+ x, y = offset
1322
+ elif loc == "upper right":
1323
+ x, y = 1.0 + abs(offset[0]), offset[1]
1324
+ elif loc == "lower left":
1325
+ x, y = offset[0], -abs(offset[1]) + 1.0
1326
+ elif loc == "lower right":
1327
+ x, y = 1.0 + abs(offset[0]), -abs(offset[1]) + 1.0
1328
+ else:
1329
+ x, y = offset
1330
+
1331
+ # Default kwargs - use 'axes' as transform string (handled by reproducer)
1332
+ text_kwargs = {
1333
+ "fontsize": fontsize,
1334
+ "fontweight": fontweight,
1335
+ "transform": "axes", # Special string marker for axes coordinates
1336
+ "va": "bottom",
1337
+ "ha": "right" if "right" in loc else "left",
1338
+ }
1339
+ text_kwargs.update(kwargs)
1340
+
1341
+ # Get the underlying matplotlib axes
1342
+ mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
1343
+
1344
+ # For actual rendering, use the real transform
1345
+ render_kwargs = text_kwargs.copy()
1346
+ render_kwargs["transform"] = mpl_ax.transAxes
1347
+
1348
+ # Record the call using recorder's method (handles args/kwargs processing)
1349
+ if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
1350
+ ax._recorder.record_call(
1351
+ ax_position=ax._position,
1352
+ method_name="text",
1353
+ args=(x, y, label),
1354
+ kwargs=text_kwargs, # Contains transform: "axes"
1355
+ )
1356
+
1357
+ # Render directly on matplotlib axes with actual transform
1358
+ return mpl_ax.text(x, y, label, **render_kwargs)