figrecipe 0.7.4__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
figrecipe/_recorder.py CHANGED
@@ -25,16 +25,21 @@ class CallRecord:
25
25
  kwargs: Dict[str, Any]
26
26
  timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
27
27
  ax_position: Tuple[int, int] = (0, 0)
28
+ # Statistics associated with this plot call (e.g., n, mean, sem)
29
+ stats: Optional[Dict[str, Any]] = None
28
30
 
29
31
  def to_dict(self) -> Dict[str, Any]:
30
32
  """Convert to dictionary for serialization."""
31
- return {
33
+ result = {
32
34
  "id": self.id,
33
35
  "function": self.function,
34
36
  "args": self.args,
35
37
  "kwargs": self.kwargs,
36
38
  "timestamp": self.timestamp,
37
39
  }
40
+ if self.stats is not None:
41
+ result["stats"] = self.stats
42
+ return result
38
43
 
39
44
  @classmethod
40
45
  def from_dict(
@@ -48,6 +53,7 @@ class CallRecord:
48
53
  kwargs=data["kwargs"],
49
54
  timestamp=data.get("timestamp", ""),
50
55
  ax_position=ax_position,
56
+ stats=data.get("stats"),
51
57
  )
52
58
 
53
59
 
@@ -60,6 +66,10 @@ class AxesRecord:
60
66
  decorations: List[CallRecord] = field(default_factory=list)
61
67
  # Panel-level caption (e.g., "(A) Description of this panel")
62
68
  caption: Optional[str] = None
69
+ # Panel-level statistics (e.g., summary stats, comparison results)
70
+ stats: Optional[Dict[str, Any]] = None
71
+ # Panel visibility (for composition)
72
+ visible: bool = True
63
73
 
64
74
  def add_call(self, record: CallRecord) -> None:
65
75
  """Add a plotting call record."""
@@ -77,6 +87,10 @@ class AxesRecord:
77
87
  }
78
88
  if self.caption is not None:
79
89
  result["caption"] = self.caption
90
+ if self.stats is not None:
91
+ result["stats"] = self.stats
92
+ if not self.visible: # Only serialize if hidden (default is True)
93
+ result["visible"] = False
80
94
  return result
81
95
 
82
96
 
@@ -105,6 +119,8 @@ class FigureRecord:
105
119
  # Metadata for scientific figures (not rendered, stored in recipe)
106
120
  title_metadata: Optional[str] = None # Figure title for publication/reference
107
121
  caption: Optional[str] = None # Figure caption (e.g., "Fig. 1. Description...")
122
+ # Figure-level statistics (e.g., comparisons across panels, summary)
123
+ stats: Optional[Dict[str, Any]] = None
108
124
 
109
125
  def get_axes_key(self, row: int, col: int) -> str:
110
126
  """Get dictionary key for axes at position."""
@@ -157,6 +173,8 @@ class FigureRecord:
157
173
  metadata["title"] = self.title_metadata
158
174
  if self.caption is not None:
159
175
  metadata["caption"] = self.caption
176
+ if self.stats is not None:
177
+ metadata["stats"] = self.stats
160
178
  if metadata:
161
179
  result["metadata"] = metadata
162
180
  return result
@@ -181,6 +199,7 @@ class FigureRecord:
181
199
  panel_labels=fig_data.get("panel_labels"),
182
200
  title_metadata=metadata.get("title"),
183
201
  caption=metadata.get("caption"),
202
+ stats=metadata.get("stats"),
184
203
  )
185
204
 
186
205
  # Reconstruct axes
@@ -195,6 +214,8 @@ class FigureRecord:
195
214
  ax_record = AxesRecord(
196
215
  position=(row, col),
197
216
  caption=ax_data.get("caption"),
217
+ stats=ax_data.get("stats"),
218
+ visible=ax_data.get("visible", True),
198
219
  )
199
220
  for call_data in ax_data.get("calls", []):
200
221
  ax_record.calls.append(CallRecord.from_dict(call_data, (row, col)))
@@ -271,6 +292,9 @@ class Recorder:
271
292
  if call_id is None:
272
293
  call_id = self._generate_call_id(method_name)
273
294
 
295
+ # Extract stats from kwargs before processing (stats is metadata, not matplotlib arg)
296
+ call_stats = kwargs.pop("stats", None) if "stats" in kwargs else None
297
+
274
298
  # Process args into serializable format
