figrecipe 0.5.0__py3-none-any.whl → 0.7.4__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 (189) hide show
  1. figrecipe/__init__.py +220 -819
  2. figrecipe/_api/__init__.py +48 -0
  3. figrecipe/_api/_extract.py +108 -0
  4. figrecipe/_api/_notebook.py +61 -0
  5. figrecipe/_api/_panel.py +46 -0
  6. figrecipe/_api/_save.py +191 -0
  7. figrecipe/_api/_seaborn_proxy.py +34 -0
  8. figrecipe/_api/_style_manager.py +153 -0
  9. figrecipe/_api/_subplots.py +333 -0
  10. figrecipe/_api/_validate.py +82 -0
  11. figrecipe/_dev/__init__.py +29 -0
  12. figrecipe/_dev/_plotters.py +76 -0
  13. figrecipe/_dev/_run_demos.py +56 -0
  14. figrecipe/_dev/demo_plotters/__init__.py +64 -0
  15. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  16. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  17. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  18. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  19. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  20. figrecipe/_dev/demo_plotters/bar_categorical/plot_bar.py +25 -0
  21. figrecipe/_dev/demo_plotters/bar_categorical/plot_barh.py +25 -0
  22. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  23. figrecipe/_dev/demo_plotters/contour_surface/plot_contour.py +30 -0
  24. figrecipe/_dev/demo_plotters/contour_surface/plot_contourf.py +29 -0
  25. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontour.py +28 -0
  26. figrecipe/_dev/demo_plotters/contour_surface/plot_tricontourf.py +28 -0
  27. figrecipe/_dev/demo_plotters/contour_surface/plot_tripcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/contour_surface/plot_triplot.py +25 -0
  29. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  30. figrecipe/_dev/demo_plotters/distribution/plot_boxplot.py +24 -0
  31. figrecipe/_dev/demo_plotters/distribution/plot_ecdf.py +24 -0
  32. figrecipe/_dev/demo_plotters/distribution/plot_hist.py +24 -0
  33. figrecipe/_dev/demo_plotters/distribution/plot_hist2d.py +25 -0
  34. figrecipe/_dev/demo_plotters/distribution/plot_violinplot.py +25 -0
  35. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  36. figrecipe/_dev/demo_plotters/image_matrix/plot_hexbin.py +25 -0
  37. figrecipe/_dev/demo_plotters/image_matrix/plot_imshow.py +23 -0
  38. figrecipe/_dev/demo_plotters/image_matrix/plot_matshow.py +23 -0
  39. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolor.py +29 -0
  40. figrecipe/_dev/demo_plotters/image_matrix/plot_pcolormesh.py +29 -0
  41. figrecipe/_dev/demo_plotters/image_matrix/plot_spy.py +29 -0
  42. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  43. figrecipe/_dev/demo_plotters/line_curve/plot_errorbar.py +28 -0
  44. figrecipe/_dev/demo_plotters/line_curve/plot_fill.py +29 -0
  45. figrecipe/_dev/demo_plotters/line_curve/plot_fill_between.py +30 -0
  46. figrecipe/_dev/demo_plotters/line_curve/plot_fill_betweenx.py +28 -0
  47. figrecipe/_dev/demo_plotters/line_curve/plot_plot.py +28 -0
  48. figrecipe/_dev/demo_plotters/line_curve/plot_stackplot.py +29 -0
  49. figrecipe/_dev/demo_plotters/line_curve/plot_stairs.py +27 -0
  50. figrecipe/_dev/demo_plotters/line_curve/plot_step.py +27 -0
  51. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/scatter_points/plot_scatter.py +24 -0
  53. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/special/plot_eventplot.py +25 -0
  55. figrecipe/_dev/demo_plotters/special/plot_loglog.py +27 -0
  56. figrecipe/_dev/demo_plotters/special/plot_pie.py +27 -0
  57. figrecipe/_dev/demo_plotters/special/plot_semilogx.py +27 -0
  58. figrecipe/_dev/demo_plotters/special/plot_semilogy.py +27 -0
  59. figrecipe/_dev/demo_plotters/special/plot_stem.py +27 -0
  60. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  61. figrecipe/_dev/demo_plotters/spectral_signal/plot_acorr.py +24 -0
  62. figrecipe/_dev/demo_plotters/spectral_signal/plot_angle_spectrum.py +28 -0
  63. figrecipe/_dev/demo_plotters/spectral_signal/plot_cohere.py +29 -0
  64. figrecipe/_dev/demo_plotters/spectral_signal/plot_csd.py +29 -0
  65. figrecipe/_dev/demo_plotters/spectral_signal/plot_magnitude_spectrum.py +28 -0
  66. figrecipe/_dev/demo_plotters/spectral_signal/plot_phase_spectrum.py +28 -0
  67. figrecipe/_dev/demo_plotters/spectral_signal/plot_psd.py +29 -0
  68. figrecipe/_dev/demo_plotters/spectral_signal/plot_specgram.py +30 -0
  69. figrecipe/_dev/demo_plotters/spectral_signal/plot_xcorr.py +25 -0
  70. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  71. figrecipe/_dev/demo_plotters/vector_flow/plot_barbs.py +30 -0
  72. figrecipe/_dev/demo_plotters/vector_flow/plot_quiver.py +30 -0
  73. figrecipe/_dev/demo_plotters/vector_flow/plot_streamplot.py +30 -0
  74. figrecipe/_editor/__init__.py +278 -0
  75. figrecipe/_editor/_bbox/__init__.py +43 -0
  76. figrecipe/_editor/_bbox/_collections.py +177 -0
  77. figrecipe/_editor/_bbox/_elements.py +159 -0
  78. figrecipe/_editor/_bbox/_extract.py +256 -0
  79. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  80. figrecipe/_editor/_bbox/_extract_text.py +342 -0
  81. figrecipe/_editor/_bbox/_lines.py +173 -0
  82. figrecipe/_editor/_bbox/_transforms.py +146 -0
  83. figrecipe/_editor/_flask_app.py +258 -0
  84. figrecipe/_editor/_helpers.py +242 -0
  85. figrecipe/_editor/_hitmap/__init__.py +76 -0
  86. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  87. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  88. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  89. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  90. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  91. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  92. figrecipe/_editor/_hitmap/_colors.py +181 -0
  93. figrecipe/_editor/_hitmap/_detect.py +137 -0
  94. figrecipe/_editor/_hitmap/_restore.py +154 -0
  95. figrecipe/_editor/_hitmap_main.py +182 -0
  96. figrecipe/_editor/_overrides.py +318 -0
  97. figrecipe/_editor/_preferences.py +135 -0
  98. figrecipe/_editor/_render_overrides.py +480 -0
  99. figrecipe/_editor/_renderer.py +199 -0
  100. figrecipe/_editor/_routes_axis.py +453 -0
  101. figrecipe/_editor/_routes_core.py +284 -0
  102. figrecipe/_editor/_routes_element.py +317 -0
  103. figrecipe/_editor/_routes_style.py +223 -0
  104. figrecipe/_editor/_templates/__init__.py +152 -0
  105. figrecipe/_editor/_templates/_html.py +502 -0
  106. figrecipe/_editor/_templates/_scripts/__init__.py +120 -0
  107. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  108. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  109. figrecipe/_editor/_templates/_scripts/_core.py +436 -0
  110. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  111. figrecipe/_editor/_templates/_scripts/_element_editor.py +310 -0
  112. figrecipe/_editor/_templates/_scripts/_files.py +195 -0
  113. figrecipe/_editor/_templates/_scripts/_hitmap.py +509 -0
  114. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  115. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  116. figrecipe/_editor/_templates/_scripts/_legend_drag.py +265 -0
  117. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  118. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  119. figrecipe/_editor/_templates/_scripts/_panel_drag.py +334 -0
  120. figrecipe/_editor/_templates/_scripts/_panel_position.py +279 -0
  121. figrecipe/_editor/_templates/_scripts/_selection.py +237 -0
  122. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  123. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  124. figrecipe/_editor/_templates/_scripts/_zoom.py +179 -0
  125. figrecipe/_editor/_templates/_styles/__init__.py +69 -0
  126. figrecipe/_editor/_templates/_styles/_base.py +64 -0
  127. figrecipe/_editor/_templates/_styles/_buttons.py +206 -0
  128. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  129. figrecipe/_editor/_templates/_styles/_controls.py +265 -0
  130. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  131. figrecipe/_editor/_templates/_styles/_forms.py +126 -0
  132. figrecipe/_editor/_templates/_styles/_hitmap.py +184 -0
  133. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  134. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  135. figrecipe/_editor/_templates/_styles/_modals.py +98 -0
  136. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  137. figrecipe/_editor/_templates/_styles/_preview.py +225 -0
  138. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  139. figrecipe/_params/_DECORATION_METHODS.py +33 -0
  140. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  141. figrecipe/_params/__init__.py +9 -0
  142. figrecipe/_recorder.py +92 -110
  143. figrecipe/_recorder_utils.py +124 -0
  144. figrecipe/_reproducer/__init__.py +18 -0
  145. figrecipe/_reproducer/_core.py +498 -0
  146. figrecipe/_reproducer/_custom_plots.py +279 -0
  147. figrecipe/_reproducer/_seaborn.py +100 -0
  148. figrecipe/_reproducer/_violin.py +186 -0
  149. figrecipe/_seaborn.py +14 -9
  150. figrecipe/_serializer.py +2 -2
  151. figrecipe/_signatures/README.md +68 -0
  152. figrecipe/_signatures/__init__.py +12 -2
  153. figrecipe/_signatures/_kwargs.py +273 -0
  154. figrecipe/_signatures/_loader.py +114 -57
  155. figrecipe/_signatures/_parsing.py +147 -0
  156. figrecipe/_utils/__init__.py +6 -4
  157. figrecipe/_utils/_crop.py +10 -4
  158. figrecipe/_utils/_image_diff.py +37 -33
  159. figrecipe/_utils/_numpy_io.py +0 -1
  160. figrecipe/_utils/_units.py +11 -3
  161. figrecipe/_validator.py +12 -3
  162. figrecipe/_wrappers/_axes.py +193 -170
  163. figrecipe/_wrappers/_axes_helpers.py +136 -0
  164. figrecipe/_wrappers/_axes_plots.py +418 -0
  165. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  166. figrecipe/_wrappers/_figure.py +277 -18
  167. figrecipe/_wrappers/_panel_labels.py +127 -0
  168. figrecipe/_wrappers/_plot_helpers.py +143 -0
  169. figrecipe/_wrappers/_violin_helpers.py +180 -0
  170. figrecipe/plt.py +0 -1
  171. figrecipe/pyplot.py +2 -1
  172. figrecipe/styles/__init__.py +12 -11
  173. figrecipe/styles/_dotdict.py +72 -0
  174. figrecipe/styles/_finalize.py +134 -0
  175. figrecipe/styles/_fonts.py +77 -0
  176. figrecipe/styles/_kwargs_converter.py +178 -0
  177. figrecipe/styles/_plot_styles.py +209 -0
  178. figrecipe/styles/_style_applier.py +60 -202
  179. figrecipe/styles/_style_loader.py +73 -121
  180. figrecipe/styles/_themes.py +151 -0
  181. figrecipe/styles/presets/MATPLOTLIB.yaml +95 -0
  182. figrecipe/styles/presets/SCITEX.yaml +181 -0
  183. figrecipe-0.7.4.dist-info/METADATA +429 -0
  184. figrecipe-0.7.4.dist-info/RECORD +188 -0
  185. figrecipe/_reproducer.py +0 -358
  186. figrecipe-0.5.0.dist-info/METADATA +0 -336
  187. figrecipe-0.5.0.dist-info/RECORD +0 -26
  188. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/WHEEL +0 -0
  189. {figrecipe-0.5.0.dist-info → figrecipe-0.7.4.dist-info}/licenses/LICENSE +0 -0
