| 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 |