figrecipe 0.5.0__py3-none-any.whl → 0.6.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 (90) hide show
  1. figrecipe/__init__.py +361 -93
  2. figrecipe/_dev/__init__.py +120 -0
  3. figrecipe/_dev/demo_plotters/__init__.py +195 -0
  4. figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
  5. figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
  6. figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
  7. figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
  8. figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
  9. figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
  10. figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
  11. figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
  12. figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
  13. figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
  14. figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
  15. figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
  16. figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
  17. figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
  18. figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
  19. figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
  20. figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
  21. figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
  22. figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
  23. figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
  24. figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
  25. figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
  26. figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
  27. figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
  28. figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
  29. figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
  30. figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
  31. figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
  32. figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
  33. figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
  34. figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
  35. figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
  36. figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
  37. figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
  38. figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
  39. figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
  40. figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
  41. figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
  42. figrecipe/_dev/demo_plotters/plot_step.py +27 -0
  43. figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
  44. figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
  45. figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
  46. figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
  47. figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
  48. figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
  49. figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
  50. figrecipe/_editor/__init__.py +230 -0
  51. figrecipe/_editor/_bbox.py +978 -0
  52. figrecipe/_editor/_flask_app.py +1229 -0
  53. figrecipe/_editor/_hitmap.py +937 -0
  54. figrecipe/_editor/_overrides.py +318 -0
  55. figrecipe/_editor/_renderer.py +349 -0
  56. figrecipe/_editor/_templates/__init__.py +75 -0
  57. figrecipe/_editor/_templates/_html.py +406 -0
  58. figrecipe/_editor/_templates/_scripts.py +2778 -0
  59. figrecipe/_editor/_templates/_styles.py +1326 -0
  60. figrecipe/_params/_DECORATION_METHODS.py +27 -0
  61. figrecipe/_params/_PLOTTING_METHODS.py +58 -0
  62. figrecipe/_params/__init__.py +9 -0
  63. figrecipe/_recorder.py +126 -73
  64. figrecipe/_reproducer.py +658 -41
  65. figrecipe/_seaborn.py +14 -9
  66. figrecipe/_serializer.py +2 -2
  67. figrecipe/_signatures/README.md +68 -0
  68. figrecipe/_signatures/__init__.py +12 -2
  69. figrecipe/_signatures/_loader.py +515 -56
  70. figrecipe/_utils/__init__.py +6 -4
  71. figrecipe/_utils/_crop.py +10 -4
  72. figrecipe/_utils/_image_diff.py +37 -33
  73. figrecipe/_utils/_numpy_io.py +0 -1
  74. figrecipe/_utils/_units.py +11 -3
  75. figrecipe/_validator.py +12 -3
  76. figrecipe/_wrappers/_axes.py +860 -46
  77. figrecipe/_wrappers/_figure.py +115 -18
  78. figrecipe/plt.py +0 -1
  79. figrecipe/pyplot.py +2 -1
  80. figrecipe/styles/__init__.py +9 -10
  81. figrecipe/styles/_style_applier.py +332 -28
  82. figrecipe/styles/_style_loader.py +172 -44
  83. figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
  84. figrecipe/styles/presets/SCITEX.yaml +176 -0
  85. figrecipe-0.6.0.dist-info/METADATA +394 -0
  86. figrecipe-0.6.0.dist-info/RECORD +90 -0
  87. figrecipe-0.5.0.dist-info/METADATA +0 -336
  88. figrecipe-0.5.0.dist-info/RECORD +0 -26
  89. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
  90. {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Style override management with separation of base and manual styles.
5
+
6
+ This module handles the layered style system:
7
+ 1. Base style (from preset like SCITEX)
8
+ 2. Programmatic style (from code, e.g., fr.load_style())
9
+ 3. Manual overrides (from GUI editor)
10
+
11
+ Manual overrides are stored separately with timestamps, allowing:
12
+ - Restoration to original (programmatic) style
13
+ - Tracking when manual edits were made
14
+ - Comparison between original and edited versions
15
+ """
16
+
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Optional
22
+
23
+
24
+ @dataclass
25
+ class StyleOverrides:
26
+ """
27
+ Manages layered style overrides with timestamp tracking.
28
+
29
+ Attributes
30
+ ----------
31
+ base_style : dict
32
+ Original style from preset (e.g., SCITEX values).
33
+ programmatic_style : dict
34
+ Style set programmatically via code.
35
+ manual_overrides : dict
36
+ Overrides from GUI editor.
37
+ call_overrides : dict
38
+ Call parameter overrides {call_id: {param: value}}.
39
+ base_timestamp : str
40
+ When base style was set (ISO format).
41
+ manual_timestamp : str
42
+ When manual overrides were last modified.
43
+ """
44
+
45
+ base_style: Dict[str, Any] = field(default_factory=dict)
46
+ programmatic_style: Dict[str, Any] = field(default_factory=dict)
47
+ manual_overrides: Dict[str, Any] = field(default_factory=dict)
48
+ call_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict)
49
+ base_timestamp: Optional[str] = None
50
+ manual_timestamp: Optional[str] = None
51
+
52
+ def get_effective_style(self) -> Dict[str, Any]:
53
+ """
54
+ Get the final effective style by merging all layers.
55
+
56
+ Priority: base < programmatic < manual
57
+
58
+ Returns
59
+ -------
60
+ dict
61
+ Merged style dictionary.
62
+ """
63
+ result = {}
64
+ result.update(self.base_style)
65
+ result.update(self.programmatic_style)
66
+ result.update(self.manual_overrides)
67
+ return result
68
+
69
+ def get_original_style(self) -> Dict[str, Any]:
70
+ """
71
+ Get style without manual overrides (for "Restore" function).
72
+
73
+ Returns
74
+ -------
75
+ dict
76
+ Style with only base + programmatic layers.
77
+ """
78
+ result = {}
79
+ result.update(self.base_style)
80
+ result.update(self.programmatic_style)
81
+ return result
82
+
83
+ def set_manual_override(self, key: str, value: Any) -> None:
84
+ """
85
+ Set a single manual override.
86
+
87
+ Parameters
88
+ ----------
89
+ key : str
90
+ Style property key (e.g., 'axes_width_mm').
91
+ value : Any
92
+ Property value.
93
+ """
94
+ self.manual_overrides[key] = value
95
+ self.manual_timestamp = datetime.now().isoformat()
96
+
97
+ def update_manual_overrides(self, overrides: Dict[str, Any]) -> None:
98
+ """
99
+ Update multiple manual overrides at once.
100
+
101
+ Parameters
102
+ ----------
103
+ overrides : dict
104
+ Dictionary of overrides to apply.
105
+ """
106
+ self.manual_overrides.update(overrides)
107
+ self.manual_timestamp = datetime.now().isoformat()
108
+
109
+ def clear_manual_overrides(self, clear_call_overrides: bool = True) -> None:
110
+ """Clear all manual overrides (restore to original).
111
+
112
+ Parameters
113
+ ----------
114
+ clear_call_overrides : bool
115
+ Also clear call parameter overrides (default True).
116
+ """
117
+ self.manual_overrides = {}
118
+ if clear_call_overrides:
119
+ self.call_overrides = {}
120
+ self.manual_timestamp = None
121
+
122
+ def has_manual_overrides(self) -> bool:
123
+ """Check if any manual overrides exist."""
124
+ return len(self.manual_overrides) > 0
125
+
126
+ def get_diff(self) -> Dict[str, Dict[str, Any]]:
127
+ """
128
+ Get differences between original and manual overrides.
129
+
130
+ Returns
131
+ -------
132
+ dict
133
+ Dictionary showing changes: {key: {'original': val, 'manual': val}}
134
+ """
135
+ original = self.get_original_style()
136
+ diff = {}
137
+
138
+ for key, manual_val in self.manual_overrides.items():
139
+ original_val = original.get(key)
140
+ if original_val != manual_val:
141
+ diff[key] = {
142
+ "original": original_val,
143
+ "manual": manual_val,
144
+ }
145
+
146
+ return diff
147
+
148
+ def set_call_override(self, call_id: str, param: str, value: Any) -> None:
149
+ """
150
+ Set a call parameter override.
151
+
152
+ Parameters
153
+ ----------
154
+ call_id : str
155
+ ID of the call (e.g., 'bp1', 'vp1').
156
+ param : str
157
+ Parameter name.
158
+ value : Any
159
+ Parameter value.
160
+ """
161
+ if call_id not in self.call_overrides:
162
+ self.call_overrides[call_id] = {}
163
+ self.call_overrides[call_id][param] = value
164
+ self.manual_timestamp = datetime.now().isoformat()
165
+
166
+ def get_call_overrides(self, call_id: str) -> Dict[str, Any]:
167
+ """Get all overrides for a specific call."""
168
+ return self.call_overrides.get(call_id, {})
169
+
170
+ def has_call_overrides(self) -> bool:
171
+ """Check if any call overrides exist."""
172
+ return len(self.call_overrides) > 0
173
+
174
+ def to_dict(self) -> Dict[str, Any]:
175
+ """Convert to dictionary for serialization."""
176
+ return {
177
+ "base_style": self.base_style,
178
+ "programmatic_style": self.programmatic_style,
179
+ "manual_overrides": self.manual_overrides,
180
+ "call_overrides": self.call_overrides,
181
+ "base_timestamp": self.base_timestamp,
182
+ "manual_timestamp": self.manual_timestamp,
183
+ }
184
+
185
+ @classmethod
186
+ def from_dict(cls, data: Dict[str, Any]) -> "StyleOverrides":
187
+ """Create from dictionary."""
188
+ return cls(
189
+ base_style=data.get("base_style", {}),
190
+ programmatic_style=data.get("programmatic_style", {}),
191
+ manual_overrides=data.get("manual_overrides", {}),
192
+ call_overrides=data.get("call_overrides", {}),
193
+ base_timestamp=data.get("base_timestamp"),
194
+ manual_timestamp=data.get("manual_timestamp"),
195
+ )
196
+
197
+
198
+ def get_overrides_path(recipe_path: Path) -> Path:
199
+ """
200
+ Get the path for storing overrides alongside a recipe.
201
+
202
+ Parameters
203
+ ----------
204
+ recipe_path : Path
205
+ Path to the recipe file.
206
+
207
+ Returns
208
+ -------
209
+ Path
210
+ Path to the overrides file.
211
+
212
+ Examples
213
+ --------
214
+ >>> get_overrides_path(Path('figure.yaml'))
215
+ Path('figure.overrides.json')
216
+ """
217
+ return recipe_path.with_suffix(".overrides.json")
218
+
219
+
220
+ def save_overrides(
221
+ overrides: StyleOverrides,
222
+ path: Path,
223
+ ) -> Path:
224
+ """
225
+ Save style overrides to JSON file.
226
+
227
+ Parameters
228
+ ----------
229
+ overrides : StyleOverrides
230
+ Overrides to save.
231
+ path : Path
232
+ Path to save to (or recipe path to derive from).
233
+
234
+ Returns
235
+ -------
236
+ Path
237
+ Path where overrides were saved.
238
+ """
239
+ # If path is a recipe, derive overrides path
240
+ if path.suffix in (".yaml", ".yml"):
241
+ path = get_overrides_path(path)
242
+
243
+ data = overrides.to_dict()
244
+ data["_version"] = "1.0"
245
+ data["_saved_at"] = datetime.now().isoformat()
246
+
247
+ with open(path, "w") as f:
248
+ json.dump(data, f, indent=2)
249
+
250
+ return path
251
+
252
+
253
+ def load_overrides(path: Path) -> Optional[StyleOverrides]:
254
+ """
255
+ Load style overrides from JSON file.
256
+
257
+ Parameters
258
+ ----------
259
+ path : Path
260
+ Path to overrides file or recipe file.
261
+
262
+ Returns
263
+ -------
264
+ StyleOverrides or None
265
+ Loaded overrides, or None if file doesn't exist.
266
+ """
267
+ # If path is a recipe, derive overrides path
268
+ if path.suffix in (".yaml", ".yml"):
269
+ path = get_overrides_path(path)
270
+
271
+ if not path.exists():
272
+ return None
273
+
274
+ with open(path) as f:
275
+ data = json.load(f)
276
+
277
+ # Remove metadata
278
+ data.pop("_version", None)
279
+ data.pop("_saved_at", None)
280
+
281
+ return StyleOverrides.from_dict(data)
282
+
283
+
284
+ def create_overrides_from_style(
285
+ base_style: Optional[Dict[str, Any]] = None,
286
+ programmatic_style: Optional[Dict[str, Any]] = None,
287
+ ) -> StyleOverrides:
288
+ """
289
+ Create StyleOverrides from existing style configuration.
290
+
291
+ Parameters
292
+ ----------
293
+ base_style : dict, optional
294
+ Base style from preset.
295
+ programmatic_style : dict, optional
296
+ Style set via code.
297
+
298
+ Returns
299
+ -------
300
+ StyleOverrides
301
+ New overrides object.
302
+ """
303
+ return StyleOverrides(
304
+ base_style=base_style or {},
305
+ programmatic_style=programmatic_style or {},
306
+ manual_overrides={},
307
+ base_timestamp=datetime.now().isoformat(),
308
+ manual_timestamp=None,
309
+ )
310
+
311
+
312
+ __all__ = [
313
+ "StyleOverrides",
314
+ "get_overrides_path",
315
+ "save_overrides",
316
+ "load_overrides",
317
+ "create_overrides_from_style",
318
+ ]
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Preview rendering with style overrides.
5
+
6
+ This module renders figure previews with user-specified style overrides
7
+ applied, enabling real-time preview updates in the GUI editor.
8
+ """
9
+
10
+ import io
11
+ from typing import Any, Dict, Optional, Tuple
12
+
13
+ from matplotlib.figure import Figure
14
+
15
+ from .._wrappers import RecordingFigure
16
+ from ._bbox import extract_bboxes
17
+
18
+
19
+ def render_preview(
20
+ fig: RecordingFigure,
21
+ overrides: Optional[Dict[str, Any]] = None,
22
+ dpi: int = 150,
23
+ dark_mode: bool = False,
24
+ ) -> Tuple[bytes, Dict[str, Any], Tuple[int, int]]:
25
+ """
26
+ Render figure preview with style overrides applied.
27
+
28
+ Parameters
29
+ ----------
30
+ fig : RecordingFigure
31
+ Figure to render.
32
+ overrides : dict, optional
33
+ Style overrides to apply.
34
+ dpi : int, optional
35
+ Render resolution (default: 150).
36
+ dark_mode : bool, optional
37
+ Whether to render in dark mode (default: False).
38
+
39
+ Returns
40
+ -------
41
+ png_bytes : bytes
42
+ PNG image data.
43
+ bboxes : dict
44
+ Element bounding boxes.
45
+ img_size : tuple
46
+ (width, height) in pixels.
47
+ """
48
+ # Get underlying matplotlib figure
49
+ mpl_fig = fig.fig if hasattr(fig, "fig") else fig
50
+
51
+ # Apply style overrides
52
+ if overrides:
53
+ _apply_overrides(mpl_fig, overrides)
54
+
55
+ # Apply dark mode if requested
56
+ if dark_mode:
57
+ _apply_dark_mode(mpl_fig)
58
+
59
+ # Render to buffer first
60
+ buf = io.BytesIO()
61
+ mpl_fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
62
+ buf.seek(0)
63
+ png_bytes = buf.read()
64
+
65
+ # Get image dimensions
66
+ from PIL import Image
67
+
68
+ buf.seek(0)
69
+ img = Image.open(buf)
70
+ img_width, img_height = img.size
71
+
72
+ # Set figure DPI to match render DPI and force canvas redraw for accurate bbox extraction
73
+ original_dpi = mpl_fig.dpi
74
+ mpl_fig.set_dpi(dpi)
75
+ mpl_fig.canvas.draw()
76
+
77
+ # Extract bounding boxes (with figure DPI matching render DPI)
78
+ bboxes = extract_bboxes(mpl_fig, img_width, img_height)
79
+
80
+ # Restore original DPI
81
+ mpl_fig.set_dpi(original_dpi)
82
+
83
+ return png_bytes, bboxes, (img_width, img_height)
84
+
85
+
86
+ def render_to_base64(
87
+ fig: RecordingFigure,
88
+ overrides: Optional[Dict[str, Any]] = None,
89
+ dpi: int = 150,
90
+ dark_mode: bool = False,
91
+ ) -> Tuple[str, Dict[str, Any], Tuple[int, int]]:
92
+ """
93
+ Render figure preview as base64 string.
94
+
95
+ Parameters
96
+ ----------
97
+ fig : RecordingFigure
98
+ Figure to render.
99
+ overrides : dict, optional
100
+ Style overrides to apply.
101
+ dpi : int, optional
102
+ Render resolution (default: 150).
103
+ dark_mode : bool, optional
104
+ Whether to render in dark mode (default: False).
105
+
106
+ Returns
107
+ -------
108
+ base64_str : str
109
+ Base64-encoded PNG image.
110
+ bboxes : dict
111
+ Element bounding boxes.
112
+ img_size : tuple
113
+ (width, height) in pixels.
114
+ """
115
+ import base64
116
+
117
+ png_bytes, bboxes, img_size = render_preview(fig, overrides, dpi, dark_mode)
118
+ base64_str = base64.b64encode(png_bytes).decode("utf-8")
119
+
120
+ return base64_str, bboxes, img_size
121
+
122
+
123
+ def _apply_overrides(fig: Figure, overrides: Dict[str, Any]) -> None:
124
+ """
125
+ Apply style overrides to figure.
126
+
127
+ Parameters
128
+ ----------
129
+ fig : Figure
130
+ Matplotlib figure.
131
+ overrides : dict
132
+ Style overrides with keys like:
133
+ - axes_width_mm, axes_height_mm
134
+ - fonts_axis_label_pt, fonts_tick_label_pt
135
+ - lines_trace_mm
136
+ - etc.
137
+ """
138
+ from ..styles._style_applier import apply_style_mm
139
+
140
+ axes_list = fig.get_axes()
141
+
142
+ for ax in axes_list:
143
+ # Apply mm-based styling
144
+ apply_style_mm(ax, overrides)
145
+
146
+ # Apply specific overrides that aren't handled by apply_style_mm
147
+ # YAML-compatible keys are canonical, legacy keys supported for backwards compatibility
148
+
149
+ # Font sizes (YAML: fonts_axis_label_pt, legacy: axis_font_size_pt)
150
+ axis_fs = overrides.get(
151
+ "fonts_axis_label_pt", overrides.get("axis_font_size_pt")
152
+ )
153
+ if axis_fs is not None:
154
+ ax.xaxis.label.set_fontsize(axis_fs)
155
+ ax.yaxis.label.set_fontsize(axis_fs)
156
+
157
+ tick_fs = overrides.get(
158
+ "fonts_tick_label_pt", overrides.get("tick_font_size_pt")
159
+ )
160
+ if tick_fs is not None:
161
+ ax.tick_params(labelsize=tick_fs)
162
+
163
+ title_fs = overrides.get("fonts_title_pt", overrides.get("title_font_size_pt"))
164
+ if title_fs is not None:
165
+ ax.title.set_fontsize(title_fs)
166
+
167
+ family = overrides.get("fonts_family", overrides.get("font_family"))
168
+ if family is not None:
169
+ ax.xaxis.label.set_fontfamily(family)
170
+ ax.yaxis.label.set_fontfamily(family)
171
+ ax.title.set_fontfamily(family)
172
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
173
+ label.set_fontfamily(family)
174
+
175
+ # Ticks (YAML: ticks_direction, legacy: tick_direction)
176
+ tick_dir = overrides.get("ticks_direction", overrides.get("tick_direction"))
177
+ if tick_dir is not None and tick_dir in ("in", "out", "inout"):
178
+ ax.tick_params(direction=tick_dir)
179
+
180
+ tick_len = overrides.get("ticks_length_mm", overrides.get("tick_length_mm"))
181
+ if tick_len is not None:
182
+ from .._utils._units import mm_to_pt
183
+
184
+ length = mm_to_pt(tick_len)
185
+ ax.tick_params(length=length)
186
+
187
+ # Grid (YAML: behavior_grid, legacy: grid)
188
+ grid_value = overrides.get("behavior_grid", overrides.get("grid"))
189
+ if grid_value is not None:
190
+ if grid_value:
191
+ ax.grid(True, alpha=0.3)
192
+ else:
193
+ ax.grid(False)
194
+
195
+ # Spines (YAML: behavior_hide_top_spine, legacy: hide_top_spine)
196
+ hide_top = overrides.get(
197
+ "behavior_hide_top_spine", overrides.get("hide_top_spine")
198
+ )
199
+ if hide_top is not None:
200
+ ax.spines["top"].set_visible(not hide_top)
201
+
202
+ hide_right = overrides.get(
203
+ "behavior_hide_right_spine", overrides.get("hide_right_spine")
204
+ )
205
+ if hide_right is not None:
206
+ ax.spines["right"].set_visible(not hide_right)
207
+
208
+ # Legend
209
+ legend = ax.get_legend()
210
+ if legend is not None:
211
+ if "legend_frameon" in overrides:
212
+ legend.set_frame_on(overrides["legend_frameon"])
213
+
214
+ if "legend_alpha" in overrides:
215
+ frame = legend.get_frame()
216
+ fc = frame.get_facecolor()
217
+ frame.set_facecolor((*fc[:3], overrides["legend_alpha"]))
218
+
219
+ # Line widths (YAML: lines_trace_mm, legacy: trace_thickness_mm)
220
+ trace_mm = overrides.get("lines_trace_mm", overrides.get("trace_thickness_mm"))
221
+ if trace_mm is not None:
222
+ from .._utils._units import mm_to_pt
223
+
224
+ lw = mm_to_pt(trace_mm)
225
+ for line in ax.get_lines():
226
+ line.set_linewidth(lw)
227
+
228
+ # Marker sizes (YAML: markers_scatter_mm, legacy: marker_size_mm)
229
+ # Only apply to PathCollection (scatter), not PolyCollection (violin/fill)
230
+ scatter_mm = overrides.get(
231
+ "markers_scatter_mm",
232
+ overrides.get("markers_size_mm", overrides.get("marker_size_mm")),
233
+ )
234
+ if scatter_mm is not None:
235
+ from matplotlib.collections import PathCollection
236
+
237
+ from .._utils._units import mm_to_scatter_size
238
+
239
+ size = mm_to_scatter_size(scatter_mm)
240
+ for coll in ax.collections:
241
+ # Only apply to scatter plots (PathCollection), not violin/fill (PolyCollection)
242
+ if isinstance(coll, PathCollection):
243
+ try:
244
+ coll.set_sizes([size])
245
+ except Exception:
246
+ pass
247
+
248
+
249
+ def _apply_dark_mode(fig: Figure) -> None:
250
+ """
251
+ Apply dark mode colors to figure.
252
+
253
+ Parameters
254
+ ----------
255
+ fig : Figure
256
+ Matplotlib figure.
257
+ """
258
+ # Dark theme colors
259
+ bg_color = "#1a1a1a"
260
+ text_color = "#e8e8e8"
261
+
262
+ # Figure background
263
+ fig.patch.set_facecolor(bg_color)
264
+
265
+ # Figure-level text elements (suptitle, supxlabel, supylabel)
266
+ if hasattr(fig, "_suptitle") and fig._suptitle is not None:
267
+ fig._suptitle.set_color(text_color)
268
+ if hasattr(fig, "_supxlabel") and fig._supxlabel is not None:
269
+ fig._supxlabel.set_color(text_color)
270
+ if hasattr(fig, "_supylabel") and fig._supylabel is not None:
271
+ fig._supylabel.set_color(text_color)
272
+
273
+ for ax in fig.get_axes():
274
+ # Axes background
275
+ ax.set_facecolor(bg_color)
276
+
277
+ # Text colors
278
+ ax.xaxis.label.set_color(text_color)
279
+ ax.yaxis.label.set_color(text_color)
280
+ ax.title.set_color(text_color)
281
+
282
+ # Tick labels
283
+ ax.tick_params(colors=text_color)
284
+
285
+ # Spines
286
+ for spine in ax.spines.values():
287
+ spine.set_color(text_color)
288
+
289
+ # Grid
290
+ ax.tick_params(color=text_color)
291
+
292
+ # Legend
293
+ legend = ax.get_legend()
294
+ if legend is not None:
295
+ frame = legend.get_frame()
296
+ frame.set_facecolor(bg_color)
297
+ frame.set_edgecolor(text_color)
298
+ for text in legend.get_texts():
299
+ text.set_color(text_color)
300
+
301
+
302
+ def render_download(
303
+ fig: RecordingFigure,
304
+ fmt: str = "png",
305
+ dpi: int = 300,
306
+ overrides: Optional[Dict[str, Any]] = None,
307
+ dark_mode: bool = False,
308
+ ) -> bytes:
309
+ """
310
+ Render figure for download in specified format.
311
+
312
+ Parameters
313
+ ----------
314
+ fig : RecordingFigure
315
+ Figure to render.
316
+ fmt : str
317
+ Output format: 'png', 'svg', 'pdf' (default: 'png').
318
+ dpi : int
319
+ Resolution for raster formats (default: 300).
320
+ overrides : dict, optional
321
+ Style overrides to apply.
322
+ dark_mode : bool, optional
323
+ Whether to render in dark mode.
324
+
325
+ Returns
326
+ -------
327
+ bytes
328
+ File content.
329
+ """
330
+ mpl_fig = fig.fig if hasattr(fig, "fig") else fig
331
+
332
+ if overrides:
333
+ _apply_overrides(mpl_fig, overrides)
334
+
335
+ if dark_mode:
336
+ _apply_dark_mode(mpl_fig)
337
+
338
+ buf = io.BytesIO()
339
+ mpl_fig.savefig(buf, format=fmt, dpi=dpi, bbox_inches="tight")
340
+ buf.seek(0)
341
+
342
+ return buf.read()
343
+
344
+
345
+ __all__ = [
346
+ "render_preview",
347
+ "render_to_base64",
348
+ "render_download",
349
+ ]