figrecipe/__init__.py CHANGED
@@ -50,93 +50,35 @@ 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
60
- from ._wrappers import RecordingAxes, RecordingFigure
61
- 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
67
-
68
- # Notebook display format flag (set once per session)
69
- _notebook_format_set = False
70
-
71
-
72
- def _enable_notebook_svg():
73
- """Enable SVG format for Jupyter notebook display.
74
-
75
- This provides crisp vector graphics at any zoom level.
76
- Called automatically when load_style() or subplots() is used.
77
- """
78
- global _notebook_format_set
79
- if _notebook_format_set:
80
- return
81
-
82
- try:
83
- # Method 1: matplotlib_inline (IPython 7.0+, JupyterLab)
84
- from matplotlib_inline.backend_inline import set_matplotlib_formats
85
- set_matplotlib_formats('svg')
86
- _notebook_format_set = True
87
- except (ImportError, Exception):
88
- try:
89
- # Method 2: IPython config (older IPython)
90
- from IPython import get_ipython
91
- ipython = get_ipython()
92
- if ipython is not None and hasattr(ipython, 'kernel'):
93
- # Only run in actual Jupyter kernel, not IPython console
94
- ipython.run_line_magic('config', "InlineBackend.figure_formats = ['svg']")
95
- _notebook_format_set = True
96
- except Exception:
97
- pass # Not in Jupyter environment or method not available
98
-
99
-
100
- def enable_svg():
101
- """Manually enable SVG format for Jupyter notebook display.
102
-
103
- Call this if figures appear pixelated in notebooks.
104
-
105
- Examples
106
- --------
107
- >>> import figrecipe as fr
108
- >>> fr.enable_svg() # Enable SVG rendering
109
- >>> fig, ax = fr.subplots() # Now renders as crisp SVG
110
- """
111
- global _notebook_format_set
112
- _notebook_format_set = False # Force re-application
113
- _enable_notebook_svg()
114
-
59
+ # Notebook utilities
60
+ from ._api._notebook import enable_svg
115
61
 
