figrecipe 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figrecipe/__init__.py ADDED
@@ -0,0 +1,1090 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ figrecipe - Record and reproduce matplotlib figures.
5
+
6
+ A lightweight library for capturing matplotlib plotting calls and
7
+ reproducing figures from saved recipes.
8
+
9
+ Usage
10
+ -----
11
+ Option 1: Import as module (recommended for explicit usage)
12
+
13
+ >>> import figrecipe as ps
14
+ >>> fig, ax = ps.subplots()
15
+ >>> ax.plot(x, y, id='my_data')
16
+ >>> ps.save(fig, 'recipe.yaml')
17
+
18
+ Option 2: Drop-in replacement for matplotlib.pyplot
19
+
20
+ >>> import figrecipe.pyplot as plt # Instead of: import matplotlib.pyplot as plt
21
+ >>> fig, ax = plt.subplots() # Automatically recording-enabled
22
+ >>> ax.plot(x, y, id='my_data')
23
+ >>> fig.save_recipe('recipe.yaml')
24
+
25
+ Examples
26
+ --------
27
+ Recording a figure:
28
+
29
+ >>> import figrecipe as ps
30
+ >>> import numpy as np
31
+ >>>
32
+ >>> x = np.linspace(0, 10, 100)
33
+ >>> y = np.sin(x)
34
+ >>>
35
+ >>> fig, ax = ps.subplots()
36
+ >>> ax.plot(x, y, color='red', linewidth=2, id='sine_wave')
37
+ >>> ax.set_xlabel('Time')
38
+ >>> ax.set_ylabel('Amplitude')
39
+ >>> ps.save(fig, 'my_figure.yaml')
40
+
41
+ Reproducing a figure:
42
+
43
+ >>> fig, ax = ps.reproduce('my_figure.yaml')
44
+ >>> plt.show()
45
+
46
+ Inspecting a recipe:
47
+
48
+ >>> info = ps.info('my_figure.yaml')
49
+ >>> print(info['calls'])
50
+ """
51
+
52
+ from pathlib import Path
53
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Union
54
+
55
+ import matplotlib.pyplot as plt
56
+ from matplotlib.axes import Axes
57
+ from matplotlib.figure import Figure
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
+
115
+
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()
138
+
139
+ __version__ = "0.4.0"
140
+ __all__ = [
141
+ # Main API
142
+ "subplots",
143
+ "save",
144
+ "reproduce",
145
+ "info",
146
+ "load",
147
+ "extract_data",
148
+ "validate",
149
+ # Style system
150
+ "load_style",
151
+ "unload_style",
152
+ "list_presets",
153
+ "STYLE",
154
+ "apply_style",
155
+ # Unit conversions
156
+ "mm_to_inch",
157
+ "mm_to_pt",
158
+ "inch_to_mm",
159
+ "pt_to_mm",
160
+ "mm_to_scatter_size",
161
+ "normalize_color",
162
+ # Font utilities
163
+ "list_available_fonts",
164
+ "check_font",
165
+ # Notebook utilities
166
+ "enable_svg",
167
+ # Seaborn support
168
+ "sns",
169
+ # Classes (for type hints)
170
+ "RecordingFigure",
171
+ "RecordingAxes",
172
+ "FigureRecord",
173
+ "CallRecord",
174
+ "ValidationResult",
175
+ # Image utilities
176
+ "crop",
177
+ # Version
178
+ "__version__",
179
+ ]
180
+
181
+
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()
316
+
317
+
318
+ def subplots(
319
+ nrows: int = 1,
320
+ ncols: int = 1,
321
+ # MM-control parameters
322
+ axes_width_mm: Optional[float] = None,
323
+ axes_height_mm: Optional[float] = None,
324
+ margin_left_mm: Optional[float] = None,
325
+ margin_right_mm: Optional[float] = None,
326
+ margin_bottom_mm: Optional[float] = None,
327
+ margin_top_mm: Optional[float] = None,
328
+ space_w_mm: Optional[float] = None,
329
+ space_h_mm: Optional[float] = None,
330
+ # Style parameters
331
+ style: Optional[Dict[str, Any]] = None,
332
+ apply_style_mm: bool = True,
333
+ **kwargs,
334
+ ) -> Tuple[RecordingFigure, Union[RecordingAxes, List[RecordingAxes]]]:
335
+ """Create a figure with recording-enabled axes.
336
+
337
+ This is a drop-in replacement for plt.subplots() that wraps the
338
+ returned figure and axes with recording capabilities.
339
+
340
+ Supports mm-based layout control for publication-quality figures.
341
+
342
+ Parameters
343
+ ----------
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
+ ----------------
370
+ style : dict, optional
371
+ Style configuration dictionary or result of load_style().
372
+ 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
+
376
+ **kwargs
377
+ Additional arguments passed to plt.subplots() (e.g., figsize, dpi).
378
+
379
+ Returns
380
+ -------
381
+ fig : RecordingFigure
382
+ 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
408
+ """
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
589
+
590
+
591
+ def save(
592
+ fig: Union[RecordingFigure, Figure],
593
+ path: Union[str, Path],
594
+ include_data: bool = True,
595
+ data_format: DataFormat = "csv",
596
+ validate: bool = True,
597
+ validate_mse_threshold: float = 100.0,
598
+ validate_error_level: str = "error",
599
+ verbose: bool = True,
600
+ dpi: Optional[int] = None,
601
+ image_format: Optional[str] = None,
602
+ ):
603
+ """Save a figure as image and recipe.
604
+
605
+ Automatically saves both the image file and the YAML recipe for
606
+ reproducibility. Specify either image or YAML path - the other
607
+ will be created with the same base name.
608
+
609
+ Parameters
610
+ ----------
611
+ fig : RecordingFigure or Figure
612
+ The figure to save. Must be a RecordingFigure for recipe saving.
613
+ 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
617
+ include_data : bool
618
+ If True, save large arrays to separate files.
619
+ 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
624
+ validate : bool
625
+ If True (default), validate reproducibility after saving by
626
+ reproducing the figure and comparing it to the original.
627
+ validate_mse_threshold : float
628
+ Maximum acceptable MSE for validation (default: 100).
629
+ 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)
634
+ verbose : bool
635
+ If True (default), print save status. Set False for CI/scripts.
636
+ dpi : int, optional
637
+ DPI for image output. Uses style DPI or 300 if not specified.
638
+ 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.
641
+
642
+ Returns
643
+ -------
644
+ 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
669
+ """
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
768
+
769
+
770
+ def reproduce(
771
+ path: Union[str, Path],
772
+ calls: Optional[List[str]] = None,
773
+ skip_decorations: bool = False,
774
+ ) -> Tuple[Figure, Union[Axes, List[Axes]]]:
775
+ """Reproduce a figure from a recipe file.
776
+
777
+ Parameters
778
+ ----------
779
+ path : str or Path
780
+ Path to .yaml recipe file.
781
+ calls : list of str, optional
782
+ If provided, only reproduce these specific call IDs.
783
+ skip_decorations : bool
784
+ If True, skip decoration calls (labels, legends, etc.).
785
+
786
+ Returns
787
+ -------
788
+ fig : matplotlib.figure.Figure
789
+ Reproduced figure.
790
+ axes : Axes or list of Axes
791
+ 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
+ """
802
+ return _reproduce(path, calls=calls, skip_decorations=skip_decorations)
803
+
804
+
805
+ 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
+ """
826
+ return get_recipe_info(path)
827
+
828
+
829
+ 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
+ """
851
+ return load_recipe(path)
852
+
853
+
854
+ def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
855
+ """Extract data arrays from a saved recipe.
856
+
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
+ Returns
866
+ -------
867
+ dict
868
+ 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
+ """
898
+ import numpy as np
899
+
900
+ record = load_recipe(path)
901
+ result = {}
902
+
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
+ for ax_key, ax_record in record.axes.items():
910
+ for call in ax_record.calls:
911
+ # Skip decoration calls
912
+ if call.function in decoration_funcs:
913
+ 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
+
958
+ if call_data:
959
+ result[call.id] = call_data
960
+
961
+ return result
962
+
963
+
964
+ # Import ValidationResult for type hints
965
+ from ._validator import ValidationResult, validate_recipe
966
+
967
+
968
+ def validate(
969
+ path: Union[str, Path],
970
+ mse_threshold: float = 100.0,
971
+ ) -> ValidationResult:
972
+ """Validate that a saved recipe can reproduce its original figure.
973
+
974
+ This is a standalone validation function for existing recipes.
975
+ For validation during save, use `ps.save(..., validate=True)`.
976
+
977
+ Parameters
978
+ ----------
979
+ path : str or Path
980
+ Path to .yaml recipe file.
981
+ mse_threshold : float
982
+ Maximum acceptable MSE for validation to pass (default: 100).
983
+
984
+ Returns
985
+ -------
986
+ 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.
1003
+ """
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
+ )
1050
+
1051
+
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
+
1055
+ Automatically detects background color (from corners) and crops to
1056
+ content, leaving only the specified margin around it.
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ input_path : str or Path
1061
+ Path to the input image (PNG, JPEG, etc.)
1062
+ 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.
1065
+ 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).
1068
+ margin_px : int, optional
1069
+ Margin in pixels (overrides margin_mm if provided).
1070
+ overwrite : bool, optional
1071
+ Whether to overwrite the input file (default: False)
1072
+ verbose : bool, optional
1073
+ Whether to print detailed information (default: False)
1074
+
1075
+ Returns
1076
+ -------
1077
+ Path
1078
+ 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
+ """
1089
+ from ._utils._crop import crop as _crop
1090
+ return _crop(input_path, output_path, margin_mm, margin_px, overwrite, verbose)