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