figrecipe 0.5.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.
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Serialization for recipe files (YAML + data files)."""
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Literal, Optional, Union
7
+
8
+ import numpy as np
9
+ from ruamel.yaml import YAML
10
+
11
+ from ._recorder import FigureRecord
12
+ from ._utils._numpy_io import save_array, load_array, DataFormat
13
+
14
+
15
+ def _convert_numpy_types(obj: Any) -> Any:
16
+ """Recursively convert numpy types to Python native types.
17
+
18
+ Parameters
19
+ ----------
20
+ obj : Any
21
+ Object to convert.
22
+
23
+ Returns
24
+ -------
25
+ Any
26
+ Object with numpy types converted to native Python types.
27
+ """
28
+ if isinstance(obj, np.ndarray):
29
+ return obj.tolist()
30
+ elif isinstance(obj, np.integer):
31
+ return int(obj)
32
+ elif isinstance(obj, np.floating):
33
+ return float(obj)
34
+ elif isinstance(obj, np.bool_):
35
+ return bool(obj)
36
+ elif isinstance(obj, dict):
37
+ return {k: _convert_numpy_types(v) for k, v in obj.items()}
38
+ elif isinstance(obj, (list, tuple)):
39
+ converted = [_convert_numpy_types(item) for item in obj]
40
+ return type(obj)(converted) if isinstance(obj, tuple) else converted
41
+ else:
42
+ return obj
43
+
44
+
45
+ def save_recipe(
46
+ record: FigureRecord,
47
+ path: Union[str, Path],
48
+ include_data: bool = True,
49
+ data_format: DataFormat = "csv",
50
+ ) -> Path:
51
+ """Save a figure record to YAML file.
52
+
53
+ Parameters
54
+ ----------
55
+ record : FigureRecord
56
+ The figure record to save.
57
+ path : str or Path
58
+ Output path (.yaml).
59
+ include_data : bool
60
+ If True, save large arrays to separate files.
61
+ data_format : str
62
+ Format for data files: 'csv' (default), 'npz', or 'inline'.
63
+ - 'csv': Human-readable CSV files with dtype header
64
+ - 'npz': Compressed numpy binary format
65
+ - 'inline': Store all data directly in YAML (may be large)
66
+
67
+ Returns
68
+ -------
69
+ Path
70
+ Path to saved YAML file.
71
+ """
72
+ path = Path(path)
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Create data directory for large arrays
76
+ data_dir = path.parent / f"{path.stem}_data"
77
+
78
+ # Convert record to dict
79
+ data = record.to_dict()
80
+
81
+ # Process arrays: save large ones to files, update references
82
+ if include_data and data_format != "inline":
83
+ data = _process_arrays_for_save(data, data_dir, record.id, data_format)
84
+
85
+ # Convert numpy types to native Python types
86
+ data = _convert_numpy_types(data)
87
+
88
+ # Save YAML
89
+ yaml = YAML()
90
+ yaml.preserve_quotes = True
91
+ yaml.indent(mapping=2, sequence=4, offset=2)
92
+
93
+ with open(path, "w") as f:
94
+ yaml.dump(data, f)
95
+
96
+ return path
97
+
98
+
99
+ def _process_arrays_for_save(
100
+ data: Dict[str, Any],
101
+ data_dir: Path,
102
+ fig_id: str,
103
+ data_format: DataFormat = "csv",
104
+ ) -> Dict[str, Any]:
105
+ """Process arrays in data dict, saving large ones to files.
106
+
107
+ Parameters
108
+ ----------
109
+ data : dict
110
+ Data dictionary to process.
111
+ data_dir : Path
112
+ Directory for array files.
113
+ fig_id : str
114
+ Figure ID for naming files.
115
+ data_format : str
116
+ Format for data files: 'csv', 'npz', or 'inline'.
117
+
118
+ Returns
119
+ -------
120
+ dict
121
+ Processed data with file references.
122
+ """
123
+ data_dir_created = False
124
+
125
+ for ax_key, ax_data in data.get("axes", {}).items():
126
+ for call_list in [ax_data.get("calls", []), ax_data.get("decorations", [])]:
127
+ for call in call_list:
128
+ call_id = call.get("id", "unknown")
129
+
130
+ # Process args
131
+ for i, arg in enumerate(call.get("args", [])):
132
+ if "_array" in arg:
133
+ # Large array - save to file
134
+ if not data_dir_created:
135
+ data_dir.mkdir(parents=True, exist_ok=True)
136
+ data_dir_created = True
137
+
138
+ arr = arg.pop("_array")
139
+ filename = f"{call_id}_{arg.get('name', f'arg{i}')}"
140
+ file_path = save_array(arr, data_dir / filename, data_format)
141
+ arg["data"] = str(file_path.relative_to(data_dir.parent))
142
+
143
+ return data
144
+
145
+
146
+ def load_recipe(path: Union[str, Path]) -> FigureRecord:
147
+ """Load a figure record from YAML file.
148
+
149
+ Parameters
150
+ ----------
151
+ path : str or Path
152
+ Path to .yaml recipe file.
153
+
154
+ Returns
155
+ -------
156
+ FigureRecord
157
+ Loaded figure record.
158
+ """
159
+ path = Path(path)
160
+
161
+ yaml = YAML()
162
+ with open(path) as f:
163
+ data = yaml.load(f)
164
+
165
+ # Resolve data file references
166
+ data = _resolve_data_references(data, path.parent)
167
+
168
+ return FigureRecord.from_dict(data)
169
+
170
+
171
+ def _resolve_data_references(
172
+ data: Dict[str, Any],
173
+ base_dir: Path,
174
+ ) -> Dict[str, Any]:
175
+ """Resolve file references to actual array data.
176
+
177
+ Parameters
178
+ ----------
179
+ data : dict
180
+ Data dictionary with file references.
181
+ base_dir : Path
182
+ Base directory for resolving relative paths.
183
+
184
+ Returns
185
+ -------
186
+ dict
187
+ Data with arrays loaded.
188
+ """
189
+ for ax_key, ax_data in data.get("axes", {}).items():
190
+ for call_list in [ax_data.get("calls", []), ax_data.get("decorations", [])]:
191
+ for call in call_list:
192
+ for arg in call.get("args", []):
193
+ data_ref = arg.get("data")
194
+
195
+ # Check if it's a file reference
196
+ if isinstance(data_ref, str) and (
197
+ data_ref.endswith(".npy")
198
+ or data_ref.endswith(".npz")
199
+ or data_ref.endswith(".csv")
200
+ ):
201
+ file_path = base_dir / data_ref
202
+ if file_path.exists():
203
+ arr = load_array(file_path)
204
+ arg["data"] = arr.tolist()
205
+ arg["_loaded_array"] = arr
206
+
207
+ return data
208
+
209
+
210
+ def recipe_to_dict(path: Union[str, Path]) -> Dict[str, Any]:
211
+ """Load recipe as raw dictionary (for inspection).
212
+
213
+ Parameters
214
+ ----------
215
+ path : str or Path
216
+ Path to .yaml recipe file.
217
+
218
+ Returns
219
+ -------
220
+ dict
221
+ Raw recipe data.
222
+ """
223
+ path = Path(path)
224
+
225
+ yaml = YAML()
226
+ with open(path) as f:
227
+ return dict(yaml.load(f))
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Matplotlib function signatures for validation and defaults."""
4
+
5
+ from ._loader import get_signature, get_defaults, validate_kwargs
6
+
7
+ __all__ = ["get_signature", "get_defaults", "validate_kwargs"]
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Load and query matplotlib function signatures."""
4
+
5
+ import inspect
6
+ from typing import Any, Dict, List, Optional, Set
7
+
8
+ import matplotlib.pyplot as plt
9
+
10
+
11
+ # Cache for signatures
12
+ _SIGNATURE_CACHE: Dict[str, Dict[str, Any]] = {}
13
+
14
+
15
+ def get_signature(method_name: str) -> Dict[str, Any]:
16
+ """Get signature for a matplotlib Axes method.
17
+
18
+ Parameters
19
+ ----------
20
+ method_name : str
21
+ Name of the method (e.g., 'plot', 'scatter').
22
+
23
+ Returns
24
+ -------
25
+ dict
26
+ Signature information with 'args' and 'kwargs' keys.
27
+ """
28
+ if method_name in _SIGNATURE_CACHE:
29
+ return _SIGNATURE_CACHE[method_name]
30
+
31
+ # Create a temporary axes to introspect
32
+ fig, ax = plt.subplots()
33
+ plt.close(fig)
34
+
35
+ method = getattr(ax, method_name, None)
36
+ if method is None:
37
+ return {"args": [], "kwargs": {}}
38
+
39
+ try:
40
+ sig = inspect.signature(method)
41
+ except (ValueError, TypeError):
42
+ return {"args": [], "kwargs": {}}
43
+
44
+ args = []
45
+ kwargs = {}
46
+
47
+ for name, param in sig.parameters.items():
48
+ if name == "self":
49
+ continue
50
+
51
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
52
+ args.append({"name": f"*{name}", "type": "*args"})
53
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
54
+ kwargs["**kwargs"] = {"type": "**kwargs"}
55
+ elif param.default is inspect.Parameter.empty:
56
+ # Positional argument
57
+ args.append({
58
+ "name": name,
59
+ "type": _get_type_str(param.annotation),
60
+ })
61
+ else:
62
+ # Keyword argument with default
63
+ kwargs[name] = {
64
+ "type": _get_type_str(param.annotation),
65
+ "default": _serialize_default(param.default),
66
+ }
67
+
68
+ result = {"args": args, "kwargs": kwargs}
69
+ _SIGNATURE_CACHE[method_name] = result
70
+ return result
71
+
72
+
73
+ def _get_type_str(annotation) -> Optional[str]:
74
+ """Convert annotation to string."""
75
+ if annotation is inspect.Parameter.empty:
76
+ return None
77
+ if hasattr(annotation, "__name__"):
78
+ return annotation.__name__
79
+ return str(annotation)
80
+
81
+
82
+ def _serialize_default(default) -> Any:
83
+ """Serialize default value."""
84
+ if default is inspect.Parameter.empty:
85
+ return None
86
+ if callable(default):
87
+ return f"<{type(default).__name__}>"
88
+ try:
89
+ import json
90
+ json.dumps(default)
91
+ return default
92
+ except (TypeError, ValueError):
93
+ return repr(default)
94
+
95
+
96
+ def get_defaults(method_name: str) -> Dict[str, Any]:
97
+ """Get default values for a method's kwargs.
98
+
99
+ Parameters
100
+ ----------
101
+ method_name : str
102
+ Name of the method.
103
+
104
+ Returns
105
+ -------
106
+ dict
107
+ Mapping of kwarg names to default values.
108
+ """
109
+ sig = get_signature(method_name)
110
+ defaults = {}
111
+
112
+ for name, info in sig.get("kwargs", {}).items():
113
+ if name != "**kwargs" and "default" in info:
114
+ defaults[name] = info["default"]
115
+
116
+ return defaults
117
+
118
+
119
+ def validate_kwargs(
120
+ method_name: str,
121
+ kwargs: Dict[str, Any],
122
+ ) -> Dict[str, List[str]]:
123
+ """Validate kwargs against method signature.
124
+
125
+ Parameters
126
+ ----------
127
+ method_name : str
128
+ Name of the method.
129
+ kwargs : dict
130
+ Kwargs to validate.
131
+
132
+ Returns
133
+ -------
134
+ dict
135
+ Validation result with 'valid', 'unknown', and 'missing' keys.
136
+ """
137
+ sig = get_signature(method_name)
138
+ known_kwargs = set(sig.get("kwargs", {}).keys()) - {"**kwargs"}
139
+
140
+ # If method accepts **kwargs, all are valid
141
+ if "**kwargs" in sig.get("kwargs", {}):
142
+ return {
143
+ "valid": list(kwargs.keys()),
144
+ "unknown": [],
145
+ "missing": [],
146
+ }
147
+
148
+ valid = []
149
+ unknown = []
150
+
151
+ for key in kwargs:
152
+ if key in known_kwargs:
153
+ valid.append(key)
154
+ else:
155
+ unknown.append(key)
156
+
157
+ return {
158
+ "valid": valid,
159
+ "unknown": unknown,
160
+ "missing": [], # Not checking required kwargs for now
161
+ }
162
+
163
+
164
+ def list_plotting_methods() -> List[str]:
165
+ """List all available plotting methods.
166
+
167
+ Returns
168
+ -------
169
+ list
170
+ Names of plotting methods.
171
+ """
172
+ fig, ax = plt.subplots()
173
+ plt.close(fig)
174
+
175
+ # Common plotting methods
176
+ methods = [
177
+ "plot", "scatter", "bar", "barh", "hist", "hist2d",
178
+ "boxplot", "violinplot", "pie", "errorbar", "fill",
179
+ "fill_between", "fill_betweenx", "stackplot", "stem",
180
+ "step", "imshow", "pcolor", "pcolormesh", "contour",
181
+ "contourf", "quiver", "barbs", "streamplot", "hexbin",
182
+ "eventplot", "stairs", "ecdf",
183
+ ]
184
+
185
+ # Filter to only those that exist
186
+ return [m for m in methods if hasattr(ax, m)]
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Utility modules for figrecipe."""
4
+
5
+ from ._numpy_io import load_array, save_array
6
+ from ._diff import get_non_default_kwargs, is_default_value
7
+ from ._units import mm_to_inch, inch_to_mm, mm_to_pt, pt_to_mm
8
+
9
+ __all__ = [
10
+ "save_array",
11
+ "load_array",
12
+ "get_non_default_kwargs",
13
+ "is_default_value",
14
+ "mm_to_inch",
15
+ "inch_to_mm",
16
+ "mm_to_pt",
17
+ "pt_to_mm",
18
+ ]
19
+
20
+ # Optional: image comparison (requires PIL)
21
+ try:
22
+ from ._image_diff import compare_images, create_comparison_figure
23
+ __all__.extend(["compare_images", "create_comparison_figure"])
24
+ except ImportError:
25
+ pass
26
+
27
+ # Optional: crop utility (requires PIL)
28
+ try:
29
+ from ._crop import crop, find_content_area
30
+ __all__.extend(["crop", "find_content_area"])
31
+ except ImportError:
32
+ pass
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Auto-crop figures to content area with optional margin.
4
+
5
+ This utility automatically detects the content area of saved figures
6
+ and crops them, removing excess whitespace while preserving a specified margin.
7
+ """
8
+
9
+ __all__ = ["crop", "find_content_area", "mm_to_pixels"]
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple, Union
14
+
15
+ import numpy as np
16
+
17
+
18
+ def find_content_area(image_path: Union[str, Path]) -> Tuple[int, int, int, int]:
19
+ """Find the bounding box of the content area in an image.
20
+
21
+ Parameters
22
+ ----------
23
+ image_path : str or Path
24
+ Path to the image file
25
+
26
+ Returns
27
+ -------
28
+ tuple
29
+ (left, upper, right, lower) bounding box coordinates
30
+
31
+ Raises
32
+ ------
33
+ FileNotFoundError
34
+ If the image cannot be read
35
+ """
36
+ from PIL import Image
37
+
38
+ img = Image.open(image_path)
39
+ img_array = np.array(img)
40
+
41
+ # Check if image has alpha channel (RGBA)
42
+ if len(img_array.shape) == 3 and img_array.shape[2] == 4:
43
+ # Use alpha channel to find content (non-transparent pixels)
44
+ alpha = img_array[:, :, 3]
45
+ rows = np.any(alpha > 0, axis=1)
46
+ cols = np.any(alpha > 0, axis=0)
47
+ else:
48
+ # For RGB images, detect background color from corners
49
+ if len(img_array.shape) == 3:
50
+ h, w = img_array.shape[:2]
51
+ corners = [
52
+ img_array[0, 0],
53
+ img_array[0, w - 1],
54
+ img_array[h - 1, 0],
55
+ img_array[h - 1, w - 1],
56
+ ]
57
+ bg_color = np.median(corners, axis=0).astype(np.uint8)
58
+ diff = np.abs(img_array.astype(np.int16) - bg_color.astype(np.int16))
59
+ is_content = np.any(diff > 10, axis=2)
60
+ else:
61
+ # Grayscale
62
+ h, w = img_array.shape
63
+ corners = [
64
+ img_array[0, 0],
65
+ img_array[0, w - 1],
66
+ img_array[h - 1, 0],
67
+ img_array[h - 1, w - 1],
68
+ ]
69
+ bg_value = np.median(corners)
70
+ is_content = np.abs(img_array.astype(np.int16) - bg_value) > 10
71
+
72
+ rows = np.any(is_content, axis=1)
73
+ cols = np.any(is_content, axis=0)
74
+
75
+ if np.any(rows) and np.any(cols):
76
+ y_min, y_max = np.where(rows)[0][[0, -1]]
77
+ x_min, x_max = np.where(cols)[0][[0, -1]]
78
+ return x_min, y_min, x_max + 1, y_max + 1
79
+ else:
80
+ return 0, 0, img.width, img.height
81
+
82
+
83
+ def mm_to_pixels(mm: float, dpi: int = 300) -> int:
84
+ """Convert millimeters to pixels at given DPI.
85
+
86
+ Parameters
87
+ ----------
88
+ mm : float
89
+ Size in millimeters
90
+ dpi : int
91
+ Resolution in dots per inch (default: 300)
92
+
93
+ Returns
94
+ -------
95
+ int
96
+ Size in pixels (rounded)
97
+ """
98
+ return round(mm * dpi / 25.4)
99
+
100
+
101
+ def crop(
102
+ input_path: Union[str, Path],
103
+ output_path: Optional[Union[str, Path]] = None,
104
+ margin_mm: float = 1.0,
105
+ margin_px: Optional[int] = None,
106
+ overwrite: bool = False,
107
+ verbose: bool = False,
108
+ return_offset: bool = False,
109
+ crop_box: Optional[Tuple[int, int, int, int]] = None,
110
+ ) -> Union[Path, Tuple[Path, dict]]:
111
+ """Crop a figure image to its content area with a specified margin.
112
+
113
+ Automatically detects background color (from corners) and crops to
114
+ content, leaving only the specified margin around it.
115
+
116
+ Parameters
117
+ ----------
118
+ input_path : str or Path
119
+ Path to the input image (PNG, JPEG, etc.)
120
+ output_path : str or Path, optional
121
+ Path to save the cropped image. If None and overwrite=True,
122
+ overwrites the input. If None and overwrite=False, adds '_cropped' suffix.
123
+ margin_mm : float, optional
124
+ Margin in millimeters to keep around content (default: 1.0mm).
125
+ Converted to pixels using image DPI (or 300 DPI if not available).
126
+ margin_px : int, optional
127
+ Margin in pixels (overrides margin_mm if provided).
128
+ overwrite : bool, optional
129
+ Whether to overwrite the input file (default: False)
130
+ verbose : bool, optional
131
+ Whether to print detailed information (default: False)
132
+ return_offset : bool, optional
133
+ If True, also return the crop offset for metadata adjustment.
134
+ crop_box : tuple, optional
135
+ Explicit crop coordinates (left, upper, right, lower). If provided,
136
+ skips auto-detection and uses these exact coordinates.
137
+
138
+ Returns
139
+ -------
140
+ Path or tuple
141
+ Path to the saved cropped image. If return_offset=True, returns
142
+ (path, offset_dict) with crop boundaries.
143
+
144
+ Examples
145
+ --------
146
+ >>> import figrecipe as fr
147
+ >>> fig, ax = fr.subplots(axes_width_mm=60, axes_height_mm=40)
148
+ >>> ax.plot([1, 2, 3], [1, 2, 3], id='line')
149
+ >>> fig.savefig("figure.png", dpi=300)
150
+ >>> fr.crop("figure.png", overwrite=True) # 1mm margin
151
+ >>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
152
+ >>> fr.crop("figure.png", margin_px=24) # explicit 24 pixels
153
+ """
154
+ from PIL import Image
155
+
156
+ input_path = Path(input_path)
157
+
158
+ # Determine output path
159
+ if output_path is None:
160
+ if overwrite:
161
+ output_path = input_path
162
+ else:
163
+ output_path = input_path.with_stem(f"{input_path.stem}_cropped")
164
+ else:
165
+ output_path = Path(output_path)
166
+
167
+ img = Image.open(input_path)
168
+ original_width, original_height = img.size
169
+
170
+ # Get DPI from image metadata (default to 300 if not available)
171
+ dpi = 300
172
+ if "dpi" in img.info:
173
+ dpi_info = img.info["dpi"]
174
+ if isinstance(dpi_info, tuple):
175
+ dpi = int(dpi_info[0]) # Use horizontal DPI
176
+ else:
177
+ dpi = int(dpi_info)
178
+
179
+ # Calculate margin in pixels
180
+ if margin_px is not None:
181
+ margin = margin_px
182
+ else:
183
+ margin = mm_to_pixels(margin_mm, dpi)
184
+
185
+ if verbose:
186
+ print(f"Original: {original_width}x{original_height}")
187
+ print(f"DPI: {dpi}")
188
+ print(f"Margin: {margin_mm}mm = {margin}px")
189
+
190
+ # Use explicit crop_box or auto-detect
191
+ if crop_box is not None:
192
+ left, upper, right, lower = crop_box
193
+ if verbose:
194
+ print(f"Using explicit crop_box: {crop_box}")
195
+ else:
196
+ left, upper, right, lower = find_content_area(input_path)
197
+ if verbose:
198
+ print(f"Content area: left={left}, upper={upper}, right={right}, lower={lower}")
199
+
200
+ # Add margin, clamping to image boundaries
201
+ left = max(left - margin, 0)
202
+ upper = max(upper - margin, 0)
203
+ right = min(right + margin, img.width)
204
+ lower = min(lower + margin, img.height)
205
+
206
+ if verbose:
207
+ print(f"Cropping to: {left},{upper} -> {right},{lower}")
208
+ print(f"New size: {right - left}x{lower - upper}")
209
+
210
+ # Crop the image
211
+ cropped_img = img.crop((left, upper, right, lower))
212
+
213
+ # Preserve metadata
214
+ save_kwargs = {}
215
+ if "dpi" in img.info:
216
+ save_kwargs["dpi"] = img.info["dpi"]
217
+
218
+ ext = output_path.suffix.lower()
219
+ if ext == ".png":
220
+ save_kwargs["compress_level"] = 0
221
+ save_kwargs["optimize"] = False
222
+
223
+ # Preserve PNG text chunks
224
+ from PIL import PngImagePlugin
225
+ pnginfo = PngImagePlugin.PngInfo()
226
+ for key, value in img.info.items():
227
+ if isinstance(value, (str, bytes)):
228
+ try:
229
+ pnginfo.add_text(key, str(value) if isinstance(value, bytes) else value)
230
+ except Exception:
231
+ pass
232
+ save_kwargs["pnginfo"] = pnginfo
233
+
234
+ elif ext in [".jpg", ".jpeg"]:
235
+ save_kwargs["quality"] = 100
236
+ save_kwargs["subsampling"] = 0
237
+ save_kwargs["optimize"] = False
238
+
239
+ cropped_img.save(output_path, **save_kwargs)
240
+
241
+ final_width, final_height = cropped_img.size
242
+ if verbose:
243
+ area_reduction = 1 - ((final_width * final_height) / (original_width * original_height))
244
+ print(f"Saved {area_reduction * 100:.1f}% of original area")
245
+ if output_path != input_path:
246
+ print(f"Saved to: {output_path}")
247
+
248
+ if return_offset:
249
+ offset = {
250
+ "left": left,
251
+ "upper": upper,
252
+ "right": right,
253
+ "lower": lower,
254
+ "original_width": original_width,
255
+ "original_height": original_height,
256
+ "new_width": final_width,
257
+ "new_height": final_height,
258
+ }
259
+ return output_path, offset
260
+
261
+ return output_path