116
- # Lazy import for seaborn to avoid hard dependency
117
- _sns_recorder = None
118
-
119
-
120
- def _get_sns():
121
- """Get the seaborn recorder (lazy initialization)."""
122
- global _sns_recorder
123
- if _sns_recorder is None:
124
- from ._seaborn import get_seaborn_recorder
125
- _sns_recorder = get_seaborn_recorder()
126
- return _sns_recorder
127
-
128
-
129
- class _SeabornProxy:
130
- """Proxy object for seaborn access via ps.sns."""
131
-
132
- def __getattr__(self, name: str):
133
- return getattr(_get_sns(), name)
134
-
135
-
136
- # Create seaborn proxy
137
- sns = _SeabornProxy()
62
+ # Seaborn proxy
63
+ from ._api._seaborn_proxy import sns
64
+ from ._recorder import CallRecord, FigureRecord
65
+ from ._reproducer import get_recipe_info
66
+ from ._reproducer import reproduce as _reproduce
67
+ from ._serializer import load_recipe
68
+ from ._utils._numpy_io import DataFormat
69
+ from ._utils._units import (
70
+ inch_to_mm,
71
+ mm_to_inch,
72
+ mm_to_pt,
73
+ mm_to_scatter_size,
74
+ normalize_color,
75
+ pt_to_mm,
76
+ )
77
+ from ._validator import ValidationResult
78
+ from ._wrappers import RecordingAxes, RecordingFigure
79
+ from .styles._style_applier import check_font, list_available_fonts
138
80
 