275
299
  processed_args = self._process_args(args, method_name)
276
300
 
@@ -283,6 +307,7 @@ class Recorder:
283
307
  args=processed_args,
284
308
  kwargs=processed_kwargs,
285
309
  ax_position=ax_position,
310
+ stats=call_stats,
286
311
  )
287
312
 
288
313
  # Add to appropriate axes
@@ -365,8 +390,8 @@ class Recorder:
365
390
  except Exception:
366
391
  pass
367
392
 
368
- # Remove internal keys
369
- skip_keys = {"id", "track", "_array"}
393
+ # Remove internal keys (stats is handled separately as metadata)
394
+ skip_keys = {"id", "track", "_array", "stats"}
370
395
  processed = {}
371
396
 
372
397
  for key, value in kwargs.items():
@@ -2,6 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """Core reproduction logic for figure reproduction."""
4
4
 
5
+ import shutil
5
6
  from pathlib import Path
6
7
  from typing import Any, Dict, List, Optional, Union
7
8
 
@@ -11,6 +12,7 @@ from matplotlib.axes import Axes
11
12
 
12
13
  from .._recorder import CallRecord, FigureRecord
13
14
  from .._serializer import load_recipe
15
+ from .._utils._bundle import resolve_recipe_path
14
16
 
15
17
 
