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