#!/usr/bin/env python3 """ PPT Master - Smart Image Cropping Tool Smartly crops images based on the preserveAspectRatio attribute of elements in SVG: - slice: Crop to fill (similar to CSS object-fit: cover) - meet: Display fully without cropping (similar to CSS object-fit: contain) Supports 9 alignment modes: - xMinYMin / xMidYMin / xMaxYMin (top alignment) - xMinYMid / xMidYMid / xMaxYMid (vertical center) - xMinYMax / xMidYMax / xMaxYMax (bottom alignment) Usage: python3 scripts/svg_finalize/crop_images.py [--dry-run] """ import os import re import hashlib import sys import argparse from pathlib import Path from xml.etree import ElementTree as ET from urllib.parse import unquote try: from PIL import Image except ImportError: print("Error: PIL (Pillow) is required. Run: pip install Pillow") exit(1) def parse_preserve_aspect_ratio(attr: str) -> tuple[str, str]: """ Parse the preserveAspectRatio attribute. Returns: (align, meet_or_slice) align: e.g. 'xMidYMid' meet_or_slice: 'meet' or 'slice' """ if not attr: return ('xMidYMid', 'meet') # Default value parts = attr.strip().split() align = parts[0] if parts else 'xMidYMid' meet_or_slice = parts[1] if len(parts) > 1 else 'meet' return (align, meet_or_slice) def get_crop_anchor(align: str) -> tuple[float, float]: """ Return the crop anchor point based on the align value. Returns: (x_anchor, y_anchor) x_anchor: 0.0 (left), 0.5 (center), 1.0 (right) y_anchor: 0.0 (top), 0.5 (center), 1.0 (bottom) """ x_map = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0} y_map = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0} x_anchor = 0.5 y_anchor = 0.5 for key, val in x_map.items(): if key in align: x_anchor = val break for key, val in y_map.items(): if key in align: y_anchor = val break return (x_anchor, y_anchor) def crop_image_to_size( img: Image.Image, target_width: int, target_height: int, x_anchor: float = 0.5, y_anchor: float = 0.5, ) -> Image.Image: """ Crop an image to the target aspect ratio, preserving original resolution (no scaling). New logic: Only crops the original image to the target aspect ratio without any scaling, thus preserving the original resolution and clarity. Args: img: PIL Image object target_width: Target width (used to calculate ratio) target_height: Target height (used to calculate ratio) x_anchor: Horizontal anchor (0=left, 0.5=center, 1=right) y_anchor: Vertical anchor (0=top, 0.5=center, 1=bottom) Returns: Cropped PIL Image object (preserving original resolution) """ img_width, img_height = img.size # Calculate target aspect ratio target_ratio = target_width / target_height img_ratio = img_width / img_height # Calculate crop region on the original image based on ratio (no scaling) if img_ratio > target_ratio: # Original image is wider; crop left and right sides crop_height = img_height crop_width = int(img_height * target_ratio) else: # Original image is taller; crop top and bottom sides crop_width = img_width crop_height = int(img_width / target_ratio) # Calculate crop position based on anchor point extra_width = img_width - crop_width extra_height = img_height - crop_height left = int(extra_width * x_anchor) top = int(extra_height * y_anchor) right = left + crop_width bottom = top + crop_height # Crop only, no scaling return img.crop((left, top, right, bottom)) def process_svg_images( svg_file: str, output_dir: str | Path | None = None, dry_run: bool = False, verbose: bool = True, ) -> tuple[int, int]: """ Process images in an SVG file, cropping based on the preserveAspectRatio attribute. Args: svg_file: SVG file path output_dir: Output directory for cropped images (default: images/cropped/) dry_run: Preview only, no actual processing verbose: Verbose output Returns: (processed_count, error_count) """ svg_path = Path(svg_file) svg_dir = svg_path.parent # Default output directory if output_dir is None: # Find the project's images directory # Parent directory of svg_output or svg_final, under images project_dir = svg_dir.parent output_dir = project_dir / 'images' / 'cropped' else: output_dir = Path(output_dir) # Parse SVG try: ET.register_namespace('', 'http://www.w3.org/2000/svg') ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink') tree = ET.parse(str(svg_path)) root = tree.getroot() except Exception as e: if verbose: print(f" [ERROR] Failed to parse SVG: {e}") return (0, 1) ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'} processed_count = 0 error_count = 0 modified = False # Find all image elements for image in root.iter('{http://www.w3.org/2000/svg}image'): # Get href attribute href = image.get('{http://www.w3.org/1999/xlink}href') or image.get('href') if not href: continue # Skip Base64 inline images if href.startswith('data:'): continue # Get preserveAspectRatio attribute par = image.get('preserveAspectRatio', '') align, mode = parse_preserve_aspect_ratio(par) # Only process slice mode if mode != 'slice': continue # Get target dimensions try: target_width = int(float(image.get('width', 0))) target_height = int(float(image.get('height', 0))) except (ValueError, TypeError): continue if target_width <= 0 or target_height <= 0: continue # Parse image path href_decoded = unquote(href) if href_decoded.startswith('../'): img_path = (svg_dir / href_decoded).resolve() else: img_path = (svg_dir / href_decoded).resolve() if not img_path.exists(): if verbose: print(f" [SKIP] Image not found: {href}") continue # Get crop anchor point x_anchor, y_anchor = get_crop_anchor(align) if dry_run: if verbose: print(f" [DRY] {img_path.name} -> {target_width}x{target_height} " f"(align: {align}, anchor: {x_anchor},{y_anchor})") processed_count += 1 continue # Create output directory output_dir.mkdir(parents=True, exist_ok=True) try: # Open and process image img = Image.open(img_path) output_is_png = img_path.suffix.lower() == '.png' # Preserve alpha for PNG assets such as translucent overlays. if output_is_png: if img.mode == 'P': img = img.convert('RGBA') elif img.mode not in ('RGBA', 'LA', 'RGB', 'L'): img = img.convert('RGBA' if 'A' in img.getbands() else 'RGB') else: if img.mode in ('RGBA', 'LA'): background = Image.new('RGB', img.size, (255, 255, 255)) alpha = img.getchannel('A') background.paste(img.convert('RGB'), mask=alpha) img = background elif img.mode == 'P': img = img.convert('RGB') elif img.mode not in ('RGB', 'L'): img = img.convert('RGB') # Crop cropped = crop_image_to_size(img, target_width, target_height, x_anchor, y_anchor) # Generate output filename (keep original name, place in cropped directory) output_filename = img_path.name output_path = output_dir / output_filename # Save if output_is_png: cropped.save(output_path, 'PNG', optimize=True) else: cropped.save(output_path, 'JPEG', quality=90, optimize=True) if verbose: print(f" [OK] {img_path.name}: {img.size} -> {target_width}x{target_height} " f"({align})") # Update image path in SVG new_href = f"../images/cropped/{output_filename}" if image.get('{http://www.w3.org/1999/xlink}href'): image.set('{http://www.w3.org/1999/xlink}href', new_href) else: image.set('href', new_href) # Remove preserveAspectRatio (image is now correctly sized) if 'preserveAspectRatio' in image.attrib: del image.attrib['preserveAspectRatio'] modified = True processed_count += 1 except Exception as e: if verbose: print(f" [ERROR] {img_path.name}: {e}") error_count += 1 # Save modified SVG if modified and not dry_run: tree.write(str(svg_path), encoding='unicode', xml_declaration=False) return (processed_count, error_count) def process_directory(directory: str, dry_run: bool = False, verbose: bool = True) -> tuple[int, int]: """Process all SVG files in a directory.""" directory_path = Path(directory) total_processed = 0 total_errors = 0 for svg_file in directory_path.glob('*.svg'): if verbose: print(f" Processing: {svg_file.name}") processed, errors = process_svg_images(str(svg_file), dry_run=dry_run, verbose=verbose) total_processed += processed total_errors += errors return (total_processed, total_errors) def main() -> None: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description='PPT Master - Smart Image Cropping Tool', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: %(prog)s projects/my_project/svg_output %(prog)s page_01.svg --dry-run preserveAspectRatio usage: xMidYMid slice Center crop (default) xMidYMin slice Keep top xMidYMax slice Keep bottom xMinYMid slice Keep left xMaxYMid slice Keep right xMidYMid meet Display fully, no cropping ''' ) parser.add_argument('path', type=Path, help='SVG file or directory') parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, no actual processing') parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode') args = parser.parse_args() if not args.path.exists(): print(f"[ERROR] Path not found: {args.path}") sys.exit(1) print("PPT Master - Smart Image Cropping") print("=" * 50) if args.path.is_file(): processed, errors = process_svg_images(str(args.path), dry_run=args.dry_run, verbose=not args.quiet) else: processed, errors = process_directory(str(args.path), dry_run=args.dry_run, verbose=not args.quiet) print() print(f"Done: {processed} image(s) cropped, {errors} error(s)") if __name__ == '__main__': main()