139
- __version__ = "0.4.0"
81
+ __version__ = "0.7.2"
140
82
  __all__ = [
141
83
  # Main API
142
84
  "subplots",
@@ -146,6 +88,8 @@ __all__ = [
146
88
  "load",
147
89
  "extract_data",
148
90
  "validate",
91
+ # GUI Editor
92
+ "edit",
149
93
  # Style system
150
94
  "load_style",
151
95
  "unload_style",
@@ -174,145 +118,21 @@ __all__ = [
174
118
  "ValidationResult",
175
119
  # Image utilities
176
120
  "crop",
121
+ # Panel labels
122
+ "panel_label",
177
123
  # Version
178
124
  "__version__",
179
125
  ]
180
126
 
181
127
 
182
- # Lazy imports for style system
183
- _style_cache = None
184
-
185
-
186
- def load_style(style="SCITEX", dark=False):
187
- """Load style configuration and apply it globally.
188
-
189
- After calling this function, subsequent `subplots()` calls will
190
- automatically use the loaded style (fonts, colors, theme, etc.).
191
-
192
- Parameters
193
- ----------
194
- style : str, Path, bool, or None
195
- One of:
196
- - "SCITEX" / "FIGRECIPE": Scientific publication style (default)
197
- - "MATPLOTLIB": Vanilla matplotlib defaults
198
- - Path to custom YAML file: "/path/to/my_style.yaml"
199
- - None or False: Unload style (reset to matplotlib defaults)
200
- dark : bool, optional
201
- If True, apply dark theme transformation (default: False).
202
- Equivalent to appending "_DARK" to preset name.
203
-
204
- Returns
205
- -------
206
- DotDict or None
207
- Style configuration with dot-notation access.
208
- Returns None if style is unloaded.
209
-
210
- Examples
211
- --------
212
- >>> import figrecipe as fr
213
-
214
- >>> # Load scientific style (default)
215
- >>> fr.load_style()
216
- >>> fr.load_style("SCITEX") # explicit
217
-
218
- >>> # Load dark theme
219
- >>> fr.load_style("SCITEX_DARK")
220
- >>> fr.load_style("SCITEX", dark=True) # equivalent
221
-
222
- >>> # Reset to vanilla matplotlib
223
- >>> fr.load_style(None) # unload
224
- >>> fr.load_style(False) # unload
225
- >>> fr.load_style("MATPLOTLIB") # explicit vanilla
226
-
227
- >>> # Access style values
228
- >>> style = fr.load_style("SCITEX")
229
- >>> style.axes.width_mm
230
- 40
231
- """
232
- from .styles import load_style as _load_style
233
- return _load_style(style, dark=dark)
234
-
235
-
236
- def unload_style():
237
- """Unload the current style and reset to matplotlib defaults.
238
-
239
- After calling this, subsequent `subplots()` calls will use vanilla
240
- matplotlib behavior without FigRecipe styling.
241
-
242
- Examples
243
- --------
244
- >>> import figrecipe as fr
245
- >>> fr.load_style("SCITEX") # Apply scientific style
246
- >>> fig, ax = fr.subplots() # Styled
247
- >>> fr.unload_style() # Reset to matplotlib defaults
248
- >>> fig, ax = fr.subplots() # Vanilla matplotlib
249
- """
250
- from .styles import unload_style as _unload_style
251
- _unload_style()
252
-
253
-
254
- def list_presets():
255
- """List available style presets.
256
-
257
- Returns
258
- -------
259
- list of str
260
- Names of available presets.
261
-
262
- Examples
263
- --------
264
- >>> import figrecipe as ps
265
- >>> ps.list_presets()
266
- ['MINIMAL', 'PRESENTATION', 'SCIENTIFIC']
267
- """
268
- from .styles import list_presets as _list_presets
269
- return _list_presets()
270
-
271
-
272
- def apply_style(ax, style=None):
273
- """Apply mm-based styling to an axes.
274
-
275
- Parameters
276
- ----------
277
- ax : matplotlib.axes.Axes
278
- Target axes to apply styling to.
279
- style : dict or DotDict, optional
280
- Style configuration. If None, uses default FIGRECIPE_STYLE.
281
-
282
- Returns
283
- -------
284
- float
285
- Trace line width in points.
286
-
287
- Examples
288
- --------
289
- >>> import figrecipe as ps
290
- >>> import matplotlib.pyplot as plt
291
- >>> fig, ax = plt.subplots()
292
- >>> trace_lw = ps.apply_style(ax)
293
- >>> ax.plot(x, y, lw=trace_lw)
294
- """
295
- from .styles import apply_style_mm, get_style, to_subplots_kwargs
296
- if style is None:
297
- style = to_subplots_kwargs(get_style())
298
- elif hasattr(style, 'to_subplots_kwargs'):
299
- style = style.to_subplots_kwargs()
300
- return apply_style_mm(ax, style)
301
-
302
-
303
- class _StyleProxy:
304
- """Proxy object for lazy style loading."""
305
-
306
- def __getattr__(self, name):
307
- from .styles import STYLE
308
- return getattr(STYLE, name)
309
-
310
- def to_subplots_kwargs(self):
311
- from .styles import to_subplots_kwargs
312
- return to_subplots_kwargs()
313
-
314
-
315
- STYLE = _StyleProxy()
128
+ # Style management
129
+ from ._api._style_manager import (
130
+ STYLE,
131
+ apply_style,
132
+ list_presets,
133
+ load_style,
134
+ unload_style,
135
+ )
316
136
 
317
137
 
318
138
  def subplots(
@@ -330,8 +150,10 @@ def subplots(
330
150
  # Style parameters
331
151
  style: Optional[Dict[str, Any]] = None,
332
152
  apply_style_mm: bool = True,
153
+ # Panel labels (None = use style default, True/False = explicit)
154
+ panel_labels: Optional[bool] = None,
333
155
  **kwargs,
334
- ) -> Tuple[RecordingFigure, Union[RecordingAxes, List[RecordingAxes]]]:
156
+ ) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
335
157
  """Create a figure with recording-enabled axes.
336
158
 
337
159
  This is a drop-in replacement for plt.subplots() that wraps the
@@ -341,251 +163,50 @@ def subplots(
341
163
 
342
164
  Parameters
343
165
  ----------
344
- nrows : int
345
- Number of rows of subplots.
346
- ncols : int
347
- Number of columns of subplots.
348
-
349
- MM-Control Parameters
350
- ---------------------
351
- axes_width_mm : float, optional
352
- Axes width in mm. If provided, overrides figsize.
353
- axes_height_mm : float, optional
354
- Axes height in mm.
355
- margin_left_mm : float, optional
356
- Left margin in mm (default: 15).
357
- margin_right_mm : float, optional
358
- Right margin in mm (default: 5).
359
- margin_bottom_mm : float, optional
360
- Bottom margin in mm (default: 12).
361
- margin_top_mm : float, optional
362
- Top margin in mm (default: 8).
363
- space_w_mm : float, optional
364
- Horizontal spacing between axes in mm (default: 8).
365
- space_h_mm : float, optional
366
- Vertical spacing between axes in mm (default: 10).
367
-
368
- Style Parameters
369
- ----------------
166
+ nrows, ncols : int
167
+ Number of rows and columns of subplots.
168
+ axes_width_mm, axes_height_mm : float, optional
169
+ Axes dimensions in mm.
170
+ margin_left_mm, margin_right_mm : float, optional
171
+ Left/right margins in mm.
172
+ margin_bottom_mm, margin_top_mm : float, optional
173
+ Bottom/top margins in mm.
174
+ space_w_mm, space_h_mm : float, optional
175
+ Horizontal/vertical spacing between axes in mm.
370
176
  style : dict, optional
371
- Style configuration dictionary or result of load_style().
177
+ Style configuration dictionary.
372
178
  apply_style_mm : bool
373
- If True (default), apply loaded style to axes after creation.
374
- Set to False to disable automatic style application.
375
-
179
+ If True (default), apply loaded style to axes.
180
+ panel_labels : bool or None
181
+ If True, add panel labels (A, B, C, ...).
376
182
  **kwargs
377
- Additional arguments passed to plt.subplots() (e.g., figsize, dpi).
183
+ Additional arguments passed to plt.subplots().
378
184
 
379
185
  Returns
380
186
  -------
381
187
  fig : RecordingFigure
382
188
  Wrapped figure object.
383
- axes : RecordingAxes or list of RecordingAxes
384
- Wrapped axes (single for 1x1, list otherwise).
385
-
386
- Examples
387
- --------
388
- Basic usage:
389
-
390
- >>> import figrecipe as ps
391
- >>> fig, ax = ps.subplots()
392
- >>> ax.plot([1, 2, 3], [4, 5, 6], color='blue')
393
- >>> ps.save(fig, 'simple.yaml')
394
-
395
- MM-based layout:
396
-
397
- >>> fig, ax = ps.subplots(
398
- ... axes_width_mm=40,
399
- ... axes_height_mm=28,
400
- ... margin_left_mm=15,
401
- ... margin_bottom_mm=12,
402
- ... )
403
-
404
- With style (automatically applied):
405
-
406
- >>> ps.load_style("FIGRECIPE_DARK") # Load dark theme
407
- >>> fig, ax = ps.subplots() # Style applied automatically
189
+ axes : RecordingAxes or ndarray
190
+ Wrapped axes.
408
191
  """
409
- # Get global style for default values (if loaded)
410
- from .styles._style_loader import _STYLE_CACHE
411
- global_style = _STYLE_CACHE
412
-
413
- # Helper to get value with priority: explicit > global style > hardcoded default
414
- def _get_mm(explicit, style_path, default):
415
- if explicit is not None:
416
- return explicit
417
- if global_style is not None:
418
- try:
419
- val = global_style
420
- for key in style_path:
421
- val = val.get(key) if isinstance(val, dict) else getattr(val, key, None)
422
- if val is None:
423
- break
424
- if val is not None:
425
- return val
426
- except (KeyError, AttributeError):
427
- pass
428
- return default
429
-
430
- # 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
- ])
441
-
442
- # Also use mm layout if global style has mm values
443
- has_style_mm = False
444
- if global_style is not None:
445
- try:
446
- 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
449
- )
450
- except (KeyError, AttributeError):
451
- pass
452
-
453
- use_mm_layout = has_explicit_mm or has_style_mm
454
-
455
- if use_mm_layout and 'figsize' not in kwargs:
456
- # 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)
465
-
466
- # Calculate total figure size
467
- total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
468
- total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
469
-
470
- # Convert to inches and set figsize
471
- kwargs['figsize'] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
472
-
473
- # Store mm metadata for recording (will be extracted by create_recording_subplots)
474
- 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,
483
- }
484
- else:
485
- mm_layout = None
486
-
487
- # Apply DPI from global style if not explicitly provided
488
- if 'dpi' not in kwargs and global_style is not None:
489
- # Try figure.dpi first, then output.dpi
490
- style_dpi = None
491
- try:
492
- if hasattr(global_style, 'figure') and hasattr(global_style.figure, 'dpi'):
493
- style_dpi = global_style.figure.dpi
494
- elif hasattr(global_style, 'output') and hasattr(global_style.output, 'dpi'):
495
- style_dpi = global_style.output.dpi
496
- except (KeyError, AttributeError):
497
- pass
498
- if style_dpi is not None:
499
- kwargs['dpi'] = style_dpi
500
-
501
- # Handle style parameter
502
- if style is not None:
503
- if hasattr(style, 'to_subplots_kwargs'):
504
- # Merge style kwargs (style values are overridden by explicit params)
505
- style_kwargs = style.to_subplots_kwargs()
506
- for key, value in style_kwargs.items():
507
- if key not in kwargs:
508
- kwargs[key] = value
509
-
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
514
-
515
- # Create the recording subplots
516
- fig, axes = create_recording_subplots(nrows, ncols, **kwargs)
517
-
518
- # Record constrained_layout setting for reproduction
519
- fig.record.constrained_layout = kwargs.get('constrained_layout', False)
520
-
521
- # Store mm_layout metadata on figure for serialization
522
- if mm_layout is not None:
523
- fig._mm_layout = mm_layout
524
-
525
- # Apply subplots_adjust to position axes correctly
526
- total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
527
- total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
528
-
529
- # Calculate relative positions (0-1 range)
530
- left = ml / total_width_mm
531
- right = 1 - (mr / total_width_mm)
532
- bottom = mb / total_height_mm
533
- top = 1 - (mt / total_height_mm)
534
-
535
- # Calculate spacing as fraction of figure size
536
- wspace = sw / aw if ncols > 1 else 0
537
- hspace = sh / ah if nrows > 1 else 0
538
-
539
- fig.fig.subplots_adjust(
540
- left=left,
541
- right=right,
542
- bottom=bottom,
543
- top=top,
544
- wspace=wspace,
545
- hspace=hspace,
546
- )
547
-
548
- # Record layout in figure record for reproduction
549
- fig.record.layout = {
550
- 'left': left,
551
- 'right': right,
552
- 'bottom': bottom,
553
- 'top': top,
554
- 'wspace': wspace,
555
- 'hspace': hspace,
556
- }
557
-
558
- # Apply styling if requested and a style is actually loaded
559
- style_dict = None
560
- should_apply_style = False
561
-
562
- if style is not None:
563
- # Explicit style parameter provided
564
- should_apply_style = True
565
- style_dict = style.to_subplots_kwargs() if hasattr(style, 'to_subplots_kwargs') else style
566
- elif apply_style_mm and global_style is not None:
567
- # Use global style if loaded and has meaningful values (not MATPLOTLIB)
568
- from .styles import to_subplots_kwargs
569
- style_dict = to_subplots_kwargs(global_style)
570
- # 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:
572
- should_apply_style = True
573
-
574
- if should_apply_style and style_dict:
575
- from .styles import apply_style_mm as _apply_style
576
- if nrows == 1 and ncols == 1:
577
- _apply_style(axes._ax, style_dict)
578
- else:
579
- # Handle 2D array of axes
580
- import numpy as np
581
- axes_array = np.array(axes)
582
- for ax in axes_array.flat:
583
- _apply_style(ax._ax if hasattr(ax, '_ax') else ax, style_dict)
584
-
585
- # Record style in figure record for reproduction
586
- fig.record.style = style_dict
587
-
588
- return fig, axes
192
+ from ._api._subplots import create_subplots
193
+
194
+ return create_subplots(
195
+ nrows=nrows,
196
+ ncols=ncols,
197
+ axes_width_mm=axes_width_mm,
198
+ axes_height_mm=axes_height_mm,
199
+ margin_left_mm=margin_left_mm,
200
+ margin_right_mm=margin_right_mm,
201
+ margin_bottom_mm=margin_bottom_mm,
202
+ margin_top_mm=margin_top_mm,
203
+ space_w_mm=space_w_mm,
204
+ space_h_mm=space_h_mm,
205
+ style=style,
206
+ apply_style_mm=apply_style_mm,
207
+ panel_labels=panel_labels,
208
+ **kwargs,
209
+ )
589
210
 
