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/__init__.py CHANGED
@@ -59,8 +59,31 @@ from numpy.typing import NDArray
59
59
  # Notebook utilities
60
60
  from ._api._notebook import enable_svg
61
61
 
62
+ # Panel label
63
+ from ._api._panel import panel_label
64
+
62
65
  # Seaborn proxy
63
66
  from ._api._seaborn_proxy import sns
67
+
68
+ # Composition API
69
+ from ._composition import (
70
+ AlignmentMode,
71
+ align_panels,
72
+ compose,
73
+ distribute_panels,
74
+ hide_panel,
75
+ import_axes,
76
+ show_panel,
77
+ smart_align,
78
+ toggle_panel,
79
+ )
80
+
81
+ # scitex.stats integration
82
+ from ._integrations import (
83
+ SCITEX_STATS_AVAILABLE,
84
+ annotate_from_stats,
85
+ from_scitex_stats,
86
+ )
64
87
  from ._recorder import CallRecord, FigureRecord
65
88
  from ._reproducer import get_recipe_info
66
89
  from ._reproducer import reproduce as _reproduce
@@ -78,14 +101,20 @@ from ._validator import ValidationResult
78
101
  from ._wrappers import RecordingAxes, RecordingFigure
79
102
  from .styles._style_applier import check_font, list_available_fonts
80
103
 
