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/_cli/_crop.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""crop command - Crop image to content."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("image", type=click.Path(exists=True))
|
|
13
|
+
@click.option(
|
|
14
|
+
"-o",
|
|
15
|
+
"--output",
|
|
16
|
+
type=click.Path(),
|
|
17
|
+
help="Output path for cropped image.",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--margin",
|
|
21
|
+
type=str,
|
|
22
|
+
default="1mm",
|
|
23
|
+
help="Margin around content (e.g., '2mm' or '10px'). Default: 1mm.",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--overwrite",
|
|
27
|
+
is_flag=True,
|
|
28
|
+
help="Overwrite the input file.",
|
|
29
|
+
)
|
|
30
|
+
def crop(
|
|
31
|
+
image: str,
|
|
32
|
+
output: Optional[str],
|
|
33
|
+
margin: str,
|
|
34
|
+
overwrite: bool,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Crop an image to its content area.
|
|
37
|
+
|
|
38
|
+
IMAGE is the path to the image file (PNG, PDF, etc.).
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
from .. import crop as fr_crop
|
|
42
|
+
except ImportError:
|
|
43
|
+
raise click.ClickException(
|
|
44
|
+
"Crop requires Pillow. Install with: pip install figrecipe[imaging]"
|
|
45
|
+
) from None
|
|
46
|
+
|
|
47
|
+
image_path = Path(image)
|
|
48
|
+
|
|
49
|
+
# Parse margin
|
|
50
|
+
margin_mm = None
|
|
51
|
+
margin_px = None
|
|
52
|
+
|
|
53
|
+
if margin.endswith("mm"):
|
|
54
|
+
margin_mm = float(margin[:-2])
|
|
55
|
+
elif margin.endswith("px"):
|
|
56
|
+
margin_px = int(margin[:-2])
|
|
57
|
+
else:
|
|
58
|
+
# Default to mm
|
|
59
|
+
try:
|
|
60
|
+
margin_mm = float(margin)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise click.ClickException(f"Invalid margin format: {margin}") from None
|
|
63
|
+
|
|
64
|
+
# Determine output path
|
|
65
|
+
if output:
|
|
66
|
+
output_path = Path(output)
|
|
67
|
+
elif overwrite:
|
|
68
|
+
output_path = None # Will overwrite in place
|
|
69
|
+
else:
|
|
70
|
+
output_path = image_path.with_stem(f"{image_path.stem}_cropped")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
result = fr_crop(
|
|
74
|
+
image_path,
|
|
75
|
+
output_path=output_path,
|
|
76
|
+
margin_mm=margin_mm,
|
|
77
|
+
margin_px=margin_px,
|
|
78
|
+
overwrite=overwrite,
|
|
79
|
+
)
|
|
80
|
+
click.echo(f"Cropped: {result}")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise click.ClickException(f"Crop failed: {e}") from e
|
figrecipe/_cli/_edit.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""edit command - Launch GUI editor."""
|
|
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), required=False)
|
|
13
|
+
@click.option(
|
|
14
|
+
"--port",
|
|
15
|
+
type=int,
|
|
16
|
+
default=5050,
|
|
17
|
+
help="Server port (default: 5050).",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--host",
|
|
21
|
+
type=str,
|
|
22
|
+
default="127.0.0.1",
|
|
23
|
+
help="Host to bind (default: 127.0.0.1).",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--no-browser",
|
|
27
|
+
is_flag=True,
|
|
28
|
+
help="Don't auto-open browser.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--desktop",
|
|
32
|
+
is_flag=True,
|
|
33
|
+
help="Launch as native desktop window (requires pywebview).",
|
|
34
|
+
)
|
|
35
|
+
def edit(
|
|
36
|
+
source: Optional[str],
|
|
37
|
+
port: int,
|
|
38
|
+
host: str,
|
|
39
|
+
no_browser: bool,
|
|
40
|
+
desktop: bool,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Launch interactive GUI editor.
|
|
43
|
+
|
|
44
|
+
SOURCE is the optional path to a .yaml recipe file or bundle.
|
|
45
|
+
If not provided, creates a new blank figure.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from .. import edit as fr_edit
|
|
49
|
+
except ImportError:
|
|
50
|
+
raise click.ClickException(
|
|
51
|
+
"Editor requires Flask. Install with: pip install figrecipe[editor]"
|
|
52
|
+
) from None
|
|
53
|
+
|
|
54
|
+
source_path = Path(source) if source else None
|
|
55
|
+
|
|
56
|
+
if desktop:
|
|
57
|
+
click.echo("Starting editor in desktop mode...")
|
|
58
|
+
else:
|
|
59
|
+
click.echo(f"Starting editor on http://{host}:{port}")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
fr_edit(
|
|
63
|
+
source_path,
|
|
64
|
+
port=port,
|
|
65
|
+
host=host,
|
|
66
|
+
open_browser=not no_browser,
|
|
67
|
+
desktop=desktop,
|
|
68
|
+
)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise click.ClickException(f"Editor failed: {e}") from e
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""extract command - Extract plotted data from recipes."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
14
|
+
@click.option(
|
|
15
|
+
"-o",
|
|
16
|
+
"--output",
|
|
17
|
+
type=click.Path(),
|
|
18
|
+
help="Output directory for extracted data.",
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"-f",
|
|
22
|
+
"--format",
|
|
23
|
+
"fmt",
|
|
24
|
+
type=click.Choice(["csv", "npz", "json"]),
|
|
25
|
+
default="csv",
|
|
26
|
+
help="Data format (default: csv).",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--axes",
|
|
30
|
+
type=str,
|
|
31
|
+
help="Specific axes to extract (e.g., ax_0_0).",
|
|
32
|
+
)
|
|
33
|
+
def extract(
|
|
34
|
+
source: str,
|
|
35
|
+
output: Optional[str],
|
|
36
|
+
fmt: str,
|
|
37
|
+
axes: Optional[str],
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Extract plotted data arrays from a recipe.
|
|
40
|
+
|
|
41
|
+
SOURCE is the path to a .yaml recipe file.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from .. import extract_data
|
|
45
|
+
|
|
46
|
+
source_path = Path(source)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
data = extract_data(source_path)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise click.ClickException(f"Failed to extract data: {e}") from e
|
|
52
|
+
|
|
53
|
+
if not data:
|
|
54
|
+
click.echo("No data found in recipe.")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Determine output directory
|
|
58
|
+
if output:
|
|
59
|
+
output_dir = Path(output)
|
|
60
|
+
else:
|
|
61
|
+
output_dir = source_path.parent / f"{source_path.stem}_data"
|
|
62
|
+
|
|
63
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
# Export data
|
|
66
|
+
for call_id, call_data in data.items():
|
|
67
|
+
if axes and not call_id.startswith(axes):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if fmt == "json":
|
|
71
|
+
_save_json(output_dir / f"{call_id}.json", call_data)
|
|
72
|
+
elif fmt == "npz":
|
|
73
|
+
_save_npz(output_dir / f"{call_id}.npz", call_data)
|
|
74
|
+
else: # csv
|
|
75
|
+
_save_csv(output_dir / f"{call_id}.csv", call_data)
|
|
76
|
+
|
|
77
|
+
click.echo(f"Extracted: {call_id}")
|
|
78
|
+
|
|
79
|
+
click.echo(f"\nData saved to: {output_dir}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _save_json(path: Path, data: dict) -> None:
|
|
83
|
+
"""Save data as JSON."""
|
|
84
|
+
import numpy as np
|
|
85
|
+
|
|
86
|
+
def convert(obj):
|
|
87
|
+
if isinstance(obj, np.ndarray):
|
|
88
|
+
return obj.tolist()
|
|
89
|
+
return obj
|
|
90
|
+
|
|
91
|
+
with open(path, "w") as f:
|
|
92
|
+
json.dump({k: convert(v) for k, v in data.items()}, f, indent=2)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _save_npz(path: Path, data: dict) -> None:
|
|
96
|
+
"""Save data as NPZ."""
|
|
97
|
+
import numpy as np
|
|
98
|
+
|
|
99
|
+
np.savez(path, **data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _save_csv(path: Path, data: dict) -> None:
|
|
103
|
+
"""Save data as CSV."""
|
|
104
|
+
import numpy as np
|
|
105
|
+
|
|
106
|
+
# Try to create a table from the data
|
|
107
|
+
arrays = {k: np.asarray(v) for k, v in data.items() if hasattr(v, "__len__")}
|
|
108
|
+
|
|
109
|
+
if not arrays:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Find max length
|
|
113
|
+
max_len = max(len(a.flatten()) for a in arrays.values())
|
|
114
|
+
|
|
115
|
+
with open(path, "w") as f:
|
|
116
|
+
# Header
|
|
117
|
+
f.write(",".join(arrays.keys()) + "\n")
|
|
118
|
+
|
|
119
|
+
# Data rows
|
|
120
|
+
for i in range(max_len):
|
|
121
|
+
row = []
|
|
122
|
+
for arr in arrays.values():
|
|
123
|
+
flat = arr.flatten()
|
|
124
|
+
if i < len(flat):
|
|
125
|
+
row.append(str(flat[i]))
|
|
126
|
+
else:
|
|
127
|
+
row.append("")
|
|
128
|
+
f.write(",".join(row) + "\n")
|
figrecipe/_cli/_fonts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""fonts command - Font management."""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option(
|
|
12
|
+
"--check",
|
|
13
|
+
type=str,
|
|
14
|
+
help="Check if a specific font is available.",
|
|
15
|
+
)
|
|
16
|
+
@click.option(
|
|
17
|
+
"--search",
|
|
18
|
+
type=str,
|
|
19
|
+
help="Search for fonts matching a pattern.",
|
|
20
|
+
)
|
|
21
|
+
def fonts(check: Optional[str], search: Optional[str]) -> None:
|
|
22
|
+
"""List or check available fonts."""
|
|
23
|
+
from .. import check_font, list_available_fonts
|
|
24
|
+
|
|
25
|
+
if check:
|
|
26
|
+
available = check_font(check)
|
|
27
|
+
if available:
|
|
28
|
+
click.echo(f"Font '{check}' is available.")
|
|
29
|
+
else:
|
|
30
|
+
click.echo(f"Font '{check}' is NOT available.")
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
all_fonts = list_available_fonts()
|
|
35
|
+
|
|
36
|
+
if search:
|
|
37
|
+
pattern = search.lower()
|
|
38
|
+
matching = [f for f in all_fonts if pattern in f.lower()]
|
|
39
|
+
click.echo(f"Fonts matching '{search}':")
|
|
40
|
+
for font in sorted(matching):
|
|
41
|
+
click.echo(f" {font}")
|
|
42
|
+
click.echo(f"\nFound {len(matching)} matching fonts.")
|
|
43
|
+
else:
|
|
44
|
+
click.echo("Available fonts:")
|
|
45
|
+
for font in sorted(all_fonts):
|
|
46
|
+
click.echo(f" {font}")
|
|
47
|
+
click.echo(f"\nTotal: {len(all_fonts)} fonts.")
|
figrecipe/_cli/_info.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""info command - Inspect recipe metadata."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
13
|
+
@click.option(
|
|
14
|
+
"--json",
|
|
15
|
+
"as_json",
|
|
16
|
+
is_flag=True,
|
|
17
|
+
help="Output as JSON.",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"-v",
|
|
21
|
+
"--verbose",
|
|
22
|
+
is_flag=True,
|
|
23
|
+
help="Show detailed information.",
|
|
24
|
+
)
|
|
25
|
+
def info(source: str, as_json: bool, verbose: bool) -> None:
|
|
26
|
+
"""Show information about a recipe.
|
|
27
|
+
|
|
28
|
+
SOURCE is the path to a .yaml recipe file.
|
|
29
|
+
"""
|
|
30
|
+
from .. import info as fr_info
|
|
31
|
+
|
|
32
|
+
source_path = Path(source)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
recipe_info = fr_info(source_path)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
raise click.ClickException(f"Failed to load recipe: {e}") from e
|
|
38
|
+
|
|
39
|
+
if as_json:
|
|
40
|
+
click.echo(json.dumps(recipe_info, indent=2, default=str))
|
|
41
|
+
else:
|
|
42
|
+
_print_info(recipe_info, verbose)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_info(info: dict, verbose: bool) -> None:
|
|
46
|
+
"""Print recipe info in human-readable format."""
|
|
47
|
+
click.echo(f"Recipe Version: {info.get('figrecipe_version', 'unknown')}")
|
|
48
|
+
click.echo(f"Figure ID: {info.get('id', 'unknown')}")
|
|
49
|
+
click.echo(f"Created: {info.get('created', 'unknown')}")
|
|
50
|
+
click.echo(f"Matplotlib: {info.get('matplotlib_version', 'unknown')}")
|
|
51
|
+
|
|
52
|
+
if "figure" in info:
|
|
53
|
+
fig = info["figure"]
|
|
54
|
+
click.echo(f"Figure Size: {fig.get('figsize', 'unknown')}")
|
|
55
|
+
click.echo(f"DPI: {fig.get('dpi', 'unknown')}")
|
|
56
|
+
|
|
57
|
+
if "axes" in info:
|
|
58
|
+
click.echo(f"Axes Count: {len(info['axes'])}")
|
|
59
|
+
|
|
60
|
+
if verbose:
|
|
61
|
+
for ax_key, ax_info in info["axes"].items():
|
|
62
|
+
click.echo(f"\n {ax_key}:")
|
|
63
|
+
if "calls" in ax_info:
|
|
64
|
+
for call in ax_info["calls"]:
|
|
65
|
+
func = call.get("function", "unknown")
|
|
66
|
+
call_id = call.get("id", "")
|
|
67
|
+
click.echo(f" - {func} ({call_id})")
|
figrecipe/_cli/_main.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Main CLI entry point for figrecipe."""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import __version__
|
|
8
|
+
from ._compose import compose
|
|
9
|
+
from ._convert import convert
|
|
10
|
+
from ._crop import crop
|
|
11
|
+
from ._edit import edit
|
|
12
|
+
from ._extract import extract
|
|
13
|
+
from ._fonts import fonts
|
|
14
|
+
from ._info import info
|
|
15
|
+
from ._reproduce import reproduce
|
|
16
|
+
from ._style import style
|
|
17
|
+
from ._validate import validate
|
|
18
|
+
from ._version import version as version_cmd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group(
|
|
22
|
+
invoke_without_command=True,
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
)
|
|
25
|
+
@click.option("--version", "-V", is_flag=True, help="Show version and exit.")
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def main(ctx: click.Context, version: bool) -> None:
|
|
28
|
+
"""figrecipe - Reproducible matplotlib figures.
|
|
29
|
+
|
|
30
|
+
A command-line interface for creating, reproducing, and editing
|
|
31
|
+
matplotlib figures using YAML recipes.
|
|
32
|
+
|
|
33
|
+
When run without a subcommand, launches the GUI editor.
|
|
34
|
+
"""
|
|
35
|
+
if version:
|
|
36
|
+
click.echo(f"figrecipe {__version__}")
|
|
37
|
+
ctx.exit(0)
|
|
38
|
+
|
|
39
|
+
if ctx.invoked_subcommand is None:
|
|
40
|
+
ctx.invoke(edit)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Register commands
|
|
44
|
+
main.add_command(reproduce)
|
|
45
|
+
main.add_command(info)
|
|
46
|
+
main.add_command(extract)
|
|
47
|
+
main.add_command(validate)
|
|
48
|
+
main.add_command(edit)
|
|
49
|
+
main.add_command(crop)
|
|
50
|
+
main.add_command(compose)
|
|
51
|
+
main.add_command(style)
|
|
52
|
+
main.add_command(convert)
|
|
53
|
+
main.add_command(fonts)
|
|
54
|
+
main.add_command(version_cmd)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""reproduce command - Recreate figure from recipe."""
|
|
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
|
+
"-o",
|
|
15
|
+
"--output",
|
|
16
|
+
type=click.Path(),
|
|
17
|
+
help="Output path for the reproduced figure.",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"-f",
|
|
21
|
+
"--format",
|
|
22
|
+
"fmt",
|
|
23
|
+
type=click.Choice(["png", "pdf", "svg"]),
|
|
24
|
+
default="png",
|
|
25
|
+
help="Output format (default: png).",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--dpi",
|
|
29
|
+
type=int,
|
|
30
|
+
default=300,
|
|
31
|
+
help="DPI for raster output (default: 300).",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--show",
|
|
35
|
+
is_flag=True,
|
|
36
|
+
help="Display the figure interactively.",
|
|
37
|
+
)
|
|
38
|
+
def reproduce(
|
|
39
|
+
source: str,
|
|
40
|
+
output: Optional[str],
|
|
41
|
+
fmt: str,
|
|
42
|
+
dpi: int,
|
|
43
|
+
show: bool,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Reproduce a figure from a YAML recipe.
|
|
46
|
+
|
|
47
|
+
SOURCE is the path to a .yaml recipe file or bundle directory.
|
|
48
|
+
"""
|
|
49
|
+
import matplotlib.pyplot as plt
|
|
50
|
+
|
|
51
|
+
from .. import reproduce as fr_reproduce
|
|
52
|
+
|
|
53
|
+
source_path = Path(source)
|
|
54
|
+
|
|
55
|
+
# Reproduce the figure
|
|
56
|
+
try:
|
|
57
|
+
fig, axes = fr_reproduce(source_path)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise click.ClickException(f"Failed to reproduce: {e}") from e
|
|
60
|
+
|
|
61
|
+
# Determine output path
|
|
62
|
+
if output:
|
|
63
|
+
output_path = Path(output)
|
|
64
|
+
else:
|
|
65
|
+
output_path = source_path.with_suffix(f".reproduced.{fmt}")
|
|
66
|
+
|
|
67
|
+
# Save or show
|
|
68
|
+
if show:
|
|
69
|
+
plt.show()
|
|
70
|
+
else:
|
|
71
|
+
fig.savefig(output_path, dpi=dpi, format=fmt)
|
|
72
|
+
click.echo(f"Saved: {output_path}")
|
|
73
|
+
|
|
74
|
+
# Close the figure (handle both regular and Recording figures)
|
|
75
|
+
try:
|
|
76
|
+
plt.close(fig)
|
|
77
|
+
except TypeError:
|
|
78
|
+
# RecordingFigure wrapper - close all instead
|
|
79
|
+
plt.close("all")
|
figrecipe/_cli/_style.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""style command - Style management subcommands."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def style() -> None:
|
|
11
|
+
"""Manage figure styles and presets."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@style.command("list")
|
|
16
|
+
def list_styles() -> None:
|
|
17
|
+
"""List available style presets."""
|
|
18
|
+
from .. import list_presets
|
|
19
|
+
|
|
20
|
+
presets = list_presets()
|
|
21
|
+
|
|
22
|
+
click.echo("Available style presets:")
|
|
23
|
+
for preset in presets:
|
|
24
|
+
click.echo(f" - {preset}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@style.command("show")
|
|
28
|
+
@click.argument("name")
|
|
29
|
+
def show_style(name: str) -> None:
|
|
30
|
+
"""Show details of a style preset.
|
|
31
|
+
|
|
32
|
+
NAME is the preset name (e.g., SCITEX, MATPLOTLIB).
|
|
33
|
+
"""
|
|
34
|
+
from ruamel.yaml import YAML
|
|
35
|
+
|
|
36
|
+
from ..styles._style_loader import load_preset
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
style_dict = load_preset(name)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise click.ClickException(f"Failed to load preset '{name}': {e}") from e
|
|
42
|
+
|
|
43
|
+
yaml = YAML()
|
|
44
|
+
yaml.default_flow_style = False
|
|
45
|
+
|
|
46
|
+
click.echo(f"Style preset: {name}\n")
|
|
47
|
+
|
|
48
|
+
import io
|
|
49
|
+
|
|
50
|
+
stream = io.StringIO()
|
|
51
|
+
yaml.dump(style_dict, stream)
|
|
52
|
+
click.echo(stream.getvalue())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@style.command("apply")
|
|
56
|
+
@click.argument("name")
|
|
57
|
+
def apply_style_cmd(name: str) -> None:
|
|
58
|
+
"""Apply a style preset globally.
|
|
59
|
+
|
|
60
|
+
NAME is the preset name (e.g., SCITEX, MATPLOTLIB).
|
|
61
|
+
"""
|
|
62
|
+
from .. import load_style
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
load_style(name)
|
|
66
|
+
click.echo(f"Applied style: {name}")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise click.ClickException(f"Failed to apply style: {e}") from e
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@style.command("reset")
|
|
72
|
+
def reset_style() -> None:
|
|
73
|
+
"""Reset to default matplotlib style."""
|
|
74
|
+
from .. import unload_style
|
|
75
|
+
|
|
76
|
+
unload_style()
|
|
77
|
+
click.echo("Style reset to defaults.")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""validate command - Verify recipe reproducibility."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
12
|
+
@click.option(
|
|
13
|
+
"--threshold",
|
|
14
|
+
type=float,
|
|
15
|
+
default=100.0,
|
|
16
|
+
help="MSE threshold for validation (default: 100).",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--strict",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
help="Fail on any difference.",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"-q",
|
|
25
|
+
"--quiet",
|
|
26
|
+
is_flag=True,
|
|
27
|
+
help="Only output pass/fail status.",
|
|
28
|
+
)
|
|
29
|
+
def validate(
|
|
30
|
+
source: str,
|
|
31
|
+
threshold: float,
|
|
32
|
+
strict: bool,
|
|
33
|
+
quiet: bool,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Validate that a recipe reproduces its original figure.
|
|
36
|
+
|
|
37
|
+
SOURCE is the path to a .yaml recipe file.
|
|
38
|
+
"""
|
|
39
|
+
from .. import validate as fr_validate
|
|
40
|
+
|
|
41
|
+
source_path = Path(source)
|
|
42
|
+
|
|
43
|
+
if strict:
|
|
44
|
+
threshold = 0.0
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
result = fr_validate(source_path, mse_threshold=threshold)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise click.ClickException(f"Validation failed: {e}") from e
|
|
50
|
+
|
|
51
|
+
if quiet:
|
|
52
|
+
if result.valid:
|
|
53
|
+
click.echo("PASS")
|
|
54
|
+
else:
|
|
55
|
+
click.echo("FAIL")
|
|
56
|
+
raise SystemExit(1)
|
|
57
|
+
else:
|
|
58
|
+
click.echo(f"Validation: {'PASS' if result.valid else 'FAIL'}")
|
|
59
|
+
click.echo(f"MSE: {result.mse:.6f}")
|
|
60
|
+
click.echo(f"Threshold: {threshold}")
|
|
61
|
+
|
|
62
|
+
if hasattr(result, "message") and result.message:
|
|
63
|
+
click.echo(f"Message: {result.message}")
|
|
64
|
+
|
|
65
|
+
if not result.valid:
|
|
66
|
+
raise SystemExit(1)
|