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.
- figrecipe/__init__.py +361 -93
- figrecipe/_dev/__init__.py +120 -0
- figrecipe/_dev/demo_plotters/__init__.py +195 -0
- figrecipe/_dev/demo_plotters/plot_acorr.py +24 -0
- figrecipe/_dev/demo_plotters/plot_angle_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_bar.py +25 -0
- figrecipe/_dev/demo_plotters/plot_barbs.py +30 -0
- figrecipe/_dev/demo_plotters/plot_barh.py +25 -0
- figrecipe/_dev/demo_plotters/plot_boxplot.py +24 -0
- figrecipe/_dev/demo_plotters/plot_cohere.py +29 -0
- figrecipe/_dev/demo_plotters/plot_contour.py +30 -0
- figrecipe/_dev/demo_plotters/plot_contourf.py +29 -0
- figrecipe/_dev/demo_plotters/plot_csd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_ecdf.py +24 -0
- figrecipe/_dev/demo_plotters/plot_errorbar.py +28 -0
- figrecipe/_dev/demo_plotters/plot_eventplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_fill.py +29 -0
- figrecipe/_dev/demo_plotters/plot_fill_between.py +30 -0
- figrecipe/_dev/demo_plotters/plot_fill_betweenx.py +28 -0
- figrecipe/_dev/demo_plotters/plot_hexbin.py +25 -0
- figrecipe/_dev/demo_plotters/plot_hist.py +24 -0
- figrecipe/_dev/demo_plotters/plot_hist2d.py +25 -0
- figrecipe/_dev/demo_plotters/plot_imshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_loglog.py +27 -0
- figrecipe/_dev/demo_plotters/plot_magnitude_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_matshow.py +23 -0
- figrecipe/_dev/demo_plotters/plot_pcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_pcolormesh.py +29 -0
- figrecipe/_dev/demo_plotters/plot_phase_spectrum.py +28 -0
- figrecipe/_dev/demo_plotters/plot_pie.py +23 -0
- figrecipe/_dev/demo_plotters/plot_plot.py +27 -0
- figrecipe/_dev/demo_plotters/plot_psd.py +29 -0
- figrecipe/_dev/demo_plotters/plot_quiver.py +30 -0
- figrecipe/_dev/demo_plotters/plot_scatter.py +24 -0
- figrecipe/_dev/demo_plotters/plot_semilogx.py +27 -0
- figrecipe/_dev/demo_plotters/plot_semilogy.py +27 -0
- figrecipe/_dev/demo_plotters/plot_specgram.py +30 -0
- figrecipe/_dev/demo_plotters/plot_spy.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stackplot.py +29 -0
- figrecipe/_dev/demo_plotters/plot_stairs.py +27 -0
- figrecipe/_dev/demo_plotters/plot_stem.py +27 -0
- figrecipe/_dev/demo_plotters/plot_step.py +27 -0
- figrecipe/_dev/demo_plotters/plot_streamplot.py +30 -0
- figrecipe/_dev/demo_plotters/plot_tricontour.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tricontourf.py +28 -0
- figrecipe/_dev/demo_plotters/plot_tripcolor.py +29 -0
- figrecipe/_dev/demo_plotters/plot_triplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_violinplot.py +25 -0
- figrecipe/_dev/demo_plotters/plot_xcorr.py +25 -0
- figrecipe/_editor/__init__.py +230 -0
- figrecipe/_editor/_bbox.py +978 -0
- figrecipe/_editor/_flask_app.py +1229 -0
- figrecipe/_editor/_hitmap.py +937 -0
- figrecipe/_editor/_overrides.py +318 -0
- figrecipe/_editor/_renderer.py +349 -0
- figrecipe/_editor/_templates/__init__.py +75 -0
- figrecipe/_editor/_templates/_html.py +406 -0
- figrecipe/_editor/_templates/_scripts.py +2778 -0
- figrecipe/_editor/_templates/_styles.py +1326 -0
- figrecipe/_params/_DECORATION_METHODS.py +27 -0
- figrecipe/_params/_PLOTTING_METHODS.py +58 -0
- figrecipe/_params/__init__.py +9 -0
- figrecipe/_recorder.py +126 -73
- figrecipe/_reproducer.py +658 -41
- figrecipe/_seaborn.py +14 -9
- figrecipe/_serializer.py +2 -2
- figrecipe/_signatures/README.md +68 -0
- figrecipe/_signatures/__init__.py +12 -2
- figrecipe/_signatures/_loader.py +515 -56
- figrecipe/_utils/__init__.py +6 -4
- figrecipe/_utils/_crop.py +10 -4
- figrecipe/_utils/_image_diff.py +37 -33
- figrecipe/_utils/_numpy_io.py +0 -1
- figrecipe/_utils/_units.py +11 -3
- figrecipe/_validator.py +12 -3
- figrecipe/_wrappers/_axes.py +860 -46
- figrecipe/_wrappers/_figure.py +115 -18
- figrecipe/plt.py +0 -1
- figrecipe/pyplot.py +2 -1
- figrecipe/styles/__init__.py +9 -10
- figrecipe/styles/_style_applier.py +332 -28
- figrecipe/styles/_style_loader.py +172 -44
- figrecipe/styles/presets/MATPLOTLIB.yaml +94 -0
- figrecipe/styles/presets/SCITEX.yaml +176 -0
- figrecipe-0.6.0.dist-info/METADATA +394 -0
- figrecipe-0.6.0.dist-info/RECORD +90 -0
- figrecipe-0.5.0.dist-info/METADATA +0 -336
- figrecipe-0.5.0.dist-info/RECORD +0 -26
- {figrecipe-0.5.0.dist-info → figrecipe-0.6.0.dist-info}/WHEEL +0 -0
- {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
|
+
]
|