16
18
  def reproduce(
@@ -24,8 +26,11 @@ def reproduce(
24
26
  Parameters
25
27
  ----------
26
28
  path : str or Path
27
- Path to .yaml or .png recipe file. If .png is provided,
28
- the corresponding .yaml file will be loaded.
29
+ Path to recipe. Supports multiple formats:
30
+ - .yaml/.yml file: Direct recipe file
31
+ - .png/.jpg/etc: Image with associated .yaml
32
+ - Directory: Bundle containing recipe.yaml
33
+ - .zip: ZIP archive containing recipe.yaml
29
34
  calls : list of str, optional
30
35
  If provided, only reproduce these specific call IDs.
31
36
  skip_decorations : bool
@@ -43,56 +48,49 @@ def reproduce(
43
48
 
44
49
  Examples
45
50
  --------
46
- >>> import figrecipe as ps
47
- >>> fig, ax = ps.reproduce("experiment_001.yaml")
48
- >>> fig, ax = ps.reproduce("experiment_001.png") # Also works
51
+ >>> import figrecipe as fr
52
+ >>> fig, ax = fr.reproduce("experiment_001.yaml")
53
+ >>> fig, ax = fr.reproduce("experiment_001.png") # Also works
54
+ >>> fig, ax = fr.reproduce("figure_bundle/") # Directory bundle
55
+ >>> fig, ax = fr.reproduce("figure.zip") # ZIP bundle
49
56
  >>> plt.show()
50
57
  """
51
- path = Path(path)
52
-
53
- # Accept both .png and .yaml - find the yaml file
54
- if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".pdf", ".svg"):
55
- yaml_path = path.with_suffix(".yaml")
56
- if not yaml_path.exists():
57
- raise FileNotFoundError(
58
- f"Recipe file not found: {yaml_path}. "
59
- f"Expected .yaml file alongside {path}"
60
- )
61
- path = yaml_path
62
-
63
- record = load_recipe(path)
58
+ # Resolve path to actual recipe YAML (handles directories, ZIPs, images)
59
+ path, temp_dir = resolve_recipe_path(path)
64
60
 
65
- # Check for override file and merge if exists
66
- if apply_overrides:
67
- overrides_path = path.with_suffix(".overrides.json")
68
- if overrides_path.exists():
69
- import json
70
-
71
- with open(overrides_path) as f:
72
- data = json.load(f)
73
-
74
- # Apply style overrides
75
- manual_overrides = data.get("manual_overrides", {})
76
- if manual_overrides:
77
- # Merge overrides into record style
78
- if record.style is None:
79
- record.style = {}
80
- record.style.update(manual_overrides)
81
-
82
- # Apply call overrides (kwargs changes from editor)
83
- call_overrides = data.get("call_overrides", {})
84
- if call_overrides:
85
- for ax_key, ax_record in record.axes.items():
86
- for call in ax_record.calls:
87
- if call.id in call_overrides:
88
- # Merge call kwargs overrides
89
- call.kwargs.update(call_overrides[call.id])
90
-
91
- return reproduce_from_record(
92
- record,
93
- calls=calls,
94
- skip_decorations=skip_decorations,
95
- )
61
+ try:
62
+ record = load_recipe(path)
63
+
64
+ # Check for override file and merge if exists
65
+ if apply_overrides:
66
+ overrides_path = path.with_suffix(".overrides.json")
67
+ if overrides_path.exists():
68
+ import json
69
+
70
+ with open(overrides_path) as f:
71
+ data = json.load(f)
72
+
73
+ # Apply style overrides
74
+ manual_overrides = data.get("manual_overrides", {})
75
+ if manual_overrides:
76
+ if record.style is None:
77
+ record.style = {}
78
+ record.style.update(manual_overrides)
79
+
80
+ # Apply call overrides (kwargs changes from editor)
81
+ call_overrides = data.get("call_overrides", {})
82
+ if call_overrides:
83
+ for ax_key, ax_record in record.axes.items():
84
+ for call in ax_record.calls:
85
+ if call.id in call_overrides:
86
+ call.kwargs.update(call_overrides[call.id])
87
+
88
+ return reproduce_from_record(
89
+ record, calls=calls, skip_decorations=skip_decorations
90
+ )
91
+ finally:
92
+ if temp_dir is not None and temp_dir.exists():
93
+ shutil.rmtree(temp_dir, ignore_errors=True)
96
94
 
97
95
 
98
96
  def reproduce_from_record(
@@ -195,6 +193,10 @@ def reproduce_from_record(
195
193
  if result is not None:
196
194
  result_cache[call.id] = result
197
195
 
196
+ # Apply panel visibility
197
+ if not getattr(ax_record, "visible", True):
198
+ ax.set_visible(False)
199
+
198
200
  # Finalize tick configuration and special plot types (avoids categorical axis interference)
199
201
  from ..styles._style_applier import finalize_special_plots, finalize_ticks
200
202
 
@@ -314,6 +316,15 @@ def _replay_call(
314
316
 
315
317
  return replay_swarmplot_call(ax, call)
316
318
 
319
+ # Handle stat_annotation specially (custom method)
320
+ if method_name == "stat_annotation":
321
+ from .._wrappers._stat_annotation import draw_stat_annotation
322
+
323
+ kwargs = call.kwargs.copy()
324
+ x1 = kwargs.pop("x1", 0)
325
+ x2 = kwargs.pop("x2", 1)
326
+ return draw_stat_annotation(ax, x1, x2, **kwargs)
327
+
317
328
  method = getattr(ax, method_name, None)
318
329
 
319
330
  if method is None:
@@ -2,6 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """Utility modules for figrecipe."""
4
4
 
5
+ from ._bundle import is_bundle_path, resolve_recipe_path
5
6
  from ._diff import get_non_default_kwargs, is_default_value
6
7
  from ._numpy_io import load_array, save_array
7
8
  from ._units import inch_to_mm, mm_to_inch, mm_to_pt, pt_to_mm
@@ -15,6 +16,8 @@ __all__ = [
15
16
  "inch_to_mm",
16
17
  "mm_to_pt",
17
18
  "pt_to_mm",
19
+ "resolve_recipe_path",
20
+ "is_bundle_path",
18
21
  ]
19
22
 
20
23
  # Optional: image comparison (requires PIL)
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Bundle and path resolution utilities for figrecipe.
4
+
5
+ This module provides utilities for resolving recipe paths from:
6
+ - Direct YAML files (.yaml, .yml)
7
+ - Image files (.png, .jpg, etc.) with associated YAML
8
+ - Bundle directories containing recipe.yaml
9
+ - ZIP files containing recipe.yaml
10
+
11
+ This enables integration with FTS (Figure Transfer Specification) bundles.
12
+ """
13
+
14
+ import tempfile
15
+ import zipfile
16
+ from pathlib import Path
17
+ from typing import Optional, Tuple, Union
18
+
19
+ # Standard recipe filename in bundles
20
+ RECIPE_FILENAME = "recipe.yaml"
21
+ RECIPE_FILENAME_ALT = "recipe.yml"
22
+
23
+
24
+ def resolve_recipe_path(
25
+ path: Union[str, Path],
26
+ extract_dir: Optional[Path] = None,
27
+ ) -> Tuple[Path, Optional[Path]]:
28
+ """Resolve a path to a recipe YAML file.
29
+
30
+ Handles multiple input formats:
31
+ - Direct YAML file: Returns as-is
32
+ - Image file (.png, etc.): Finds associated .yaml
33
+ - Directory: Looks for recipe.yaml inside
34
+ - ZIP file: Extracts and finds recipe.yaml
35
+
36
+ Parameters
37
+ ----------
38
+ path : str or Path
39
+ Input path - can be YAML, image, directory, or ZIP.
40
+ extract_dir : Path, optional
41
+ Directory to extract ZIP contents to. If None, uses a temp directory.
42
+
43
+ Returns
44
+ -------
45
+ tuple
46
+ (recipe_path, temp_dir) where temp_dir is set if extraction occurred
47
+ and the caller should clean it up, or None if no cleanup needed.
48
+
49
+ Raises
50
+ ------
51
+ FileNotFoundError
52
+ If path doesn't exist or recipe.yaml not found.
53
+ ValueError
54
+ If path type is not supported.
55
+
56
+ Examples
57
+ --------
58
+ >>> recipe_path, temp = resolve_recipe_path("figure.yaml")
59
+ >>> recipe_path, temp = resolve_recipe_path("figure/")
60
+ >>> recipe_path, temp = resolve_recipe_path("figure.zip")
61
+ """
62
+ path = Path(path)
63
+
64
+ if not path.exists():
65
+ raise FileNotFoundError(f"Path not found: {path}")
66
+
67
+ # Case 1: Direct YAML file
68
+ if path.suffix.lower() in (".yaml", ".yml"):
69
+ return path, None
70
+
71
+ # Case 2: Image file - find associated YAML
72
+ if path.suffix.lower() in (
73
+ ".png",
74
+ ".jpg",
75
+ ".jpeg",
76
+ ".pdf",
77
+ ".svg",
78
+ ".tif",
79
+ ".tiff",
80
+ ):
81
+ return _resolve_from_image(path), None
82
+
83
+ # Case 3: Directory - look for recipe.yaml
84
+ if path.is_dir():
85
+ return _resolve_from_directory(path), None
86
+
87
+ # Case 4: ZIP file - extract and find recipe.yaml
88
+ if path.suffix.lower() == ".zip":
89
+ return _resolve_from_zip(path, extract_dir)
90
+
91
+ raise ValueError(
92
+ f"Unsupported path type: {path.suffix}. "
93
+ f"Expected .yaml, .yml, .png, .zip, or directory."
94
+ )
95
+
96
+
97
+ def _resolve_from_image(path: Path) -> Path:
98
+ """Find YAML recipe associated with an image file."""
99
+ yaml_path = path.with_suffix(".yaml")
100
+ if yaml_path.exists():
101
+ return yaml_path
102
+
103
+ yml_path = path.with_suffix(".yml")
104
+ if yml_path.exists():
105
+ return yml_path
106
+
107
+ raise FileNotFoundError(
108
+ f"Recipe file not found for {path.name}. "
109
+ f"Expected {yaml_path.name} or {yml_path.name}"
110
+ )
111
+
112
+
113
+ def _resolve_from_directory(path: Path) -> Path:
114
+ """Find recipe.yaml inside a directory (FTS bundle)."""
115
+ recipe_path = path / RECIPE_FILENAME
116
+ if recipe_path.exists():
117
+ return recipe_path
118
+
119
+ recipe_alt = path / RECIPE_FILENAME_ALT
120
+ if recipe_alt.exists():
121
+ return recipe_alt
122
+
123
+ # Also check for any .yaml file as fallback
124
+ yaml_files = list(path.glob("*.yaml")) + list(path.glob("*.yml"))
125
+ if len(yaml_files) == 1:
126
+ return yaml_files[0]
127
+
128
+ if yaml_files:
129
+ raise FileNotFoundError(
130
+ f"Multiple YAML files found in {path}. "
131
+ f"Expected {RECIPE_FILENAME} or a single .yaml file."
132
+ )
133
+
134
+ raise FileNotFoundError(
135
+ f"No recipe found in directory {path}. "
136
+ f"Expected {RECIPE_FILENAME} or a .yaml file."
137
+ )
138
+
139
+
140
+ def _resolve_from_zip(
141
+ path: Path,
142
+ extract_dir: Optional[Path] = None,
143
+ ) -> Tuple[Path, Path]:
144
+ """Extract ZIP and find recipe.yaml inside."""
145
+ if not zipfile.is_zipfile(path):
146
+ raise ValueError(f"Not a valid ZIP file: {path}")
147
+
148
+ # Create extraction directory
149
+ if extract_dir is None:
150
+ extract_dir = Path(tempfile.mkdtemp(prefix="figrecipe_bundle_"))
151
+
152
+ with zipfile.ZipFile(path, "r") as zf:
153
+ # Find recipe.yaml in the ZIP
154
+ recipe_name = None
155
+ for name in zf.namelist():
156
+ basename = Path(name).name
157
+ if basename in (RECIPE_FILENAME, RECIPE_FILENAME_ALT):
158
+ recipe_name = name
159
+ break
160
+
161
+ if recipe_name is None:
162
+ # Try to find any yaml file
163
+ yaml_files = [n for n in zf.namelist() if n.endswith((".yaml", ".yml"))]
164
+ if len(yaml_files) == 1:
165
+ recipe_name = yaml_files[0]
166
+ elif yaml_files:
167
+ raise FileNotFoundError(
168
+ f"Multiple YAML files found in {path}. Expected {RECIPE_FILENAME}."
169
+ )
170
+ else:
171
+ raise FileNotFoundError(
172
+ f"No recipe found in ZIP {path}. Expected {RECIPE_FILENAME}."
173
+ )
174
+
175
+ # Extract all files (needed for data files referenced by recipe)
176
+ zf.extractall(extract_dir)
177
+
178
+ recipe_path = extract_dir / recipe_name
179
+ return recipe_path, extract_dir
180
+
181
+
182
+ def is_bundle_path(path: Union[str, Path]) -> bool:
183
+ """Check if path is a bundle directory or ZIP.
184
+
185
+ Parameters
186
+ ----------
187
+ path : str or Path
188
+ Path to check.
189
+
190
+ Returns
191
+ -------
192
+ bool
193
+ True if path is a directory or ZIP file.
194
+ """
195
+ path = Path(path)
196
+ if not path.exists():
197
+ return False
198
+ return path.is_dir() or path.suffix.lower() == ".zip"
199
+
200
+
201
+ __all__ = [
202
+ "resolve_recipe_path",
203
+ "is_bundle_path",
204
+ "RECIPE_FILENAME",
205
+ ]
@@ -82,15 +82,26 @@ class RecordingAxes:
82
82
  """Create a wrapper function that records the call."""
83
83
  from ._axes_helpers import record_call_with_color_capture
84
84
 
85
- def wrapper(*args, id: Optional[str] = None, track: bool = True, **kwargs):
85
+ def wrapper(
86
+ *args,
87
+ id: Optional[str] = None,
88
+ track: bool = True,
89
+ stats: Optional[Dict[str, Any]] = None,
90
+ **kwargs,
91
+ ):
92
+ # Call matplotlib method (without stats - it's metadata only)
86
93
  result = method(*args, **kwargs)
87
94
  if self._track and track:
95
+ # Re-add stats to kwargs for recording
96
+ record_kwargs = kwargs.copy()
97
+ if stats is not None:
98
+ record_kwargs["stats"] = stats
88
99
  record_call_with_color_capture(
89
100
  self._recorder,
90
101
  self._position,
91
102
  method_name,
92
103
  args,
93
- kwargs,
104
+ record_kwargs,
94
105
  result,
95
106
  id,
96
107
  self._result_refs,
@@ -134,6 +145,48 @@ class RecordingAxes:
134
145
  ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
135
146
  return ax_record.caption
136
147
 
148
+ def set_stats(self, stats: Dict[str, Any]) -> "RecordingAxes":
149
+ """Set panel-level statistics metadata (not rendered, stored in recipe).
150
+
151
+ This is for storing statistical summary or comparison results
152
+ for this panel/axis, such as group means, sample sizes, or
153
+ comparison p-values.
154
+
155
+ Parameters
156
+ ----------
157
+ stats : dict
158
+ Statistics dictionary. Common keys include:
159
+ - n: sample size
160
+ - mean: mean value
161
+ - std: standard deviation
162
+ - sem: standard error of the mean
163
+ - comparisons: list of comparison results
164
+
165
+ Returns
166
+ -------
167
+ RecordingAxes
168
+ Self for method chaining.
169
+
170
+ Examples
171
+ --------
172
+ >>> fig, axes = fr.subplots(1, 2)
173
+ >>> axes[0].set_stats({"n": 50, "mean": 3.2, "std": 1.1})
174
+ >>> axes[1].set_stats({
175
+ ... "n": 48,
176
+ ... "mean": 5.1,
177
+ ... "comparisons": [{"vs": "control", "p_value": 0.003}]
178
+ ... })
179
+ """
180
+ ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
181
+ ax_record.stats = stats
182
+ return self
183
+
184
+ @property
185
+ def stats(self) -> Optional[Dict[str, Any]]:
186
+ """Get the panel-level statistics metadata."""
187
+ ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
188
+ return ax_record.stats
189
+
137
190
  def no_record(self):
138
191
  """Context manager to temporarily disable recording.
139
192
 
@@ -333,6 +386,101 @@ class RecordingAxes:
333
386
  ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
334
387
  return ax_record.caption
335
388
 
389
+ def generate_panel_caption(
390
+ self, label: Optional[str] = None, style: str = "publication"
391
+ ) -> str:
392
+ """Generate a caption for this panel from stats metadata."""
393
+ from ._caption_generator import generate_panel_caption
394
+
395
+ return generate_panel_caption(label=label, stats=self.stats, style=style)
396
+
397
+ def add_stat_annotation(
398
+ self,
399
+ x1: float,
400
+ x2: float,
401
+ p_value: Optional[float] = None,
402
+ text: Optional[str] = None,
403
+ y: Optional[float] = None,
404
+ style: str = "stars",
405
+ bracket_height: Optional[float] = None,
406
+ text_offset: Optional[float] = None,
407
+ color: Optional[str] = None,
408
+ linewidth: Optional[float] = None,
409
+ fontsize: Optional[float] = None,
410
+ fontweight: Optional[str] = None,
411
+ id: Optional[str] = None,
412
+ track: bool = True,
413
+ **kwargs,
414
+ ):
415
+ """Add a statistical comparison annotation (bracket with stars/p-value).
416
+
417
+ Parameters
418
+ ----------
419
+ x1, x2 : float
420
+ X positions of the two groups being compared.
421
+ p_value : float, optional
422
+ P-value for automatic star conversion.
423
+ text : str, optional
424
+ Custom text (overrides p_value formatting).
425
+ y : float, optional
426
+ Y position for bracket (auto-calculated if None).
427
+ style : str
428
+ "stars", "p_value", "both", or "bracket_only".
429
+ """
430
+ from ._stat_annotation import draw_stat_annotation
431
+
432
+ # Draw the annotation
433
+ artists = draw_stat_annotation(
434
+ self._ax,
435
+ x1,
436
+ x2,
437
+ y=y,
438
+ text=text,
439
+ p_value=p_value,
440
+ style=style,
441
+ bracket_height=bracket_height,
442
+ text_offset=text_offset,
443
+ color=color,
444
+ linewidth=linewidth,
445
+ fontsize=fontsize,
446
+ fontweight=fontweight,
447
+ **kwargs,
448
+ )
449
+
450
+ # Record if tracking
451
+ if self._track and track:
452
+ call_id = id if id else self._recorder._generate_call_id("stat_annotation")
453
+ record_kwargs = {
454
+ "x1": x1,
455
+ "x2": x2,
456
+ "p_value": p_value,
457
+ "text": text,
458
+ "y": y,
459
+ "style": style,
460
+ "bracket_height": bracket_height,
461
+ "text_offset": text_offset,
462
+ "color": color,
463
+ "linewidth": linewidth,
464
+ "fontsize": fontsize,
465
+ }
466
+ record_kwargs.update(kwargs)
467
+ # Remove None values
468
+ record_kwargs = {k: v for k, v in record_kwargs.items() if v is not None}
469
+
470
+ from .._recorder import CallRecord
471
+
472
+ record = CallRecord(
473
+ id=call_id,
474
+ function="stat_annotation",
475
+ args=[],
476
+ kwargs=record_kwargs,
477
+ ax_position=self._position,
478
+ )
479
+ ax_record = self._recorder.figure_record.get_or_create_axes(*self._position)
480
+ ax_record.add_decoration(record)
481
+
482
+ return artists
483
+
336
484
 
337
485
  class _NoRecordContext:
338
486
  """Context manager to temporarily disable recording."""