590
211
 
591
212
  def save(
@@ -609,162 +230,45 @@ def save(
609
230
  Parameters
610
231
  ----------
611
232
  fig : RecordingFigure or Figure
612
- The figure to save. Must be a RecordingFigure for recipe saving.
233
+ The figure to save.
613
234
  path : str or Path
614
- Output path. Can be:
615
- - Image path (.png, .pdf, .svg, .jpg): Saves image + YAML recipe
616
- - YAML path (.yaml, .yml): Saves recipe + image
235
+ Output path (.png, .pdf, .svg, .yaml, etc.)
617
236
  include_data : bool
618
237
  If True, save large arrays to separate files.
619
238
  data_format : str
620
- Format for data files: 'csv' (default), 'npz', or 'inline'.
621
- - 'csv': Human-readable CSV files with dtype header
622
- - 'npz': Compressed numpy binary format (efficient)
623
- - 'inline': Store all data directly in YAML
239
+ Format for data files: 'csv', 'npz', or 'inline'.
624
240
  validate : bool
625
- If True (default), validate reproducibility after saving by
626
- reproducing the figure and comparing it to the original.
241
+ If True (default), validate reproducibility after saving.
627
242
  validate_mse_threshold : float
628
243
  Maximum acceptable MSE for validation (default: 100).
629
244
  validate_error_level : str
630
- How to handle validation failures: 'error' (default), 'warning', or 'debug'.
631
- - 'error': Raise ValueError on failure
632
- - 'warning': Emit UserWarning on failure
633
- - 'debug': Silent (check result.valid manually)
245
+ How to handle validation failures: 'error', 'warning', or 'debug'.
634
246
  verbose : bool
635
- If True (default), print save status. Set False for CI/scripts.
247
+ If True (default), print save status.
636
248
  dpi : int, optional
637
- DPI for image output. Uses style DPI or 300 if not specified.
249
+ DPI for image output.
638
250
  image_format : str, optional
639
- Image format when path is YAML ('png', 'pdf', 'svg').
640
- Uses style's output.format or 'png' if not specified.
251
+ Image format when path is YAML.
641
252
 
642
253
  Returns
643
254
  -------
644
255
  tuple
645
- (image_path, yaml_path, ValidationResult or None) tuple.
646
- ValidationResult is None when validate=False.
647
-
648
- Examples
649
- --------
650
- >>> import figrecipe as fr
651
- >>> fig, ax = fr.subplots()
652
- >>> ax.plot(x, y, color='red', id='my_data')
653
- >>>
654
- >>> # Save as PNG (also creates experiment.yaml)
655
- >>> img_path, yaml_path, result = fr.save(fig, 'experiment.png')
656
- >>>
657
- >>> # Save as YAML (also creates experiment.png)
658
- >>> img_path, yaml_path, result = fr.save(fig, 'experiment.yaml')
659
- >>>
660
- >>> # Save as PDF with custom DPI
661
- >>> fr.save(fig, 'experiment.pdf', dpi=600)
662
-
663
- Notes
664
- -----
665
- The recipe file contains:
666
- - Figure metadata (size, DPI, matplotlib version)
667
- - All plotting calls with their arguments
668
- - References to data files for large arrays
256
+ (image_path, yaml_path, ValidationResult or None)
669
257
  """
670
- path = Path(path)
671
-
672
- if not isinstance(fig, RecordingFigure):
673
- raise TypeError(
674
- "Expected RecordingFigure. Use fr.subplots() to create "
675
- "a recording-enabled figure."
676
- )
677
-
678
- # Determine image and YAML paths based on extension
679
- IMAGE_EXTENSIONS = {'.png', '.pdf', '.svg', '.jpg', '.jpeg', '.eps', '.tiff', '.tif'}
680
- YAML_EXTENSIONS = {'.yaml', '.yml'}
681
-
682
- suffix_lower = path.suffix.lower()
683
-
684
- if suffix_lower in IMAGE_EXTENSIONS:
685
- # User provided image path
686
- image_path = path
687
- yaml_path = path.with_suffix('.yaml')
688
- img_format = suffix_lower[1:] # Remove leading dot
689
- elif suffix_lower in YAML_EXTENSIONS:
690
- # User provided YAML path
691
- yaml_path = path
692
- # Determine image format from style or default
693
- if image_format is not None:
694
- img_format = image_format.lower().lstrip('.')
695
- else:
696
- # Check global style for preferred format
697
- from .styles._style_loader import _STYLE_CACHE
698
- if _STYLE_CACHE is not None:
699
- try:
700
- img_format = _STYLE_CACHE.output.format.lower()
701
- except (KeyError, AttributeError):
702
- img_format = 'png'
703
- else:
704
- img_format = 'png'
705
- image_path = path.with_suffix(f'.{img_format}')
706
- else:
707
- # Unknown extension - treat as base name, add both extensions
708
- yaml_path = path.with_suffix('.yaml')
709
- if image_format is not None:
710
- img_format = image_format.lower().lstrip('.')
711
- else:
712
- from .styles._style_loader import _STYLE_CACHE
713
- if _STYLE_CACHE is not None:
714
- try:
715
- img_format = _STYLE_CACHE.output.format.lower()
716
- except (KeyError, AttributeError):
717
- img_format = 'png'
718
- else:
719
- img_format = 'png'
720
- image_path = path.with_suffix(f'.{img_format}')
721
-
722
- # Get DPI from style if not specified
723
- if dpi is None:
724
- from .styles._style_loader import _STYLE_CACHE
725
- if _STYLE_CACHE is not None:
726
- try:
727
- dpi = _STYLE_CACHE.output.dpi
728
- except (KeyError, AttributeError):
729
- dpi = 300
730
- else:
731
- dpi = 300
732
-
733
- # Get transparency setting from style
734
- transparent = False
735
- from .styles._style_loader import _STYLE_CACHE
736
- if _STYLE_CACHE is not None:
737
- try:
738
- transparent = _STYLE_CACHE.output.transparent
739
- except (KeyError, AttributeError):
740
- pass
741
-
742
- # Save the image
743
- fig.fig.savefig(image_path, dpi=dpi, bbox_inches='tight', transparent=transparent)
744
-
745
- # Save the recipe
746
- saved_yaml = fig.save_recipe(yaml_path, include_data=include_data, data_format=data_format)
747
-
748
- # Validate if requested
749
- if validate:
750
- from ._validator import validate_on_save
751
- result = validate_on_save(fig, saved_yaml, mse_threshold=validate_mse_threshold)
752
- status = "PASSED" if result.valid else "FAILED"
753
- if verbose:
754
- print(f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})")
755
- if not result.valid:
756
- msg = f"Reproducibility validation failed (MSE={result.mse:.1f}): {result.message}"
757
- if validate_error_level == "error":
758
- raise ValueError(msg)
759
- elif validate_error_level == "warning":
760
- import warnings
761
- warnings.warn(msg, UserWarning)
762
- # "debug" level: silent, just return the result
763
- return image_path, yaml_path, result
764
-
765
- if verbose:
766
- print(f"Saved: {image_path} + {yaml_path}")
767
- return image_path, yaml_path, None
258
+ from ._api._save import save_figure
259
+
260
+ return save_figure(
261
+ fig=fig,
262
+ path=path,
263
+ include_data=include_data,
264
+ data_format=data_format,
265
+ validate=validate,
266
+ validate_mse_threshold=validate_mse_threshold,
267
+ validate_error_level=validate_error_level,
268
+ verbose=verbose,
269
+ dpi=dpi,
270
+ image_format=image_format,
271
+ )
768
272
 
769
273
 
770
274
  def reproduce(
@@ -781,7 +285,7 @@ def reproduce(
781
285
  calls : list of str, optional
782
286
  If provided, only reproduce these specific call IDs.
783
287
  skip_decorations : bool
784
- If True, skip decoration calls (labels, legends, etc.).
288
+ If True, skip decoration calls.
785
289
 
786
290
  Returns
787
291
  -------
@@ -789,191 +293,50 @@ def reproduce(
789
293
  Reproduced figure.
790
294
  axes : Axes or list of Axes
791
295
  Reproduced axes.
792
-
793
- Examples
794
- --------
795
- >>> import figrecipe as ps
796
- >>> fig, ax = ps.reproduce('experiment.yaml')
797
- >>> plt.show()
798
-
799
- >>> # Reproduce only specific plots
800
- >>> fig, ax = ps.reproduce('experiment.yaml', calls=['scatter_001'])
801
296
  """
802
297
  return _reproduce(path, calls=calls, skip_decorations=skip_decorations)
803
298
 
804
299
 
805
300
  def info(path: Union[str, Path]) -> Dict[str, Any]:
806
- """Get information about a recipe without reproducing.
807
-
808
- Parameters
809
- ----------
810
- path : str or Path
811
- Path to .yaml recipe file.
812
-
813
- Returns
814
- -------
815
- dict
816
- Recipe information including figure ID, creation time,
817
- matplotlib version, size, and list of calls.
818
-
819
- Examples
820
- --------
821
- >>> import figrecipe as ps
822
- >>> recipe_info = ps.info('experiment.yaml')
823
- >>> print(f"Created: {recipe_info['created']}")
824
- >>> print(f"Calls: {len(recipe_info['calls'])}")
825
- """
301
+ """Get information about a recipe without reproducing."""
826
302
  return get_recipe_info(path)
827
303
 
828
304
 
829
305
  def load(path: Union[str, Path]) -> FigureRecord:
830
- """Load a recipe as a FigureRecord object.
831
-
832
- Parameters
833
- ----------
834
- path : str or Path
835
- Path to .yaml recipe file.
836
-
837
- Returns
838
- -------
839
- FigureRecord
840
- The loaded figure record.
841
-
842
- Examples
843
- --------
844
- >>> import figrecipe as ps
845
- >>> record = ps.load('experiment.yaml')
846
- >>> # Modify the record
847
- >>> record.axes['ax_0_0'].calls[0].kwargs['color'] = 'blue'
848
- >>> # Reproduce with modifications
849
- >>> fig, ax = ps.reproduce_from_record(record)
850
- """
306
+ """Load a recipe as a FigureRecord object."""
851
307
  return load_recipe(path)
852
308
 
853
309
 
854
310
  def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
855
311
  """Extract data arrays from a saved recipe.
856
312
 
857
- This function allows you to import/recover the data that was
858
- plotted in a figure from its recipe file.
859
-
860
- Parameters
861
- ----------
862
- path : str or Path
863
- Path to .yaml recipe file.
864
-
865
313
  Returns
866
314
  -------
867
315
  dict
868
316
  Nested dictionary: {call_id: {'x': array, 'y': array, ...}}
869
- Each call's data is stored under its ID with keys for each argument.
870
-
871
- Examples
872
- --------
873
- >>> import figrecipe as ps
874
- >>> import numpy as np
875
- >>>
876
- >>> # Create and save a figure
877
- >>> x = np.linspace(0, 10, 100)
878
- >>> y = np.sin(x)
879
- >>> fig, ax = ps.subplots()
880
- >>> ax.plot(x, y, id='sine_wave')
881
- >>> ps.save(fig, 'figure.yaml')
882
- >>>
883
- >>> # Later, extract the data
884
- >>> data = ps.extract_data('figure.yaml')
885
- >>> x_recovered = data['sine_wave']['x']
886
- >>> y_recovered = data['sine_wave']['y']
887
- >>> np.allclose(x, x_recovered)
888
- True
889
-
890
- Notes
891
- -----
892
- - Data is extracted from all plot calls (plot, scatter, bar, etc.)
893
- - For plot() calls: 'x' and 'y' contain the coordinates
894
- - For scatter(): 'x', 'y', and optionally 'c' (colors), 's' (sizes)
895
- - For bar(): 'x' (categories) and 'height' (values)
896
- - For hist(): 'x' (data array)
897
317
  """
898
- import numpy as np
318
+ from ._api._extract import DECORATION_FUNCS, extract_call_data
899
319
 
900
320
  record = load_recipe(path)
901
321
  result = {}
902
322
 
903
- # Decoration functions to skip
904
- decoration_funcs = {
905
- "set_xlabel", "set_ylabel", "set_title", "set_xlim", "set_ylim",
906
- "legend", "grid", "axhline", "axvline", "text", "annotate",
907
- }
908
-
909
323
  for ax_key, ax_record in record.axes.items():
910
324
  for call in ax_record.calls:
911
- # Skip decoration calls
912
- if call.function in decoration_funcs:
325
+ if call.function in DECORATION_FUNCS:
913
326
  continue
914
-
915
- call_data = {}
916
-
917
- def to_array(data):
918
- """Convert data to numpy array, handling YAML types."""
919
- # Handle dict with 'data' key (serialized array format)
920
- if isinstance(data, dict) or (hasattr(data, "keys") and "data" in data):
921
- return np.array(data["data"])
922
- if hasattr(data, "tolist"): # Already array-like
923
- return np.array(data)
924
- return np.array(list(data) if hasattr(data, "__iter__") and not isinstance(data, str) else data)
925
-
926
- # Extract positional arguments based on function type
927
- if call.function in ("plot", "scatter", "fill_between"):
928
- if len(call.args) >= 1:
929
- call_data["x"] = to_array(call.args[0])
930
- if len(call.args) >= 2:
931
- call_data["y"] = to_array(call.args[1])
932
-
933
- elif call.function == "bar":
934
- if len(call.args) >= 1:
935
- call_data["x"] = to_array(call.args[0])
936
- if len(call.args) >= 2:
937
- call_data["height"] = to_array(call.args[1])
938
-
939
- elif call.function == "hist":
940
- if len(call.args) >= 1:
941
- call_data["x"] = to_array(call.args[0])
942
-
943
- elif call.function == "errorbar":
944
- if len(call.args) >= 1:
945
- call_data["x"] = to_array(call.args[0])
946
- if len(call.args) >= 2:
947
- call_data["y"] = to_array(call.args[1])
948
-
949
- # Extract relevant kwargs
950
- for key in ("c", "s", "yerr", "xerr", "weights", "bins"):
951
- if key in call.kwargs:
952
- val = call.kwargs[key]
953
- if isinstance(val, (list, tuple)) or hasattr(val, "__iter__") and not isinstance(val, str):
954
- call_data[key] = to_array(val)
955
- else:
956
- call_data[key] = val
957
-
327
+ call_data = extract_call_data(call)
958
328
  if call_data:
959
329
  result[call.id] = call_data
960
330
 
961
331
  return result
962
332
 
963
333
 
964
- # Import ValidationResult for type hints
965
- from ._validator import ValidationResult, validate_recipe
966
-
967
-
968
334
  def validate(
969
335
  path: Union[str, Path],
970
336
  mse_threshold: float = 100.0,
971
337
  ) -> ValidationResult:
972
338
  """Validate that a saved recipe can reproduce its original figure.
973
339
 
974
- This is a standalone validation function for existing recipes.
975
- For validation during save, use `ps.save(..., validate=True)`.
976
-
977
340
  Parameters
978
341
  ----------
979
342
  path : str or Path
@@ -984,87 +347,31 @@ def validate(
984
347
  Returns
985
348
  -------
986
349
  ValidationResult
987
- Detailed comparison results including MSE, dimensions, etc.
988
-
989
- Examples
990
- --------
991
- >>> import figrecipe as ps
992
- >>> result = ps.validate('experiment.yaml')
993
- >>> print(result.summary())
994
- >>> if result.valid:
995
- ... print("Recipe is reproducible!")
996
-
997
- Notes
998
- -----
999
- This function reproduces the figure from the recipe and compares
1000
- the result to re-rendering the recipe. It cannot compare to the
1001
- original figure unless you use `ps.save(..., validate=True)` which
1002
- performs validation before closing the original figure.
350
+ Detailed comparison results.
1003
351
  """
1004
- # For standalone validation, we reproduce twice and compare
1005
- # (This validates the recipe is self-consistent)
1006
- from ._reproducer import reproduce
1007
- from ._utils._image_diff import compare_images
1008
- import tempfile
1009
- import numpy as np
1010
-
1011
- path = Path(path)
1012
-
1013
- with tempfile.TemporaryDirectory() as tmpdir:
1014
- tmpdir = Path(tmpdir)
1015
-
1016
- # Reproduce twice
1017
- fig1, _ = reproduce(path)
1018
- img1_path = tmpdir / "render1.png"
1019
- fig1.savefig(img1_path, dpi=150)
1020
-
1021
- fig2, _ = reproduce(path)
1022
- img2_path = tmpdir / "render2.png"
1023
- fig2.savefig(img2_path, dpi=150)
1024
-
1025
- # Compare
1026
- diff = compare_images(img1_path, img2_path)
1027
-
1028
- mse = diff["mse"]
1029
- if np.isnan(mse):
1030
- valid = False
1031
- message = f"Image dimensions differ: {diff['size1']} vs {diff['size2']}"
1032
- elif mse > mse_threshold:
1033
- valid = False
1034
- message = f"MSE ({mse:.2f}) exceeds threshold ({mse_threshold})"
1035
- else:
1036
- valid = True
1037
- message = "Recipe produces consistent output"
1038
-
1039
- return ValidationResult(
1040
- valid=valid,
1041
- mse=mse if not np.isnan(mse) else float("inf"),
1042
- psnr=diff["psnr"],
1043
- max_diff=diff["max_diff"] if not np.isnan(diff["max_diff"]) else float("inf"),
1044
- size_original=diff["size1"],
1045
- size_reproduced=diff["size2"],
1046
- same_size=diff["same_size"],
1047
- file_size_diff=diff["file_size2"] - diff["file_size1"],
1048
- message=message,
1049
- )
352
+ from ._api._validate import validate_recipe
1050
353
 
354
+ return validate_recipe(path, mse_threshold)
1051
355
 
1052
- def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=False, verbose=False):
1053
- """Crop a figure image to its content area with a specified margin.
1054
356
 
1055
- Automatically detects background color (from corners) and crops to
1056
- content, leaving only the specified margin around it.
357
+ def crop(
358
+ input_path,
359
+ output_path=None,
360
+ margin_mm=1.0,
361
+ margin_px=None,
362
+ overwrite=False,
363
+ verbose=False,
364
+ ):
365
+ """Crop a figure image to its content area with a specified margin.
1057
366
 
1058
367
  Parameters
1059
368
  ----------
1060
369
  input_path : str or Path
1061
- Path to the input image (PNG, JPEG, etc.)
370
+ Path to the input image.
1062
371
  output_path : str or Path, optional
1063
- Path to save the cropped image. If None and overwrite=True,
1064
- overwrites the input. If None and overwrite=False, adds '_cropped' suffix.
372
+ Path to save the cropped image.
1065
373
  margin_mm : float, optional
1066
- Margin in millimeters to keep around content (default: 1.0mm).
1067
- Converted to pixels using image DPI (or 300 DPI if not available).
374
+ Margin in millimeters (default: 1.0mm).
1068
375
  margin_px : int, optional
1069
376
  Margin in pixels (overrides margin_mm if provided).
1070
377
  overwrite : bool, optional
@@ -1076,15 +383,109 @@ def crop(input_path, output_path=None, margin_mm=1.0, margin_px=None, overwrite=
1076
383
  -------
1077
384
  Path
1078
385
  Path to the saved cropped image.
1079
-
1080
- Examples
1081
- --------
1082
- >>> import figrecipe as fr
1083
- >>> fig, ax = fr.subplots(axes_width_mm=60, axes_height_mm=40)
1084
- >>> ax.plot([1, 2, 3], [1, 2, 3], id='line')
1085
- >>> fig.savefig("figure.png", dpi=300)
1086
- >>> fr.crop("figure.png", overwrite=True) # 1mm margin
1087
- >>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
1088
386
  """
1089
387
  from ._utils._crop import crop as _crop
388
+
1090
389
  return _crop(input_path, output_path, margin_mm, margin_px, overwrite, verbose)
390
+
391
+
392
+ def edit(
393
+ source,
394
+ style=None,
395
+ port: int = 5050,
396
+ open_browser: bool = True,
397
+ hot_reload: bool = False,
398
+ ):
399
+ """Launch interactive GUI editor for figure styling.
400
+
401
+ Parameters
402
+ ----------
403
+ source : RecordingFigure, str, or Path
404
+ Either a live RecordingFigure object or path to a .yaml recipe file.
405
+ style : str or dict, optional
406
+ Style preset name or style dict.
407
+ port : int, optional
408
+ Flask server port (default: 5050).
409
+ open_browser : bool, optional
410
+ Whether to open browser automatically (default: True).
411
+ hot_reload : bool, optional
412
+ Enable hot reload (default: False).
413
+
414
+ Returns
415
+ -------
416
+ dict
417
+ Final style overrides after editing session.
418
+ """
419
+ from ._editor import edit as _edit
420
+
421
+ return _edit(
422
+ source, style=style, port=port, open_browser=open_browser, hot_reload=hot_reload
423
+ )
424
+
425
+
426
+ def panel_label(
427
+ ax,
428
+ label: str,
429
+ loc: str = "upper left",
430
+ fontsize: Optional[float] = None,
431
+ fontweight: str = "bold",
432
+ offset: Tuple[float, float] = (-0.1, 1.05),
433
+ **kwargs,
434
+ ):
435
+ """Add a panel label (A, B, C, ...) to an axes.
436
+
437
+ Parameters
438
+ ----------
439
+ ax : Axes or RecordingAxes
440
+ The axes to label.
441
+ label : str
442
+ The label text (e.g., 'A', 'B', 'a)', '(1)').
443
+ loc : str, optional
444
+ Label location: 'upper left', 'upper right', etc.
445
+ fontsize : float, optional
446
+ Font size in points.
447
+ fontweight : str, optional
448
+ Font weight (default: 'bold').
449
+ offset : tuple of float, optional
450
+ (x, y) offset in axes coordinates.
451
+ **kwargs
452
+ Additional arguments passed to ax.text().
453
+
454
+ Returns
455
+ -------
456
+ Text
457
+ The matplotlib Text object.
458
+ """
459
+ import matplotlib.pyplot as mpl_plt
460
+
461
+ from ._api._panel import calculate_panel_position, get_panel_label_fontsize
462
+
463
+ fontsize = get_panel_label_fontsize(fontsize)
464
+ x, y = calculate_panel_position(loc, offset)
465
+
466
+ default_color = mpl_plt.rcParams.get("text.color", "black")
467
+
468
+ text_kwargs = {
469
+ "fontsize": fontsize,
470
+ "fontweight": fontweight,
471
+ "color": default_color,
472
+ "transform": "axes",
473
+ "va": "bottom",
474
+ "ha": "right" if "right" in loc else "left",
475
+ }
476
+ text_kwargs.update(kwargs)
477
+
478
+ mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
479
+
480
+ render_kwargs = text_kwargs.copy()
481
+ render_kwargs["transform"] = mpl_ax.transAxes
482
+
483
+ if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
484
+ ax._recorder.record_call(
485
+ ax_position=ax._position,
486
+ method_name="text",
487
+ args=(x, y, label),
488
+ kwargs=text_kwargs,
489
+ )
490
+
491
+ return mpl_ax.text(x, y, label, **render_kwargs)