"""Thumbnail generation utilities using Pillow. Generates 200x200 thumbnails for image files (jpg, png, webp). Non-image files return None (no thumbnail). """ import logging from pathlib import Path from typing import Optional from PIL import Image, ImageOps logger = logging.getLogger(__name__) THUMBNAIL_SIZE = (200, 200) IMAGE_MIME_TYPES = { "image/jpeg", "image/png", "image/webp", } IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"} def is_image_mime_type(mime_type: str) -> bool: """Check if the given MIME type is a supported image type.""" return mime_type.lower() in IMAGE_MIME_TYPES def generate_thumbnail( source_path: str | Path, thumbnail_dir: str | Path, stored_filename: str, ) -> Optional[str]: """Generate a 200x200 thumbnail for an image file. Args: source_path: Path to the original image file. thumbnail_dir: Directory where thumbnails are stored. stored_filename: The stored filename of the original file. Returns: Relative path to the thumbnail file, or None if the file is not a supported image or thumbnail generation fails. """ source_path = Path(source_path) thumbnail_dir = Path(thumbnail_dir) # Check if the file extension is a supported image type ext = source_path.suffix.lower().lstrip(".") if ext not in IMAGE_EXTENSIONS: return None try: thumbnail_dir.mkdir(parents=True, exist_ok=True) thumbnail_name = f"thumb_{stored_filename}" # Use .jpg for thumbnails to save space if ext in ("jpg", "jpeg"): thumbnail_name = f"thumb_{Path(stored_filename).stem}.jpg" elif ext == "png": thumbnail_name = f"thumb_{Path(stored_filename).stem}.png" elif ext == "webp": thumbnail_name = f"thumb_{Path(stored_filename).stem}.webp" thumbnail_path = thumbnail_dir / thumbnail_name with Image.open(source_path) as img: # Convert to RGB if necessary (e.g., for RGBA/P mode images) if img.mode in ("RGBA", "P", "LA"): # Create a white background for transparency background = Image.new("RGB", img.size, (255, 255, 255)) if img.mode == "P": img = img.convert("RGBA") background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None) img = background elif img.mode != "RGB": img = img.convert("RGB") # Use ImageOps.fit for a centered crop to exact thumbnail size thumbnail = ImageOps.fit(img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS) thumbnail.save(thumbnail_path, quality=85, optimize=True) logger.info("Thumbnail generated: %s", thumbnail_path) return str(thumbnail_path) except Exception as exc: logger.error("Failed to generate thumbnail for %s: %s", source_path, exc) return None