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
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """version command - Show version information."""
4
+
5
+ import click
6
+
7
+
8
+ @click.command()
9
+ @click.option(
10
+ "--full",
11
+ is_flag=True,
12
+ help="Show full version info with dependencies.",
13
+ )
14
+ def version(full: bool) -> None:
15
+ """Show version information."""
16
+ from .. import __version__
17
+
18
+ click.echo(f"figrecipe {__version__}")
19
+
20
+ if full:
21
+ click.echo()
22
+ _show_dependency_versions()
23
+
24
+
25
+ def _show_dependency_versions() -> None:
26
+ """Show versions of key dependencies."""
27
+ deps = [
28
+ ("matplotlib", "matplotlib"),
29
+ ("numpy", "numpy"),
30
+ ("ruamel.yaml", "ruamel.yaml"),
31
+ ("scipy", "scipy"),
32
+ ("Pillow", "PIL"),
33
+ ("seaborn", "seaborn"),
34
+ ("pandas", "pandas"),
35
+ ("flask", "flask"),
36
+ ]
37
+
38
+ click.echo("Dependencies:")
39
+ for name, module in deps:
40
+ try:
41
+ mod = __import__(module)
42
+ ver = getattr(mod, "__version__", "unknown")
43
+ click.echo(f" {name}: {ver}")
44
+ except ImportError:
45
+ click.echo(f" {name}: not installed")
46
+
47
+ # Python version
48
+ import sys
49
+
50
+ click.echo(f"\nPython: {sys.version}")
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Composition module for combining multiple figures.
4
+
5
+ This module provides functionality to:
6
+ - Compose new figures from multiple recipe sources
7
+ - Import axes from external recipes into existing figures
8
+ - Hide/show panels for visual composition
9
+ - Align and distribute panels
10
+
11
+ Phase 1-3 of the composition feature.
12
+ """
13
+
14
+ from ._alignment import AlignmentMode, align_panels, distribute_panels, smart_align
15
+ from ._compose import compose
16
+ from ._import_axes import import_axes
17
+ from ._visibility import hide_panel, show_panel, toggle_panel
18
+
19
+ __all__ = [
20
+ # Phase 1: Composition
21
+ "compose",
22
+ "import_axes",
23
+ # Phase 2: Visibility
24
+ "hide_panel",
25
+ "show_panel",
26
+ "toggle_panel",
27
+ # Phase 3: Alignment
28
+ "AlignmentMode",
29
+ "align_panels",
30
+ "distribute_panels",
31
+ "smart_align",
32
+ ]
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Panel alignment tools for composition feature.
4
+
5
+ Provides alignment and distribution functions for multi-panel figures.
6
+ """
7
+
8
+ from enum import Enum
9
+ from typing import List, Optional, Tuple, Union
10
+
11
+ from matplotlib.transforms import Bbox
12
+
13
+ from .._wrappers import RecordingFigure
14
+
15
+
16
+ class AlignmentMode(Enum):
17
+ """Alignment modes for panel positioning."""
18
+
19
+ LEFT = "left"
20
+ RIGHT = "right"
21
+ TOP = "top"
22
+ BOTTOM = "bottom"
23
+ CENTER_H = "center_h" # Horizontal center
24
+ CENTER_V = "center_v" # Vertical center
25
+ AXIS_X = "axis_x" # Align x-axes
26
+ AXIS_Y = "axis_y" # Align y-axes
27
+
28
+
29
+ def align_panels(
30
+ fig: RecordingFigure,
31
+ panels: List[Tuple[int, int]],
32
+ mode: Union[str, AlignmentMode],
33
+ reference: Optional[Tuple[int, int]] = None,
34
+ ) -> None:
35
+ """Align multiple panels to a reference panel.
36
+
37
+ Parameters
38
+ ----------
39
+ fig : RecordingFigure
40
+ The figure containing the panels.
41
+ panels : list of tuple
42
+ List of (row, col) positions to align.
43
+ mode : str or AlignmentMode
44
+ Alignment mode: 'left', 'right', 'top', 'bottom',
45
+ 'center_h', 'center_v', 'axis_x', 'axis_y'.
46
+ reference : tuple, optional
47
+ Reference panel position. If None, uses first panel.
48
+
49
+ Examples
50
+ --------
51
+ >>> import figrecipe as fr
52
+ >>> fig, axes = fr.subplots(2, 2)
53
+ >>> # Align left column panels to left edge
54
+ >>> fr.align_panels(fig, [(0, 0), (1, 0)], mode="left")
55
+ """
56
+ mode = AlignmentMode(mode) if isinstance(mode, str) else mode
57
+
58
+ if not panels:
59
+ return
60
+
61
+ ref_pos = reference or panels[0]
62
+ ref_ax = _get_mpl_axes(fig, ref_pos)
63
+ if ref_ax is None:
64
+ return
65
+ ref_bbox = ref_ax.get_position()
66
+
67
+ for pos in panels:
68
+ if pos == ref_pos:
69
+ continue
70
+
71
+ ax = _get_mpl_axes(fig, pos)
72
+ if ax is None:
73
+ continue
74
+ bbox = ax.get_position()
75
+ new_bbox = _calculate_aligned_bbox(bbox, ref_bbox, mode)
76
+ ax.set_position(new_bbox)
77
+
78
+
79
+ def distribute_panels(
80
+ fig: RecordingFigure,
81
+ panels: List[Tuple[int, int]],
82
+ direction: str = "horizontal",
83
+ spacing_mm: Optional[float] = None,
84
+ ) -> None:
85
+ """Distribute panels evenly with optional fixed spacing.
86
+
87
+ Parameters
88
+ ----------
89
+ fig : RecordingFigure
90
+ The figure containing the panels.
91
+ panels : list of tuple
92
+ List of (row, col) positions to distribute.
93
+ direction : str
94
+ 'horizontal' or 'vertical'.
95
+ spacing_mm : float, optional
96
+ Fixed spacing in mm. If None, distribute evenly within
97
+ current bounds.
98
+
99
+ Examples
100
+ --------
101
+ >>> import figrecipe as fr
102
+ >>> fig, axes = fr.subplots(1, 3)
103
+ >>> # Distribute evenly
104
+ >>> fr.distribute_panels(fig, [(0, 0), (0, 1), (0, 2)])
105
+ >>> # With fixed 5mm spacing
106
+ >>> fr.distribute_panels(fig, [(0, 0), (0, 1), (0, 2)], spacing_mm=5)
107
+ """
108
+ if len(panels) < 2:
109
+ return
110
+
111
+ # Sort panels by position
112
+ if direction == "horizontal":
113
+ sorted_panels = sorted(panels, key=lambda p: p[1])
114
+ else:
115
+ sorted_panels = sorted(panels, key=lambda p: p[0])
116
+
117
+ # Get bounding boxes
118
+ bboxes = []
119
+ valid_panels = []
120
+ for p in sorted_panels:
121
+ ax = _get_mpl_axes(fig, p)
122
+ if ax is not None:
123
+ bboxes.append(ax.get_position())
124
+ valid_panels.append(p)
125
+
126
+ if len(valid_panels) < 2:
127
+ return
128
+
129
+ # Calculate even distribution
130
+ if direction == "horizontal":
131
+ _distribute_horizontal(fig, valid_panels, bboxes, spacing_mm)
132
+ else:
133
+ _distribute_vertical(fig, valid_panels, bboxes, spacing_mm)
134
+
135
+
136
+ def smart_align(
137
+ fig: RecordingFigure,
138
+ panels: Optional[List[Tuple[int, int]]] = None,
139
+ ) -> None:
140
+ """Automatically align panels in a compact grid layout.
141
+
142
+ Works like human behavior:
143
+ 1. Detect grid structure (nrows, ncols)
144
+ 2. Place panels from top-left to bottom-right
145
+ 3. Calculate minimum rectangle to cover all content in each row/column
146
+ 4. Unify row heights and column widths
147
+ 5. Use space effectively with theme margins and spacing
148
+
149
+ Uses margin and spacing values from the loaded SCITEX theme:
150
+ - margins.left_mm, margins.right_mm, margins.top_mm, margins.bottom_mm
151
+ - spacing.horizontal_mm, spacing.vertical_mm
152
+
153
+ Parameters
154
+ ----------
155
+ fig : RecordingFigure
156
+ The figure containing the panels.
157
+ panels : list of tuple, optional
158
+ Specific panels to align. If None, aligns all panels.
159
+
160
+ Examples
161
+ --------
162
+ >>> import figrecipe as fr
163
+ >>> fig, axes = fr.subplots(2, 2)
164
+ >>> # ... add plots ...
165
+ >>> fr.smart_align(fig) # Align all panels using theme settings
166
+ """
167
+ from .._utils._units import mm_to_inch
168
+
169
+ if panels is None:
170
+ panels = [tuple(map(int, ax_key.split("_")[1:3])) for ax_key in fig.record.axes]
171
+
172
+ if not panels:
173
+ return
174
+
175
+ # Get matplotlib figure
176
+ mpl_fig = fig.fig if hasattr(fig, "fig") else fig
177
+
178
+ # Get style from loaded theme
179
+ try:
180
+ from ..styles._style_loader import _STYLE_CACHE
181
+
182
+ style = _STYLE_CACHE
183
+ except (ImportError, AttributeError):
184
+ style = None
185
+
186
+ # Extract margin values from theme (with defaults)
187
+ if style and hasattr(style, "margins"):
188
+ margin_left = style.margins.get("left_mm", 6)
189
+ margin_right = style.margins.get("right_mm", 1)
190
+ margin_top = style.margins.get("top_mm", 5)
191
+ margin_bottom = style.margins.get("bottom_mm", 5)
192
+ else:
193
+ margin_left = margin_right = margin_top = margin_bottom = 5
194
+
195
+ # Extract spacing values from theme (with defaults)
196
+ if style and hasattr(style, "spacing"):
197
+ spacing_h_mm = style.spacing.get("horizontal_mm", 10)
198
+ spacing_v_mm = style.spacing.get("vertical_mm", 15)
199
+ else:
200
+ spacing_h_mm = 10
201
+ spacing_v_mm = 15
202
+
203
+ # Determine grid dimensions
204
+ max_row = max(p[0] for p in panels)
205
+ max_col = max(p[1] for p in panels)
206
+ nrows = max_row + 1
207
+ ncols = max_col + 1
208
+
209
+ # Get figure size in inches
210
+ fig_width, fig_height = mpl_fig.get_size_inches()
211
+
212
+ # Convert margins/spacing to figure fraction
213
+ margin_left_frac = mm_to_inch(margin_left) / fig_width
214
+ margin_right_frac = mm_to_inch(margin_right) / fig_width
215
+ margin_top_frac = mm_to_inch(margin_top) / fig_height
216
+ margin_bottom_frac = mm_to_inch(margin_bottom) / fig_height
217
+ spacing_frac_w = mm_to_inch(spacing_h_mm) / fig_width
218
+ spacing_frac_h = mm_to_inch(spacing_v_mm) / fig_height
219
+
220
+ # Build grid of axes
221
+ grid = {}
222
+ for pos in panels:
223
+ ax = _get_mpl_axes(fig, pos)
224
+ if ax is not None:
225
+ grid[pos] = ax
226
+
227
+ # Calculate content-based widths for each column
228
+ col_widths = []
229
+ for c in range(ncols):
230
+ max_width = 0
231
+ for r in range(nrows):
232
+ if (r, c) in grid:
233
+ bbox = grid[(r, c)].get_position()
234
+ max_width = max(max_width, bbox.width)
235
+ col_widths.append(max_width if max_width > 0 else 0.2)
236
+
237
+ # Calculate content-based heights for each row
238
+ row_heights = []
239
+ for r in range(nrows):
240
+ max_height = 0
241
+ for c in range(ncols):
242
+ if (r, c) in grid:
243
+ bbox = grid[(r, c)].get_position()
244
+ max_height = max(max_height, bbox.height)
245
+ row_heights.append(max_height if max_height > 0 else 0.15)
246
+
247
+ # Calculate total content size
248
+ total_content_w = sum(col_widths) + spacing_frac_w * (ncols - 1)
249
+ total_content_h = sum(row_heights) + spacing_frac_h * (nrows - 1)
250
+
251
+ # Available space after asymmetric margins
252
+ avail_w = 1.0 - margin_left_frac - margin_right_frac
253
+ avail_h = 1.0 - margin_top_frac - margin_bottom_frac
254
+
255
+ # Scale factor to fit content in available space
256
+ scale_w = avail_w / total_content_w if total_content_w > 0 else 1.0
257
+ scale_h = avail_h / total_content_h if total_content_h > 0 else 1.0
258
+ scale = min(scale_w, scale_h, 1.0) # Don't enlarge, only shrink if needed
259
+
260
+ # Apply scaling
261
+ col_widths = [w * scale for w in col_widths]
262
+ row_heights = [h * scale for h in row_heights]
263
+ spacing_w = spacing_frac_w * scale
264
+ spacing_h = spacing_frac_h * scale
265
+
266
+ # Recalculate total after scaling
267
+ total_w = sum(col_widths) + spacing_w * (ncols - 1)
268
+ total_h = sum(row_heights) + spacing_h * (nrows - 1)
269
+
270
+ # Position grid: left-aligned with left margin, centered vertically
271
+ start_x = margin_left_frac + (avail_w - total_w) / 2
272
+
273
+ # Position panels from top-left to bottom-right
274
+ # Matplotlib y=0 is bottom, so we work from top down
275
+ y = 1.0 - margin_top_frac - (avail_h - total_h) / 2
276
+ for r in range(nrows):
277
+ y -= row_heights[r]
278
+ x = start_x
279
+ for c in range(ncols):
280
+ if (r, c) in grid:
281
+ ax = grid[(r, c)]
282
+ new_bbox = Bbox.from_bounds(x, y, col_widths[c], row_heights[r])
283
+ ax.set_position(new_bbox)
284
+ x += col_widths[c] + spacing_w
285
+ y -= spacing_h
286
+
287
+
288
+ def _get_mpl_axes(fig: RecordingFigure, position: Tuple[int, int]):
289
+ """Get matplotlib axes at position.
290
+
291
+ Parameters
292
+ ----------
293
+ fig : RecordingFigure
294
+ The figure.
295
+ position : tuple
296
+ (row, col) position.
297
+
298
+ Returns
299
+ -------
300
+ matplotlib.axes.Axes or None
301
+ The matplotlib axes, or None if not found.
302
+ """
303
+ row, col = position
304
+ try:
305
+ axes = fig._axes
306
+ if isinstance(axes, list):
307
+ if isinstance(axes[0], list):
308
+ ax = axes[row][col]
309
+ else:
310
+ # 1D list for single row/column
311
+ ax = axes[max(row, col)]
312
+ else:
313
+ # Numpy array
314
+ ax = axes[row, col]
315
+
316
+ return ax._ax if hasattr(ax, "_ax") else ax
317
+ except (IndexError, AttributeError, KeyError, TypeError):
318
+ return None
319
+
320
+
321
+ def _calculate_aligned_bbox(
322
+ bbox: Bbox,
323
+ ref_bbox: Bbox,
324
+ mode: AlignmentMode,
325
+ ) -> Bbox:
326
+ """Calculate new bbox aligned to reference.
327
+
328
+ Parameters
329
+ ----------
330
+ bbox : Bbox
331
+ Current bounding box.
332
+ ref_bbox : Bbox
333
+ Reference bounding box.
334
+ mode : AlignmentMode
335
+ Alignment mode.
336
+
337
+ Returns
338
+ -------
339
+ Bbox
340
+ New aligned bounding box.
341
+ """
342
+ x0, y0 = bbox.x0, bbox.y0
343
+ width, height = bbox.width, bbox.height
344
+
345
+ if mode == AlignmentMode.LEFT:
346
+ x0 = ref_bbox.x0
347
+ elif mode == AlignmentMode.RIGHT:
348
+ x0 = ref_bbox.x1 - width
349
+ elif mode == AlignmentMode.TOP:
350
+ y0 = ref_bbox.y1 - height
351
+ elif mode == AlignmentMode.BOTTOM:
352
+ y0 = ref_bbox.y0
353
+ elif mode == AlignmentMode.CENTER_H:
354
+ x0 = ref_bbox.x0 + (ref_bbox.width - width) / 2
355
+ elif mode == AlignmentMode.CENTER_V:
356
+ y0 = ref_bbox.y0 + (ref_bbox.height - height) / 2
357
+ elif mode == AlignmentMode.AXIS_X:
358
+ # Align bottom edges (x-axis position)
359
+ y0 = ref_bbox.y0
360
+ elif mode == AlignmentMode.AXIS_Y:
361
+ # Align left edges (y-axis position)
362
+ x0 = ref_bbox.x0
363
+
364
+ return Bbox.from_bounds(x0, y0, width, height)
365
+
366
+
367
+ def _distribute_horizontal(
368
+ fig: RecordingFigure,
369
+ panels: List[Tuple[int, int]],
370
+ bboxes: List[Bbox],
371
+ spacing_mm: Optional[float],
372
+ ) -> None:
373
+ """Distribute panels horizontally.
374
+
375
+ Parameters
376
+ ----------
377
+ fig : RecordingFigure
378
+ The figure.
379
+ panels : list of tuple
380
+ Panel positions (sorted).
381
+ bboxes : list of Bbox
382
+ Current bounding boxes.
383
+ spacing_mm : float or None
384
+ Fixed spacing in mm, or None for even distribution.
385
+ """
386
+ if spacing_mm is not None:
387
+ from .._utils._units import mm_to_inch
388
+
389
+ fig_width = fig.fig.get_figwidth()
390
+ spacing = mm_to_inch(spacing_mm) / fig_width
391
+ else:
392
+ total_width = sum(b.width for b in bboxes)
393
+ available = bboxes[-1].x1 - bboxes[0].x0
394
+ spacing = (
395
+ (available - total_width) / (len(panels) - 1) if len(panels) > 1 else 0
396
+ )
397
+
398
+ x = bboxes[0].x0
399
+ for panel, bbox in zip(panels, bboxes):
400
+ ax = _get_mpl_axes(fig, panel)
401
+ if ax is not None:
402
+ new_bbox = Bbox.from_bounds(x, bbox.y0, bbox.width, bbox.height)
403
+ ax.set_position(new_bbox)
404
+ x += bbox.width + spacing
405
+
406
+
407
+ def _distribute_vertical(
408
+ fig: RecordingFigure,
409
+ panels: List[Tuple[int, int]],
410
+ bboxes: List[Bbox],
411
+ spacing_mm: Optional[float],
412
+ ) -> None:
413
+ """Distribute panels vertically.
414
+
415
+ Parameters
416
+ ----------
417
+ fig : RecordingFigure
418
+ The figure.
419
+ panels : list of tuple
420
+ Panel positions (sorted).
421
+ bboxes : list of Bbox
422
+ Current bounding boxes.
423
+ spacing_mm : float or None
424
+ Fixed spacing in mm, or None for even distribution.
425
+ """
426
+ if spacing_mm is not None:
427
+ from .._utils._units import mm_to_inch
428
+
429
+ fig_height = fig.fig.get_figheight()
430
+ spacing = mm_to_inch(spacing_mm) / fig_height
431
+ else:
432
+ total_height = sum(b.height for b in bboxes)
433
+ available = bboxes[-1].y1 - bboxes[0].y0
434
+ spacing = (
435
+ (available - total_height) / (len(panels) - 1) if len(panels) > 1 else 0
436
+ )
437
+
438
+ y = bboxes[0].y0
439
+ for panel, bbox in zip(panels, bboxes):
440
+ ax = _get_mpl_axes(fig, panel)
441
+ if ax is not None:
442
+ new_bbox = Bbox.from_bounds(bbox.x0, y, bbox.width, bbox.height)
443
+ ax.set_position(new_bbox)
444
+ y += bbox.height + spacing
445
+
446
+
447
+ __all__ = [
448
+ "AlignmentMode",
449
+ "align_panels",
450
+ "distribute_panels",
451
+ "smart_align",
452
+ ]
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Main composition logic for combining multiple figures."""
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Tuple, Union
7
+
8
+ from numpy.typing import NDArray
9
+
10
+ from .._recorder import FigureRecord
11
+ from .._serializer import load_recipe
12
+ from .._wrappers import RecordingAxes, RecordingFigure
13
+
14
+
15
+ def compose(
16
+ layout: Tuple[int, int],
17
+ sources: Dict[Tuple[int, int], Union[str, Path, FigureRecord, Tuple]],
18
+ **kwargs,
19
+ ) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
20
+ """Compose a new figure from multiple recipe sources.
21
+
22
+ Parameters
23
+ ----------
24
+ layout : tuple
25
+ (nrows, ncols) for the new composite figure.
26
+ sources : dict
27
+ Mapping of (row, col) -> source specification.
28
+ Source can be:
29
+ - str/Path: Recipe file path (uses first axes)
30
+ - FigureRecord: Direct record (uses first axes)
31
+ - Tuple[source, ax_key]: Specific axes from source
32
+
33
+ **kwargs
34
+ Additional arguments passed to subplots().
35
+
36
+ Returns
37
+ -------
38
+ fig : RecordingFigure
39
+ Composed figure.
40
+ axes : RecordingAxes or ndarray of RecordingAxes
41
+ Axes of the composed figure.
42
+
43
+ Examples
44
+ --------
45
+ >>> import figrecipe as fr
46
+ >>> fig, axes = fr.compose(
47
+ ... layout=(1, 2),
48
+ ... sources={
49
+ ... (0, 0): "experiment_a.yaml",
50
+ ... (0, 1): "experiment_b.yaml",
51
+ ... }
52
+ ... )
53
+ """
54
+ from .. import subplots
55
+
56
+ nrows, ncols = layout
57
+ fig, axes = subplots(nrows=nrows, ncols=ncols, **kwargs)
58
+
59
+ for (row, col), source_spec in sources.items():
60
+ source_record, ax_key = _parse_source_spec(source_spec)
61
+ ax_record = source_record.axes.get(ax_key)
62
+
63
+ if ax_record is None:
64
+ available = list(source_record.axes.keys())
65
+ raise ValueError(
66
+ f"Axes '{ax_key}' not found in source. Available: {available}"
67
+ )
68
+
69
+ target_ax = _get_axes_at(axes, row, col, nrows, ncols)
70
+ _replay_axes_record(target_ax, ax_record, fig.record, row, col)
71
+
72
+ return fig, axes
73
+
74
+
75
+ def _parse_source_spec(
76
+ spec: Union[str, Path, FigureRecord, Tuple],
77
+ ) -> Tuple[FigureRecord, str]:
78
+ """Parse source specification into (FigureRecord, ax_key).
79
+
80
+ Parameters
81
+ ----------
82
+ spec : various
83
+ Source specification.
84
+
85
+ Returns
86
+ -------
87
+ tuple
88
+ (FigureRecord, ax_key)
89
+ """
90
+ if isinstance(spec, (str, Path)):
91
+ return load_recipe(spec), "ax_0_0"
92
+ elif isinstance(spec, FigureRecord):
93
+ return spec, "ax_0_0"
94
+ elif isinstance(spec, tuple) and len(spec) == 2:
95
+ source, ax_key = spec
96
+ if isinstance(source, (str, Path)):
97
+ return load_recipe(source), ax_key
98
+ elif isinstance(source, FigureRecord):
99
+ return source, ax_key
100
+ raise TypeError(f"Invalid source in tuple: {type(source)}")
101
+ raise TypeError(f"Invalid source spec type: {type(spec)}")
102
+
103
+
104
+ def _get_axes_at(
105
+ axes: Union[RecordingAxes, NDArray],
106
+ row: int,
107
+ col: int,
108
+ nrows: int,
109
+ ncols: int,
110
+ ) -> RecordingAxes:
111
+ """Get axes at position, handling different array shapes.
112
+
113
+ Parameters
114
+ ----------
115
+ axes : RecordingAxes or ndarray
116
+ Axes object(s) from subplots.
117
+ row, col : int
118
+ Target position.
119
+ nrows, ncols : int
120
+ Grid dimensions.
121
+
122
+ Returns
123
+ -------
124
+ RecordingAxes
125
+ Axes at the specified position.
126
+ """
127
+ if nrows == 1 and ncols == 1:
128
+ return axes
129
+ elif nrows == 1:
130
+ return axes[col]
131
+ elif ncols == 1:
132
+ return axes[row]
133
+ else:
134
+ return axes[row, col]
135
+
136
+
137
+ def _replay_axes_record(
138
+ target_ax: RecordingAxes,
139
+ ax_record,
140
+ fig_record: FigureRecord,
141
+ row: int,
142
+ col: int,
143
+ ) -> None:
144
+ """Replay all calls from ax_record onto target axes.
145
+
146
+ Parameters
147
+ ----------
148
+ target_ax : RecordingAxes
149
+ Target axes to replay onto.
150
+ ax_record : AxesRecord
151
+ Source axes record with calls.
152
+ fig_record : FigureRecord
153
+ Figure record to update.
154
+ row, col : int
155
+ Target position for recording.
156
+ """
157
+ from .._reproducer._core import _replay_call
158
+
159
+ mpl_ax = target_ax._ax if hasattr(target_ax, "_ax") else target_ax
160
+ result_cache: Dict[str, Any] = {}
161
+
162
+ # Replay plotting calls
163
+ for call in ax_record.calls:
164
+ result = _replay_call(mpl_ax, call, result_cache)
165
+ if result is not None:
166
+ result_cache[call.id] = result
167
+
168
+ # Replay decoration calls
169
+ for call in ax_record.decorations:
170
+ result = _replay_call(mpl_ax, call, result_cache)
171
+ if result is not None:
172
+ result_cache[call.id] = result
173
+
174
+ # Update figure record with imported axes
175
+ ax_key = f"ax_{row}_{col}"
176
+ fig_record.axes[ax_key] = ax_record
177
+
178
+
179
+ __all__ = ["compose"]