81
- __version__ = "0.7.2"
104
+ try:
105
+ from importlib.metadata import version as _get_version
106
+
107
+ __version__ = _get_version("figrecipe")
108
+ except Exception:
109
+ __version__ = "0.0.0" # Fallback for development
82
110
  __all__ = [
83
111
  # Main API
84
112
  "subplots",
85
113
  "save",
86
114
  "reproduce",
115
+ "load", # Alias for reproduce
87
116
  "info",
88
- "load",
117
+ "load_record",
89
118
  "extract_data",
90
119
  "validate",
91
120
  # GUI Editor
@@ -120,6 +149,21 @@ __all__ = [
120
149
  "crop",
121
150
  # Panel labels
122
151
  "panel_label",
152
+ # Composition
153
+ "compose",
154
+ "import_axes",
155
+ "hide_panel",
156
+ "show_panel",
157
+ "toggle_panel",
158
+ # Alignment
159
+ "AlignmentMode",
160
+ "align_panels",
161
+ "distribute_panels",
162
+ "smart_align",
163
+ # scitex.stats integration
164
+ "from_scitex_stats",
165
+ "annotate_from_stats",
166
+ "SCITEX_STATS_AVAILABLE",
123
167
  # Version
124
168
  "__version__",
125
169
  ]
@@ -302,11 +346,15 @@ def info(path: Union[str, Path]) -> Dict[str, Any]:
302
346
  return get_recipe_info(path)
303
347
 
304
348
 
305
- def load(path: Union[str, Path]) -> FigureRecord:
306
- """Load a recipe as a FigureRecord object."""
349
+ def load_record(path: Union[str, Path]) -> FigureRecord:
350
+ """Load a recipe as a FigureRecord object (advanced use)."""
307
351
  return load_recipe(path)
308
352
 
309
353
 
354
+ # Alias for intuitive save/load symmetry
355
+ load = reproduce
356
+
357
+
310
358
  def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
311
359
  """Extract data arrays from a saved recipe.
312
360
 
@@ -390,26 +438,37 @@ def crop(
390
438
 
391
439
 
392
440
  def edit(
393
- source,
441
+ source=None,
394
442
  style=None,
395
443
  port: int = 5050,
444
+ host: str = "127.0.0.1",
396
445
  open_browser: bool = True,
397
446
  hot_reload: bool = False,
447
+ working_dir=None,
448
+ desktop: bool = False,
398
449
  ):
399
450
  """Launch interactive GUI editor for figure styling.
400
451
 
401
452
  Parameters
402
453
  ----------
403
- source : RecordingFigure, str, or Path
404
- Either a live RecordingFigure object or path to a .yaml recipe file.
454
+ source : RecordingFigure, str, Path, or None
455
+ Either a live RecordingFigure object, path to a .yaml recipe file,
456
+ or None to create a new blank figure.
405
457
  style : str or dict, optional
406
458
  Style preset name or style dict.
407
459
  port : int, optional
408
460
  Flask server port (default: 5050).
461
+ host : str, optional
462
+ Host to bind Flask server (default: "127.0.0.1", use "0.0.0.0" for Docker).
409
463
  open_browser : bool, optional
410
464
  Whether to open browser automatically (default: True).
411
465
  hot_reload : bool, optional
412
466
  Enable hot reload (default: False).
467
+ working_dir : str or Path, optional
468
+ Working directory for file browser (default: directory containing source).
469
+ desktop : bool, optional
470
+ Launch as native desktop window using pywebview (default: False).
471
+ Requires: pip install figrecipe[desktop]
413
472
 
414
473
  Returns
415
474
  -------
@@ -419,73 +478,12 @@ def edit(
419
478
  from ._editor import edit as _edit
420
479
 
421
480
  return _edit(
422
- source, style=style, port=port, open_browser=open_browser, hot_reload=hot_reload
481
+ source,
482
+ style=style,
483
+ port=port,
484
+ host=host,
485
+ open_browser=open_browser,
486
+ hot_reload=hot_reload,
487
+ working_dir=working_dir,
488
+ desktop=desktop,
423
489
  )
424
-
425
-
426
- def panel_label(
427
- ax,
428
- label: str,
429
- loc: str = "upper left",
430
- fontsize: Optional[float] = None,
431
- fontweight: str = "bold",
432
- offset: Tuple[float, float] = (-0.1, 1.05),
433
- **kwargs,
434
- ):
435
- """Add a panel label (A, B, C, ...) to an axes.
436
-
437
- Parameters
438
- ----------
439
- ax : Axes or RecordingAxes
440
- The axes to label.
441
- label : str
442
- The label text (e.g., 'A', 'B', 'a)', '(1)').
443
- loc : str, optional
444
- Label location: 'upper left', 'upper right', etc.
445
- fontsize : float, optional
446
- Font size in points.
447
- fontweight : str, optional
448
- Font weight (default: 'bold').
449
- offset : tuple of float, optional
450
- (x, y) offset in axes coordinates.
451
- **kwargs
452
- Additional arguments passed to ax.text().
453
-
454
- Returns
455
- -------
456
- Text
457
- The matplotlib Text object.
458
- """
459
- import matplotlib.pyplot as mpl_plt
460
-
461
- from ._api._panel import calculate_panel_position, get_panel_label_fontsize
462
-
463
- fontsize = get_panel_label_fontsize(fontsize)
464
- x, y = calculate_panel_position(loc, offset)
465
-
466
- default_color = mpl_plt.rcParams.get("text.color", "black")
467
-
468
- text_kwargs = {
469
- "fontsize": fontsize,
470
- "fontweight": fontweight,
471
- "color": default_color,
472
- "transform": "axes",
473
- "va": "bottom",
474
- "ha": "right" if "right" in loc else "left",
475
- }
476
- text_kwargs.update(kwargs)
477
-
478
- mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
479
-
480
- render_kwargs = text_kwargs.copy()
481
- render_kwargs["transform"] = mpl_ax.transAxes
482
-
483
- if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
484
- ax._recorder.record_call(
485
- ax_position=ax._position,
486
- method_name="text",
487
- args=(x, y, label),
488
- kwargs=text_kwargs,
489
- )
490
-
491
- return mpl_ax.text(x, y, label, **render_kwargs)
figrecipe/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """figrecipe CLI entry point.
4
+
5
+ Allows running figrecipe as a module:
6
+ python -m figrecipe <command>
7
+ """
8
+
9
+ from ._cli import main
10
+
11
+ if __name__ == "__main__":
12
+ main()
figrecipe/_api/_panel.py CHANGED
@@ -38,9 +38,76 @@ def calculate_panel_position(
38
38
  return x, y
39
39
 
40
40
 
41
+ def panel_label(
42
+ ax,
43
+ label: str,
44
+ loc: str = "upper left",
45
+ fontsize: Optional[float] = None,
46
+ fontweight: str = "bold",
47
+ offset: Tuple[float, float] = (-0.1, 1.05),
48
+ **kwargs,
49
+ ):
50
+ """Add a panel label (A, B, C, ...) to an axes.
51
+
52
+ Parameters
53
+ ----------
54
+ ax : Axes or RecordingAxes
55
+ The axes to label.
56
+ label : str
57
+ The label text (e.g., 'A', 'B', 'a)', '(1)').
58
+ loc : str, optional
59
+ Label location: 'upper left', 'upper right', etc.
60
+ fontsize : float, optional
61
+ Font size in points.
62
+ fontweight : str, optional
63
+ Font weight (default: 'bold').
64
+ offset : tuple of float, optional
65
+ (x, y) offset in axes coordinates.
66
+ **kwargs
67
+ Additional arguments passed to ax.text().
68
+
69
+ Returns
70
+ -------
71
+ Text
72
+ The matplotlib Text object.
73
+ """
74
+ import matplotlib.pyplot as mpl_plt
75
+
76
+ fontsize = get_panel_label_fontsize(fontsize)
77
+ x, y = calculate_panel_position(loc, offset)
78
+
79
+ default_color = mpl_plt.rcParams.get("text.color", "black")
80
+
81
+ text_kwargs = {
82
+ "fontsize": fontsize,
83
+ "fontweight": fontweight,
84
+ "color": default_color,
85
+ "transform": "axes",
86
+ "va": "bottom",
87
+ "ha": "right" if "right" in loc else "left",
88
+ }
89
+ text_kwargs.update(kwargs)
90
+
91
+ mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
92
+
93
+ render_kwargs = text_kwargs.copy()
94
+ render_kwargs["transform"] = mpl_ax.transAxes
95
+
96
+ if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
97
+ ax._recorder.record_call(
98
+ ax_position=ax._position,
99
+ method_name="text",
100
+ args=(x, y, label),
101
+ kwargs=text_kwargs,
102
+ )
103
+
104
+ return mpl_ax.text(x, y, label, **render_kwargs)
105
+
106
+
41
107
  __all__ = [
42
108
  "get_panel_label_fontsize",
43
109
  "calculate_panel_position",
110
+ "panel_label",
44
111
  ]
45
112
 
46
113
  # EOF
figrecipe/_api/_save.py CHANGED
@@ -2,6 +2,9 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """Save function helpers for the public API."""
4
4
 
5
+ import shutil
6
+ import tempfile
7
+ import zipfile
5
8
  from pathlib import Path
6
9
  from typing import Optional, Tuple
7
10
 
@@ -17,6 +20,7 @@ IMAGE_EXTENSIONS = {
17
20
  ".tif",
18
21
  }
19
22
  YAML_EXTENSIONS = {".yaml", ".yml"}
23
+ BUNDLE_RECIPE_NAME = "recipe.yaml"
20
24
 
21
25
 
22
26
  def resolve_save_paths(
@@ -101,6 +105,76 @@ def get_save_transparency() -> bool:
101
105
  return False
102
106
 
103
107
 
108
+ def _is_bundle_path(path: Path) -> bool:
109
+ """Check if path represents a bundle (directory or ZIP)."""
110
+ suffix = path.suffix.lower()
111
+ # ZIP file
112
+ if suffix == ".zip":
113
+ return True
114
+ # Existing directory
115
+ if path.is_dir():
116
+ return True
117
+ # Path ending with / (explicit directory)
118
+ if str(path).endswith("/"):
119
+ return True
120
+ # No extension and doesn't look like a file
121
+ if not suffix and not path.exists():
122
+ return True
123
+ return False
124
+
125
+
126
+ def _save_as_bundle(
127
+ fig,
128
+ path: Path,
129
+ include_data: bool,
130
+ data_format: str,
131
+ dpi: int,
132
+ transparent: bool,
133
+ image_format: str,
134
+ verbose: bool,
135
+ ) -> Tuple[Path, Path]:
136
+ """Save figure as a bundle (directory or ZIP)."""
137
+ suffix = path.suffix.lower()
138
+ is_zip = suffix == ".zip"
139
+
140
+ # Create temporary directory for bundle contents
141
+ with tempfile.TemporaryDirectory() as tmpdir:
142
+ tmpdir = Path(tmpdir)
143
+
144
+ # Determine image format
145
+ img_format = image_format or _get_default_image_format()
146
+ image_name = f"figure.{img_format}"
147
+
148
+ # Save image
149
+ image_path = tmpdir / image_name
150
+ fig.fig.savefig(
151
+ image_path, dpi=dpi, bbox_inches="tight", transparent=transparent
152
+ )
153
+
154
+ # Save recipe
155
+ yaml_path = tmpdir / BUNDLE_RECIPE_NAME
156
+ fig.save_recipe(yaml_path, include_data=include_data, data_format=data_format)
157
+
158
+ if is_zip:
159
+ # Create ZIP bundle
160
+ zip_path = path.with_suffix(".zip")
161
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
162
+ zf.write(yaml_path, BUNDLE_RECIPE_NAME)
163
+ zf.write(image_path, image_name)
164
+ if verbose:
165
+ print(f"Saved: {zip_path} (ZIP bundle)")
166
+ return zip_path, zip_path
167
+ else:
168
+ # Create directory bundle
169
+ bundle_dir = Path(str(path).rstrip("/"))
170
+ bundle_dir.mkdir(parents=True, exist_ok=True)
171
+ shutil.copy2(yaml_path, bundle_dir / BUNDLE_RECIPE_NAME)
172
+ shutil.copy2(image_path, bundle_dir / image_name)
173
+ if verbose:
174
+ print(f"Saved: {bundle_dir}/ (directory bundle)")
175
+ return bundle_dir, bundle_dir / BUNDLE_RECIPE_NAME
176
+
177
+
104
178
  def save_figure(
105
179
  fig,
106
180
  path,
@@ -113,7 +187,14 @@ def save_figure(
113
187
  dpi: Optional[int] = None,
114
188
  image_format: Optional[str] = None,
115
189
  ):
116
- """Core save implementation."""
190
+ """Core save implementation.
191
+
192
+ Supports multiple output formats:
193
+ - Image file (.png, .pdf, etc.): Saves image + .yaml recipe
194
+ - YAML file (.yaml): Saves recipe + image
195
+ - Directory (path/ or no extension): Saves as bundle directory
196
+ - ZIP file (.zip): Saves as ZIP bundle
197
+ """
117
198
  from .._wrappers import RecordingFigure
118
199
 
119
200
  path = Path(path)
@@ -124,9 +205,6 @@ def save_figure(
124
205
  "a recording-enabled figure."
125
206
  )
126
207
 
127
- # Resolve paths
128
- image_path, yaml_path, _ = resolve_save_paths(path, image_format)
129
-
130
208
  # Get DPI and transparency from style if not specified
131
209
  dpi = get_save_dpi(dpi)
132
210
  transparent = get_save_transparency()
@@ -145,6 +223,24 @@ def save_figure(
145
223
  finalize_ticks(ax)
146
224
  finalize_special_plots(ax, style_dict)
147
225
 
226
+ # Check if saving as bundle
227
+ if _is_bundle_path(path):
228
+ bundle_path, yaml_path = _save_as_bundle(
229
+ fig,
230
+ path,
231
+ include_data,
232
+ data_format,
233
+ dpi,
234
+ transparent,
235
+ image_format or _get_default_image_format(),
236
+ verbose,
237
+ )
238
+ # No validation for bundles (yet)
239
+ return bundle_path, yaml_path, None
240
+
241
+ # Resolve paths for standard save
242
+ image_path, yaml_path, _ = resolve_save_paths(path, image_format)
243
+
148
244
  # Save the image
149
245
  fig.fig.savefig(image_path, dpi=dpi, bbox_inches="tight", transparent=transparent)
150
246
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """figrecipe CLI - Command-line interface for figrecipe."""
4
+
5
+ from ._main import main
6
+
7
+ __all__ = ["main"]
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """compose command - Combine multiple figures."""
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple
7
+
8
+ import click
9
+
10
+
11
+ @click.command()
12
+ @click.argument("sources", nargs=-1, type=click.Path(exists=True), required=True)
13
+ @click.option(
14
+ "-o",
15
+ "--output",
16
+ type=click.Path(),
17
+ required=True,
18
+ help="Output path for composed figure.",
19
+ )
20
+ @click.option(
21
+ "--layout",
22
+ type=click.Choice(["horizontal", "vertical", "grid"]),
23
+ default="horizontal",
24
+ help="Layout arrangement (default: horizontal).",
25
+ )
26
+ @click.option(
27
+ "--cols",
28
+ type=int,
29
+ help="Number of columns for grid layout.",
30
+ )
31
+ @click.option(
32
+ "--dpi",
33
+ type=int,
34
+ default=300,
35
+ help="DPI for output (default: 300).",
36
+ )
37
+ def compose(
38
+ sources: Tuple[str, ...],
39
+ output: str,
40
+ layout: str,
41
+ cols: Optional[int],
42
+ dpi: int,
43
+ ) -> None:
44
+ """Compose multiple figures into one.
45
+
46
+ SOURCES are paths to .yaml recipe files or bundle directories.
47
+ """
48
+ from .. import compose as fr_compose
49
+ from .. import reproduce, save
50
+
51
+ if len(sources) < 2:
52
+ raise click.ClickException("At least 2 source figures required.")
53
+
54
+ source_paths = [Path(s) for s in sources]
55
+ output_path = Path(output)
56
+
57
+ # Determine grid dimensions
58
+ n = len(sources)
59
+ if layout == "horizontal":
60
+ nrows, ncols = 1, n
61
+ elif layout == "vertical":
62
+ nrows, ncols = n, 1
63
+ else: # grid
64
+ if cols:
65
+ ncols = cols
66
+ nrows = (n + cols - 1) // cols
67
+ else:
68
+ # Auto-determine roughly square grid
69
+ import math
70
+
71
+ ncols = math.ceil(math.sqrt(n))
72
+ nrows = math.ceil(n / ncols)
73
+
74
+ # Reproduce and compose figures
75
+ try:
76
+ figures = []
77
+ for src in source_paths:
78
+ fig, _ = reproduce(src)
79
+ figures.append(fig)
80
+
81
+ composed = fr_compose(*figures, nrows=nrows, ncols=ncols)
82
+ save(composed, output_path, dpi=dpi)
83
+
84
+ click.echo(f"Composed {len(figures)} figures: {output_path}")
85
+
86
+ except Exception as e:
87
+ raise click.ClickException(f"Composition failed: {e}") from e
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """convert command - Convert between formats."""
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+
11
+ @click.command()
12
+ @click.argument("source", type=click.Path(exists=True))
13
+ @click.option(
14
+ "-f",
15
+ "--format",
16
+ "fmt",
17
+ type=click.Choice(["png", "pdf", "svg", "yaml"]),
18
+ required=True,
19
+ help="Target format.",
20
+ )
21
+ @click.option(
22
+ "-o",
23
+ "--output",
24
+ type=click.Path(),
25
+ help="Output path.",
26
+ )
27
+ @click.option(
28
+ "--dpi",
29
+ type=int,
30
+ default=300,
31
+ help="DPI for raster output (default: 300).",
32
+ )
33
+ def convert(
34
+ source: str,
35
+ fmt: str,
36
+ output: Optional[str],
37
+ dpi: int,
38
+ ) -> None:
39
+ """Convert between figure formats.
40
+
41
+ SOURCE is a .yaml recipe or image file.
42
+ """
43
+ source_path = Path(source)
44
+
45
+ # Determine output path
46
+ if output:
47
+ output_path = Path(output)
48
+ else:
49
+ output_path = source_path.with_suffix(f".{fmt}")
50
+
51
+ # Handle different source types
52
+ if source_path.suffix in [".yaml", ".yml"]:
53
+ _convert_from_recipe(source_path, output_path, fmt, dpi)
54
+ elif source_path.suffix in [".png", ".pdf", ".svg"]:
55
+ _convert_image(source_path, output_path, fmt, dpi)
56
+ else:
57
+ raise click.ClickException(f"Unsupported source format: {source_path.suffix}")
58
+
59
+
60
+ def _convert_from_recipe(source: Path, output: Path, fmt: str, dpi: int) -> None:
61
+ """Convert from YAML recipe to image format."""
62
+ import matplotlib.pyplot as plt
63
+
64
+ from .. import reproduce
65
+
66
+ try:
67
+ fig, _ = reproduce(source)
68
+
69
+ if fmt == "yaml":
70
+ # Already have YAML, just copy
71
+ import shutil
72
+
73
+ shutil.copy(source, output)
74
+ else:
75
+ fig.savefig(output, dpi=dpi, format=fmt)
76
+
77
+ # Close the figure (handle both regular and Recording figures)
78
+ try:
79
+ plt.close(fig)
80
+ except TypeError:
81
+ plt.close("all")
82
+
83
+ click.echo(f"Converted: {output}")
84
+
85
+ except Exception as e:
86
+ raise click.ClickException(f"Conversion failed: {e}") from e
87
+
88
+
89
+ def _convert_image(source: Path, output: Path, fmt: str, dpi: int) -> None:
90
+ """Convert between image formats."""
91
+ if fmt == "yaml":
92
+ raise click.ClickException(
93
+ "Cannot convert image to YAML. Use a recipe file instead."
94
+ )
95
+
96
+ try:
97
+ from PIL import Image
98
+
99
+ img = Image.open(source)
100
+
101
+ if fmt == "pdf":
102
+ img.save(output, "PDF", resolution=dpi)
103
+ elif fmt == "svg":
104
+ raise click.ClickException(
105
+ "Cannot convert raster image to SVG. Use a recipe file instead."
106
+ )
107
+ else:
108
+ img.save(output, fmt.upper())
109
+
110
+ click.echo(f"Converted: {output}")
111
+
112
+ except ImportError:
113
+ raise click.ClickException(
114
+ "Image conversion requires Pillow. Install with: pip install figrecipe[imaging]"
115
+ ) from None
116
+ except Exception as e:
117
+ raise click.ClickException(f"Conversion failed: {e}") from e