返回 ppt-master
crop_images.py
根目录 / skills / ppt-master / scripts / svg_finalize / crop_images.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Smart Image Cropping Tool
4
5 Smartly crops images based on the preserveAspectRatio attribute of <image> elements in SVG:
6 - slice: Crop to fill (similar to CSS object-fit: cover)
7 - meet: Display fully without cropping (similar to CSS object-fit: contain)
8
9 Supports 9 alignment modes:
10 - xMinYMin / xMidYMin / xMaxYMin (top alignment)
11 - xMinYMid / xMidYMid / xMaxYMid (vertical center)
12 - xMinYMax / xMidYMax / xMaxYMax (bottom alignment)
13
14 Usage:
15 python3 scripts/svg_finalize/crop_images.py <SVG file or directory> [--dry-run]
16 """
17
18 import os
19 import re
20 import hashlib
21 import sys
22 import argparse
23 from pathlib import Path
24 from xml.etree import ElementTree as ET
25 from urllib.parse import unquote
26
27 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
28 if str(_SCRIPTS_DIR) not in sys.path:
29 sys.path.insert(0, str(_SCRIPTS_DIR))
30
31 from console_encoding import configure_utf8_stdio # noqa: E402
32
33 configure_utf8_stdio()
34
35 try:
36 from PIL import Image
37 except ImportError:
38 print("Error: PIL (Pillow) is required. Run: pip install Pillow")
39 exit(1)
40
41
42 def parse_preserve_aspect_ratio(attr: str) -> tuple[str, str]:
43 """
44 Parse the preserveAspectRatio attribute.
45
46 Returns: (align, meet_or_slice)
47 align: e.g. 'xMidYMid'
48 meet_or_slice: 'meet' or 'slice'
49 """
50 if not attr:
51 return ('xMidYMid', 'meet') # Default value
52
53 parts = attr.strip().split()
54 align = parts[0] if parts else 'xMidYMid'
55 meet_or_slice = parts[1] if len(parts) > 1 else 'meet'
56
57 return (align, meet_or_slice)
58
59
60 def get_crop_anchor(align: str) -> tuple[float, float]:
61 """
62 Return the crop anchor point based on the align value.
63
64 Returns: (x_anchor, y_anchor)
65 x_anchor: 0.0 (left), 0.5 (center), 1.0 (right)
66 y_anchor: 0.0 (top), 0.5 (center), 1.0 (bottom)
67 """
68 x_map = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}
69 y_map = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}
70
71 x_anchor = 0.5
72 y_anchor = 0.5
73
74 for key, val in x_map.items():
75 if key in align:
76 x_anchor = val
77 break
78
79 for key, val in y_map.items():
80 if key in align:
81 y_anchor = val
82 break
83
84 return (x_anchor, y_anchor)
85
86
87 def crop_image_to_size(
88 img: Image.Image,
89 target_width: float,
90 target_height: float,
91 x_anchor: float = 0.5,
92 y_anchor: float = 0.5,
93 ) -> Image.Image:
94 """
95 Crop an image to the target aspect ratio, preserving original resolution (no scaling).
96
97 New logic: Only crops the original image to the target aspect ratio without any scaling,
98 thus preserving the original resolution and clarity.
99
100 Args:
101 img: PIL Image object
102 target_width: Target width (used to calculate ratio)
103 target_height: Target height (used to calculate ratio)
104 x_anchor: Horizontal anchor (0=left, 0.5=center, 1=right)
105 y_anchor: Vertical anchor (0=top, 0.5=center, 1=bottom)
106
107 Returns:
108 Cropped PIL Image object (preserving original resolution)
109 """
110 img_width, img_height = img.size
111 if img_width <= 0 or img_height <= 0:
112 raise ValueError('source image dimensions must be positive')
113 if target_width <= 0 or target_height <= 0:
114 raise ValueError('target image dimensions must be positive')
115
116 # Calculate target aspect ratio
117 target_ratio = target_width / target_height
118 img_ratio = img_width / img_height
119
120 # Calculate crop region on the original image based on ratio (no scaling)
121 if img_ratio > target_ratio:
122 # Original image is wider; crop left and right sides
123 crop_height = img_height
124 crop_width = max(1, min(img_width, int(round(img_height * target_ratio))))
125 else:
126 # Original image is taller; crop top and bottom sides
127 crop_width = img_width
128 crop_height = max(1, min(img_height, int(round(img_width / target_ratio))))
129
130 # Calculate crop position based on anchor point
131 extra_width = img_width - crop_width
132 extra_height = img_height - crop_height
133
134 left = int(extra_width * x_anchor)
135 top = int(extra_height * y_anchor)
136 right = left + crop_width
137 bottom = top + crop_height
138
139 # Crop only, no scaling
140 return img.crop((left, top, right, bottom))
141
142
143 def process_svg_images(
144 svg_file: str,
145 output_dir: str | Path | None = None,
146 dry_run: bool = False,
147 verbose: bool = True,
148 ) -> tuple[int, int]:
149 """
150 Process images in an SVG file, cropping based on the preserveAspectRatio attribute.
151
152 Args:
153 svg_file: SVG file path
154 output_dir: Output directory for cropped images (default: images/cropped/)
155 dry_run: Preview only, no actual processing
156 verbose: Verbose output
157
158 Returns:
159 (processed_count, error_count)
160 """
161 svg_path = Path(svg_file)
162 svg_dir = svg_path.parent
163
164 # Default output directory
165 if output_dir is None:
166 # Find the project's images directory
167 # Parent directory of svg_output or svg_final, under images
168 project_dir = svg_dir.parent
169 output_dir = project_dir / 'images' / 'cropped'
170 else:
171 output_dir = Path(output_dir)
172
173 # Parse SVG
174 try:
175 ET.register_namespace('', 'http://www.w3.org/2000/svg')
176 ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
177 tree = ET.parse(str(svg_path))
178 root = tree.getroot()
179 except Exception as e:
180 if verbose:
181 print(f" [ERROR] Failed to parse SVG: {e}")
182 return (0, 1)
183
184 ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
185
186 processed_count = 0
187 error_count = 0
188 modified = False
189
190 # Find all image elements
191 for image in root.iter('{http://www.w3.org/2000/svg}image'):
192 # Get href attribute
193 href = image.get('{http://www.w3.org/1999/xlink}href') or image.get('href')
194 if not href:
195 continue
196
197 # Skip Base64 inline images
198 if href.startswith('data:'):
199 continue
200
201 # Get preserveAspectRatio attribute
202 par = image.get('preserveAspectRatio', '')
203 align, mode = parse_preserve_aspect_ratio(par)
204
205 # Only process slice mode
206 if mode != 'slice':
207 continue
208
209 # Get target dimensions
210 try:
211 target_width = int(float(image.get('width', 0)))
212 target_height = int(float(image.get('height', 0)))
213 except (ValueError, TypeError):
214 continue
215
216 if target_width <= 0 or target_height <= 0:
217 continue
218
219 # Parse image path
220 href_decoded = unquote(href)
221 if href_decoded.startswith('../'):
222 img_path = (svg_dir / href_decoded).resolve()
223 else:
224 img_path = (svg_dir / href_decoded).resolve()
225
226 if not img_path.exists():
227 if verbose:
228 print(f" [SKIP] Image not found: {href}")
229 continue
230
231 # Get crop anchor point
232 x_anchor, y_anchor = get_crop_anchor(align)
233
234 if dry_run:
235 if verbose:
236 print(f" [DRY] {img_path.name} -> {target_width}x{target_height} "
237 f"(align: {align}, anchor: {x_anchor},{y_anchor})")
238 processed_count += 1
239 continue
240
241 # Create output directory
242 output_dir.mkdir(parents=True, exist_ok=True)
243
244 try:
245 # Open and process image
246 img = Image.open(img_path)
247 output_is_png = img_path.suffix.lower() == '.png'
248
249 # Preserve alpha for PNG assets such as translucent overlays.
250 if output_is_png:
251 if img.mode == 'P':
252 img = img.convert('RGBA')
253 elif img.mode not in ('RGBA', 'LA', 'RGB', 'L'):
254 img = img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
255 else:
256 if img.mode in ('RGBA', 'LA'):
257 background = Image.new('RGB', img.size, (255, 255, 255))
258 alpha = img.getchannel('A')
259 background.paste(img.convert('RGB'), mask=alpha)
260 img = background
261 elif img.mode == 'P':
262 img = img.convert('RGB')
263 elif img.mode not in ('RGB', 'L'):
264 img = img.convert('RGB')
265
266 # Crop
267 cropped = crop_image_to_size(img, target_width, target_height, x_anchor, y_anchor)
268
269 # Generate output filename (keep original name, place in cropped directory)
270 output_filename = img_path.name
271 output_path = output_dir / output_filename
272
273 # Save
274 if output_is_png:
275 cropped.save(output_path, 'PNG', optimize=True)
276 else:
277 cropped.save(output_path, 'JPEG', quality=90, optimize=True)
278
279 if verbose:
280 print(f" [OK] {img_path.name}: {img.size} -> {target_width}x{target_height} "
281 f"({align})")
282
283 # Update image path in SVG
284 new_href = f"../images/cropped/{output_filename}"
285 if image.get('{http://www.w3.org/1999/xlink}href'):
286 image.set('{http://www.w3.org/1999/xlink}href', new_href)
287 else:
288 image.set('href', new_href)
289
290 # Remove preserveAspectRatio (image is now correctly sized)
291 if 'preserveAspectRatio' in image.attrib:
292 del image.attrib['preserveAspectRatio']
293
294 modified = True
295 processed_count += 1
296
297 except Exception as e:
298 if verbose:
299 print(f" [ERROR] {img_path.name}: {e}")
300 error_count += 1
301
302 # Save modified SVG
303 if modified and not dry_run:
304 tree.write(str(svg_path), encoding='unicode', xml_declaration=False)
305
306 return (processed_count, error_count)
307
308
309 def process_directory(directory: str, dry_run: bool = False, verbose: bool = True) -> tuple[int, int]:
310 """Process all SVG files in a directory."""
311 directory_path = Path(directory)
312 total_processed = 0
313 total_errors = 0
314
315 for svg_file in directory_path.glob('*.svg'):
316 if verbose:
317 print(f" Processing: {svg_file.name}")
318 processed, errors = process_svg_images(str(svg_file), dry_run=dry_run, verbose=verbose)
319 total_processed += processed
320 total_errors += errors
321
322 return (total_processed, total_errors)
323
324
325 def main() -> None:
326 """Run the CLI entry point."""
327 parser = argparse.ArgumentParser(
328 description='PPT Master - Smart Image Cropping Tool',
329 formatter_class=argparse.RawDescriptionHelpFormatter,
330 epilog='''
331 Examples:
332 %(prog)s projects/my_project/svg_output
333 %(prog)s page_01.svg --dry-run
334
335 preserveAspectRatio usage:
336 xMidYMid slice Center crop (default)
337 xMidYMin slice Keep top
338 xMidYMax slice Keep bottom
339 xMinYMid slice Keep left
340 xMaxYMid slice Keep right
341 xMidYMid meet Display fully, no cropping
342 '''
343 )
344
345 parser.add_argument('path', type=Path, help='SVG file or directory')
346 parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, no actual processing')
347 parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode')
348
349 args = parser.parse_args()
350
351 if not args.path.exists():
352 print(f"[ERROR] Path not found: {args.path}")
353 sys.exit(1)
354
355 print("PPT Master - Smart Image Cropping")
356 print("=" * 50)
357
358 if args.path.is_file():
359 processed, errors = process_svg_images(str(args.path), dry_run=args.dry_run,
360 verbose=not args.quiet)
361 else:
362 processed, errors = process_directory(str(args.path), dry_run=args.dry_run,
363 verbose=not args.quiet)
364
365 print()
366 print(f"Done: {processed} image(s) cropped, {errors} error(s)")
367
368
369 if __name__ == '__main__':
370 main()
371
371 lines PYTHON