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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +2 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -20,6 +20,7 @@ from ._extract_axes import (
|
|
|
20
20
|
extract_patches,
|
|
21
21
|
)
|
|
22
22
|
from ._extract_text import (
|
|
23
|
+
extract_annotations,
|
|
23
24
|
extract_figure_text,
|
|
24
25
|
extract_legend,
|
|
25
26
|
extract_spines,
|
|
@@ -55,18 +56,35 @@ def extract_bboxes(
|
|
|
55
56
|
bboxes = {}
|
|
56
57
|
|
|
57
58
|
# Get renderer for bbox calculations
|
|
58
|
-
|
|
59
|
+
# Handle matplotlib's Done exception from _get_renderer (can occur with corrupted canvas state)
|
|
60
|
+
try:
|
|
61
|
+
fig.canvas.draw()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
# Matplotlib's Done exception or other draw issues - reset canvas and retry
|
|
64
|
+
if "Done" in str(type(e).__name__) or "Done" in str(e):
|
|
65
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
66
|
+
|
|
67
|
+
fig.set_canvas(FigureCanvasAgg(fig))
|
|
68
|
+
try:
|
|
69
|
+
fig.canvas.draw()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # Continue with potentially stale renderer
|
|
72
|
+
else:
|
|
73
|
+
raise
|
|
59
74
|
renderer = fig.canvas.get_renderer()
|
|
60
75
|
|
|
61
|
-
# Get
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
# Get figure bounds for coordinate transformation
|
|
77
|
+
# NOTE: We do NOT use bbox_inches='tight' in savefig, so use fixed figsize
|
|
78
|
+
fig_width_inches = fig.get_figwidth()
|
|
79
|
+
fig_height_inches = fig.get_figheight()
|
|
80
|
+
|
|
81
|
+
# Create a bbox representing the full figure (for compatibility with element extraction)
|
|
82
|
+
tight_bbox = Bbox.from_bounds(0, 0, fig_width_inches, fig_height_inches)
|
|
65
83
|
|
|
66
|
-
#
|
|
67
|
-
pad_inches = 0.
|
|
68
|
-
saved_width_inches =
|
|
69
|
-
saved_height_inches =
|
|
84
|
+
# No padding since we don't use bbox_inches='tight'
|
|
85
|
+
pad_inches = 0.0
|
|
86
|
+
saved_width_inches = fig_width_inches
|
|
87
|
+
saved_height_inches = fig_height_inches
|
|
70
88
|
|
|
71
89
|
# Calculate scale factors from saved image size to pixel size
|
|
72
90
|
scale_x = img_width / saved_width_inches if saved_width_inches > 0 else 1
|
|
@@ -104,6 +122,16 @@ def extract_bboxes(
|
|
|
104
122
|
bboxes,
|
|
105
123
|
)
|
|
106
124
|
|
|
125
|
+
# Compute panel bboxes using tight bbox from matplotlib
|
|
126
|
+
panel_bboxes = _compute_panel_bboxes(
|
|
127
|
+
bboxes,
|
|
128
|
+
len(fig.get_axes()),
|
|
129
|
+
fig=fig,
|
|
130
|
+
renderer=renderer,
|
|
131
|
+
img_width=img_width,
|
|
132
|
+
img_height=img_height,
|
|
133
|
+
)
|
|
134
|
+
|
|
107
135
|
# Add metadata
|
|
108
136
|
bboxes["_meta"] = {
|
|
109
137
|
"img_width": img_width,
|
|
@@ -111,6 +139,7 @@ def extract_bboxes(
|
|
|
111
139
|
"fig_width_inches": fig.get_figwidth(),
|
|
112
140
|
"fig_height_inches": fig.get_figheight(),
|
|
113
141
|
"dpi": fig.dpi,
|
|
142
|
+
"panel_bboxes": panel_bboxes,
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
return bboxes
|
|
@@ -249,6 +278,123 @@ def _extract_axes_bboxes(
|
|
|
249
278
|
saved_height_inches,
|
|
250
279
|
bboxes,
|
|
251
280
|
)
|
|
281
|
+
extract_annotations(
|
|
282
|
+
ax,
|
|
283
|
+
ax_idx,
|
|
284
|
+
fig,
|
|
285
|
+
renderer,
|
|
286
|
+
tight_bbox,
|
|
287
|
+
img_width,
|
|
288
|
+
img_height,
|
|
289
|
+
scale_x,
|
|
290
|
+
scale_y,
|
|
291
|
+
pad_inches,
|
|
292
|
+
saved_height_inches,
|
|
293
|
+
bboxes,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _compute_panel_bboxes(
|
|
298
|
+
bboxes: Dict[str, Any],
|
|
299
|
+
num_axes: int,
|
|
300
|
+
fig: Figure = None,
|
|
301
|
+
renderer=None,
|
|
302
|
+
img_width: int = None,
|
|
303
|
+
img_height: int = None,
|
|
304
|
+
) -> Dict[int, Dict[str, float]]:
|
|
305
|
+
"""
|
|
306
|
+
Compute tight bounding box for each panel (axis).
|
|
307
|
+
|
|
308
|
+
Uses matplotlib's ax.get_tightbbox() for reliable panel bounds that include
|
|
309
|
+
all decorators (title, labels, tick labels) without outliers.
|
|
310
|
+
|
|
311
|
+
Falls back to union of elements if tight bbox is unavailable.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
bboxes : dict
|
|
316
|
+
All extracted element bboxes.
|
|
317
|
+
num_axes : int
|
|
318
|
+
Number of axes in the figure.
|
|
319
|
+
fig : Figure, optional
|
|
320
|
+
Matplotlib figure (needed for tight bbox computation).
|
|
321
|
+
renderer : optional
|
|
322
|
+
Matplotlib renderer (needed for tight bbox computation).
|
|
323
|
+
img_width : int, optional
|
|
324
|
+
Image width in pixels.
|
|
325
|
+
img_height : int, optional
|
|
326
|
+
Image height in pixels.
|
|
327
|
+
|
|
328
|
+
Returns
|
|
329
|
+
-------
|
|
330
|
+
dict
|
|
331
|
+
Mapping from ax_index to panel bbox: {ax_index: {x, y, width, height}}
|
|
332
|
+
"""
|
|
333
|
+
panel_bboxes = {}
|
|
334
|
+
|
|
335
|
+
# Try to use tight bbox if figure is available
|
|
336
|
+
if fig is not None and renderer is not None and img_width and img_height:
|
|
337
|
+
fig_width_inches = fig.get_figwidth()
|
|
338
|
+
fig_height_inches = fig.get_figheight()
|
|
339
|
+
dpi = fig.dpi
|
|
340
|
+
|
|
341
|
+
for ax_idx, ax in enumerate(fig.get_axes()):
|
|
342
|
+
try:
|
|
343
|
+
tight = ax.get_tightbbox(renderer)
|
|
344
|
+
if tight is not None:
|
|
345
|
+
# Convert from display coords to image coords
|
|
346
|
+
# Display y=0 is at bottom, image y=0 is at top
|
|
347
|
+
x = tight.x0 * img_width / (fig_width_inches * dpi)
|
|
348
|
+
y = img_height - tight.y1 * img_height / (fig_height_inches * dpi)
|
|
349
|
+
width = tight.width * img_width / (fig_width_inches * dpi)
|
|
350
|
+
height = tight.height * img_height / (fig_height_inches * dpi)
|
|
351
|
+
panel_bboxes[ax_idx] = {
|
|
352
|
+
"x": x,
|
|
353
|
+
"y": y,
|
|
354
|
+
"width": width,
|
|
355
|
+
"height": height,
|
|
356
|
+
}
|
|
357
|
+
except Exception:
|
|
358
|
+
pass # Fall back to union method below
|
|
359
|
+
|
|
360
|
+
# Fall back to union method for any missing panels
|
|
361
|
+
for ax_idx in range(num_axes):
|
|
362
|
+
if ax_idx in panel_bboxes:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
min_x, min_y = float("inf"), float("inf")
|
|
366
|
+
max_x, max_y = float("-inf"), float("-inf")
|
|
367
|
+
|
|
368
|
+
for key, bbox in bboxes.items():
|
|
369
|
+
if key == "_meta" or not isinstance(bbox, dict):
|
|
370
|
+
continue
|
|
371
|
+
elem_ax_index = bbox.get("ax_index")
|
|
372
|
+
if elem_ax_index is None:
|
|
373
|
+
if key.startswith("ax") and "_" in key:
|
|
374
|
+
try:
|
|
375
|
+
elem_ax_index = int(key.split("_")[0][2:])
|
|
376
|
+
except (ValueError, IndexError):
|
|
377
|
+
continue
|
|
378
|
+
else:
|
|
379
|
+
continue
|
|
380
|
+
if elem_ax_index != ax_idx:
|
|
381
|
+
continue
|
|
382
|
+
x, y = bbox.get("x"), bbox.get("y")
|
|
383
|
+
w, h = bbox.get("width"), bbox.get("height")
|
|
384
|
+
if x is None or y is None or w is None or h is None:
|
|
385
|
+
continue
|
|
386
|
+
min_x, min_y = min(min_x, x), min(min_y, y)
|
|
387
|
+
max_x, max_y = max(max_x, x + w), max(max_y, y + h)
|
|
388
|
+
|
|
389
|
+
if min_x != float("inf"):
|
|
390
|
+
panel_bboxes[ax_idx] = {
|
|
391
|
+
"x": min_x,
|
|
392
|
+
"y": min_y,
|
|
393
|
+
"width": max_x - min_x,
|
|
394
|
+
"height": max_y - min_y,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return panel_bboxes
|
|
252
398
|
|
|
253
399
|
|
|
254
400
|
__all__ = ["extract_bboxes"]
|
|
@@ -332,11 +332,135 @@ def extract_figure_text(
|
|
|
332
332
|
pass
|
|
333
333
|
|
|
334
334
|
|
|
335
|
+
def extract_annotations(
|
|
336
|
+
ax,
|
|
337
|
+
ax_idx,
|
|
338
|
+
fig,
|
|
339
|
+
renderer,
|
|
340
|
+
tight_bbox,
|
|
341
|
+
img_width,
|
|
342
|
+
img_height,
|
|
343
|
+
scale_x,
|
|
344
|
+
scale_y,
|
|
345
|
+
pad_inches,
|
|
346
|
+
saved_height_inches,
|
|
347
|
+
bboxes,
|
|
348
|
+
):
|
|
349
|
+
"""Extract bboxes for decorative elements (ax.text, ax.annotate, panel labels).
|
|
350
|
+
|
|
351
|
+
These are cosmetic elements that can be repositioned without affecting data.
|
|
352
|
+
"""
|
|
353
|
+
import string
|
|
354
|
+
|
|
355
|
+
panel_label_chars = set(string.ascii_uppercase)
|
|
356
|
+
|
|
357
|
+
# Extract ax.texts (includes panel labels and other text annotations)
|
|
358
|
+
for i, text_obj in enumerate(ax.texts):
|
|
359
|
+
text_content = text_obj.get_text().strip()
|
|
360
|
+
if not text_content:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
bbox = get_text_bbox(
|
|
365
|
+
text_obj,
|
|
366
|
+
fig,
|
|
367
|
+
renderer,
|
|
368
|
+
tight_bbox,
|
|
369
|
+
img_width,
|
|
370
|
+
img_height,
|
|
371
|
+
scale_x,
|
|
372
|
+
scale_y,
|
|
373
|
+
pad_inches,
|
|
374
|
+
saved_height_inches,
|
|
375
|
+
)
|
|
376
|
+
if bbox:
|
|
377
|
+
# Determine if this is a panel label
|
|
378
|
+
is_panel_label = (
|
|
379
|
+
text_content in panel_label_chars
|
|
380
|
+
and text_obj.get_transform() == ax.transAxes
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Get position
|
|
384
|
+
pos = text_obj.get_position()
|
|
385
|
+
|
|
386
|
+
# Calculate axes-relative position (0-1)
|
|
387
|
+
if text_obj.get_transform() == ax.transAxes:
|
|
388
|
+
# Already in axes coordinates
|
|
389
|
+
rel_x, rel_y = pos[0], pos[1]
|
|
390
|
+
else:
|
|
391
|
+
# Convert from data coordinates to axes
|
|
392
|
+
xlim = ax.get_xlim()
|
|
393
|
+
ylim = ax.get_ylim()
|
|
394
|
+
rel_x = (
|
|
395
|
+
(pos[0] - xlim[0]) / (xlim[1] - xlim[0])
|
|
396
|
+
if xlim[1] != xlim[0]
|
|
397
|
+
else 0.5
|
|
398
|
+
)
|
|
399
|
+
rel_y = (
|
|
400
|
+
(pos[1] - ylim[0]) / (ylim[1] - ylim[0])
|
|
401
|
+
if ylim[1] != ylim[0]
|
|
402
|
+
else 0.5
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if is_panel_label:
|
|
406
|
+
key = f"ax{ax_idx}_panel_label"
|
|
407
|
+
elem_type = "panel_label"
|
|
408
|
+
else:
|
|
409
|
+
key = f"ax{ax_idx}_text_{i}"
|
|
410
|
+
elem_type = "text"
|
|
411
|
+
|
|
412
|
+
bboxes[key] = {
|
|
413
|
+
**bbox,
|
|
414
|
+
"type": elem_type,
|
|
415
|
+
"label": elem_type,
|
|
416
|
+
"ax_index": ax_idx,
|
|
417
|
+
"text": text_content,
|
|
418
|
+
"text_index": i,
|
|
419
|
+
"pos_x": pos[0],
|
|
420
|
+
"pos_y": pos[1],
|
|
421
|
+
"rel_x": rel_x,
|
|
422
|
+
"rel_y": rel_y,
|
|
423
|
+
}
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
# Extract ax.patches that are arrows (FancyArrowPatch)
|
|
428
|
+
from matplotlib.patches import FancyArrowPatch
|
|
429
|
+
|
|
430
|
+
for i, patch in enumerate(ax.patches):
|
|
431
|
+
if isinstance(patch, FancyArrowPatch):
|
|
432
|
+
try:
|
|
433
|
+
patch_bbox = patch.get_window_extent(renderer)
|
|
434
|
+
if patch_bbox is not None:
|
|
435
|
+
bbox = transform_bbox(
|
|
436
|
+
patch_bbox,
|
|
437
|
+
fig,
|
|
438
|
+
tight_bbox,
|
|
439
|
+
img_width,
|
|
440
|
+
img_height,
|
|
441
|
+
scale_x,
|
|
442
|
+
scale_y,
|
|
443
|
+
pad_inches,
|
|
444
|
+
saved_height_inches,
|
|
445
|
+
)
|
|
446
|
+
if bbox:
|
|
447
|
+
bboxes[f"ax{ax_idx}_arrow_{i}"] = {
|
|
448
|
+
**bbox,
|
|
449
|
+
"type": "arrow",
|
|
450
|
+
"label": "arrow",
|
|
451
|
+
"ax_index": ax_idx,
|
|
452
|
+
"arrow_index": i,
|
|
453
|
+
}
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
|
|
335
458
|
__all__ = [
|
|
336
459
|
"extract_text_elements",
|
|
337
460
|
"extract_legend",
|
|
338
461
|
"extract_spines",
|
|
339
462
|
"extract_figure_text",
|
|
463
|
+
"extract_annotations",
|
|
340
464
|
]
|
|
341
465
|
|
|
342
466
|
# EOF
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Call-specific override application for element properties.
|
|
5
|
+
|
|
6
|
+
This is the SINGLE SOURCE OF TRUTH for applying element-level changes.
|
|
7
|
+
Both initial render and re-render use this same function through apply_overrides().
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_call_overrides(
|
|
16
|
+
fig: Figure, call_overrides: Dict[str, Dict[str, Any]], record: Any
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Apply call-specific overrides to figure elements.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
fig : Figure
|
|
23
|
+
Matplotlib figure.
|
|
24
|
+
call_overrides : dict
|
|
25
|
+
Mapping from call_id to {param: value} overrides.
|
|
26
|
+
record : FigureRecord
|
|
27
|
+
Recording record to find call metadata.
|
|
28
|
+
"""
|
|
29
|
+
from matplotlib.patches import Wedge
|
|
30
|
+
|
|
31
|
+
axes_list = fig.get_axes()
|
|
32
|
+
|
|
33
|
+
# Build mapping from ax_key to axes index
|
|
34
|
+
# ax_keys are in format "ax_{row}_{col}", need to map to actual axes indices
|
|
35
|
+
ax_keys_sorted = sorted(record.axes.keys())
|
|
36
|
+
ax_key_to_index = {key: idx for idx, key in enumerate(ax_keys_sorted)}
|
|
37
|
+
|
|
38
|
+
for call_id, params in call_overrides.items():
|
|
39
|
+
# Find the call in record to get function type, ax_index, and call position
|
|
40
|
+
call_function = None
|
|
41
|
+
ax_index = None
|
|
42
|
+
ax_record_found = None
|
|
43
|
+
for ax_key, ax_record in record.axes.items():
|
|
44
|
+
for call in ax_record.calls:
|
|
45
|
+
if call.id == call_id:
|
|
46
|
+
call_function = call.function
|
|
47
|
+
ax_record_found = ax_record
|
|
48
|
+
# Use sorted key order to get correct axes index
|
|
49
|
+
ax_index = ax_key_to_index.get(ax_key, 0)
|
|
50
|
+
break
|
|
51
|
+
if call_function:
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
if call_function is None or ax_index is None or ax_index >= len(axes_list):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
ax = axes_list[ax_index]
|
|
58
|
+
|
|
59
|
+
# Apply overrides based on plot type
|
|
60
|
+
for param, value in params.items():
|
|
61
|
+
if call_function in ("bar", "barh", "hist"):
|
|
62
|
+
# Bar/hist creates multiple patches per call - apply to ALL
|
|
63
|
+
_apply_bar_override(ax, ax_record_found, call_id, param, value)
|
|
64
|
+
elif call_function == "plot":
|
|
65
|
+
_apply_line_override(ax, ax_record_found, call_id, param, value)
|
|
66
|
+
elif call_function == "scatter":
|
|
67
|
+
_apply_scatter_override(ax, ax_record_found, call_id, param, value)
|
|
68
|
+
elif call_function == "pie":
|
|
69
|
+
# Pie wedges - apply to all wedges for this call
|
|
70
|
+
wedges = [p for p in ax.patches if isinstance(p, Wedge)]
|
|
71
|
+
for wedge in wedges:
|
|
72
|
+
_apply_patch_param(wedge, param, value)
|
|
73
|
+
elif call_function in ("fill_between", "fill_betweenx"):
|
|
74
|
+
_apply_fill_override(ax, ax_record_found, call_id, param, value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _apply_bar_override(ax, ax_record, call_id, param, value):
|
|
78
|
+
"""Apply override to bar/hist patches for a specific call."""
|
|
79
|
+
from matplotlib.patches import Rectangle
|
|
80
|
+
|
|
81
|
+
rectangles = [p for p in ax.patches if isinstance(p, Rectangle)]
|
|
82
|
+
if not rectangles:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Find all bar/hist calls to determine grouping
|
|
86
|
+
bar_calls = [c for c in ax_record.calls if c.function in ("bar", "barh", "hist")]
|
|
87
|
+
if not bar_calls:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Find which call index this is
|
|
91
|
+
call_idx = next((i for i, c in enumerate(bar_calls) if c.id == call_id), None)
|
|
92
|
+
if call_idx is None:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Distribute patches among calls
|
|
96
|
+
patches_per_call = len(rectangles) // len(bar_calls) if bar_calls else 1
|
|
97
|
+
start_idx = call_idx * patches_per_call
|
|
98
|
+
end_idx = start_idx + patches_per_call
|
|
99
|
+
|
|
100
|
+
# Apply to all patches for this call
|
|
101
|
+
for patch in rectangles[start_idx:end_idx]:
|
|
102
|
+
_apply_patch_param(patch, param, value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _apply_line_override(ax, ax_record, call_id, param, value):
|
|
106
|
+
"""Apply override to line for a specific call."""
|
|
107
|
+
lines = [line for line in ax.get_lines() if not line.get_label().startswith("_")]
|
|
108
|
+
line_calls = [c for c in ax_record.calls if c.function == "plot"]
|
|
109
|
+
|
|
110
|
+
call_idx = next((i for i, c in enumerate(line_calls) if c.id == call_id), None)
|
|
111
|
+
if call_idx is not None and call_idx < len(lines):
|
|
112
|
+
_apply_line_param(lines[call_idx], param, value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_scatter_override(ax, ax_record, call_id, param, value):
|
|
116
|
+
"""Apply override to scatter collection for a specific call."""
|
|
117
|
+
from matplotlib.collections import PathCollection
|
|
118
|
+
|
|
119
|
+
collections = [c for c in ax.collections if isinstance(c, PathCollection)]
|
|
120
|
+
scatter_calls = [c for c in ax_record.calls if c.function == "scatter"]
|
|
121
|
+
|
|
122
|
+
call_idx = next((i for i, c in enumerate(scatter_calls) if c.id == call_id), None)
|
|
123
|
+
if call_idx is not None and call_idx < len(collections):
|
|
124
|
+
_apply_collection_param(collections[call_idx], param, value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _apply_fill_override(ax, ax_record, call_id, param, value):
|
|
128
|
+
"""Apply override to fill_between for a specific call."""
|
|
129
|
+
from matplotlib.collections import PolyCollection
|
|
130
|
+
|
|
131
|
+
fills = [c for c in ax.collections if isinstance(c, PolyCollection)]
|
|
132
|
+
fill_calls = [
|
|
133
|
+
c for c in ax_record.calls if c.function in ("fill_between", "fill_betweenx")
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
call_idx = next((i for i, c in enumerate(fill_calls) if c.id == call_id), None)
|
|
137
|
+
if call_idx is not None and call_idx < len(fills):
|
|
138
|
+
_apply_collection_param(fills[call_idx], param, value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _apply_patch_param(patch: Any, param: str, value: Any) -> None:
|
|
142
|
+
"""Apply parameter to a patch (bar, wedge, etc.)."""
|
|
143
|
+
if param == "color":
|
|
144
|
+
patch.set_facecolor(value)
|
|
145
|
+
elif param == "edgecolor":
|
|
146
|
+
patch.set_edgecolor(value)
|
|
147
|
+
elif param == "linewidth":
|
|
148
|
+
patch.set_linewidth(value)
|
|
149
|
+
elif param == "alpha":
|
|
150
|
+
patch.set_alpha(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _apply_line_param(line: Any, param: str, value: Any) -> None:
|
|
154
|
+
"""Apply parameter to a line."""
|
|
155
|
+
if param == "color":
|
|
156
|
+
line.set_color(value)
|
|
157
|
+
elif param == "linewidth":
|
|
158
|
+
line.set_linewidth(value)
|
|
159
|
+
elif param == "linestyle":
|
|
160
|
+
line.set_linestyle(value)
|
|
161
|
+
elif param == "alpha":
|
|
162
|
+
line.set_alpha(value)
|
|
163
|
+
elif param == "marker":
|
|
164
|
+
line.set_marker(value)
|
|
165
|
+
elif param == "markersize":
|
|
166
|
+
line.set_markersize(value)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_collection_param(coll: Any, param: str, value: Any) -> None:
|
|
170
|
+
"""Apply parameter to a collection (scatter, fill)."""
|
|
171
|
+
if param in ("color", "c"):
|
|
172
|
+
coll.set_facecolors(value)
|
|
173
|
+
elif param == "edgecolor":
|
|
174
|
+
coll.set_edgecolors(value)
|
|
175
|
+
elif param == "s":
|
|
176
|
+
coll.set_sizes([value] if not hasattr(value, "__len__") else value)
|
|
177
|
+
elif param == "alpha":
|
|
178
|
+
coll.set_alpha(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = ["apply_call_overrides"]
|
|
182
|
+
|
|
183
|
+
# EOF
|