from __future__ import annotations import os import uuid from pathlib import Path from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename try: from PIL import Image, ImageOps, UnidentifiedImageError PILLOW_AVAILABLE = True except ImportError: # pragma: no cover - local fallback when Pillow is unavailable Image = None ImageOps = None UnidentifiedImageError = OSError PILLOW_AVAILABLE = False ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} IMAGE_VARIANTS = { "sm": {"width": 320, "quality": 76}, "md": {"width": 720, "quality": 82}, "lg": {"width": 1280, "quality": 86}, } DEFAULT_RENDERED_FORMAT = "webp" ORIGINAL_MAX_WIDTH = 1600 def allowed_image_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS def ensure_upload_structure(upload_folder: str | Path) -> None: upload_root = Path(upload_folder) upload_root.mkdir(parents=True, exist_ok=True) (upload_root / "variants").mkdir(parents=True, exist_ok=True) for variant_name in IMAGE_VARIANTS: (upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True) def build_variant_filename(filename: str, variant_name: str) -> str: source = Path(filename) return f"{source.stem}__{variant_name}.webp" def build_variant_relative_path(filename: str, variant_name: str) -> str: return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}" def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None: if not filename: return upload_root = Path(upload_folder) original_path = upload_root / filename if original_path.exists(): original_path.unlink() for variant_name in IMAGE_VARIANTS: variant_path = upload_root / build_variant_relative_path(filename, variant_name) if variant_path.exists(): variant_path.unlink() def _open_image(upload: FileStorage) -> Image.Image: if not PILLOW_AVAILABLE or Image is None or ImageOps is None: raise OSError("Pillow ist nicht verfügbar.") upload.stream.seek(0) image = Image.open(upload.stream) image.load() return ImageOps.exif_transpose(image) def _prepare_image(image: Image.Image) -> Image.Image: if not PILLOW_AVAILABLE: return image if image.mode not in {"RGB", "RGBA"}: image = image.convert("RGBA" if "A" in image.getbands() else "RGB") return image def _resize_copy(image: Image.Image, width: int) -> Image.Image: resized = image.copy() resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS) return resized def _save_image(image: Image.Image, destination: Path, quality: int) -> None: destination.parent.mkdir(parents=True, exist_ok=True) image.save( destination, format=DEFAULT_RENDERED_FORMAT.upper(), quality=quality, method=6, ) def save_photo_with_variants( upload: FileStorage | None, upload_folder: str | Path, current_filename: str | None = None, ) -> str | None: if not upload or not upload.filename: return current_filename if not allowed_image_file(upload.filename): raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") ensure_upload_structure(upload_folder) original_name = secure_filename(upload.filename) extension = original_name.rsplit(".", 1)[1].lower() try: image = _prepare_image(_open_image(upload)) filename = f"{uuid.uuid4().hex}.webp" original_path = Path(upload_folder) / filename optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH) _save_image(optimized, original_path, quality=88) for variant_name, config in IMAGE_VARIANTS.items(): variant_image = _resize_copy(image, int(config["width"])) variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name) _save_image(variant_image, variant_path, quality=int(config["quality"])) except (UnidentifiedImageError, OSError, ValueError): filename = f"{uuid.uuid4().hex}.{extension}" original_path = Path(upload_folder) / filename upload.stream.seek(0) upload.save(original_path) if current_filename and current_filename != filename: remove_photo_assets(upload_folder, current_filename) return filename def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None: if not filename: return None if variant in IMAGE_VARIANTS: if upload_folder is not None: variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant) if variant_path.exists(): return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) else: return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) return url_builder("uploaded_file", filename=filename) def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str: if not filename: return "" parts = [] for variant_name, config in IMAGE_VARIANTS.items(): variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder) if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)): parts.append(f"{variant_url} {config['width']}w") original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320 parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w") return ", ".join(parts) def image_sizes(card: str = "grid") -> str: if card == "detail": return "(max-width: 720px) 100vw, 720px" return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px" def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool: if not upload or not upload.filename: return True stream = upload.stream current_position = stream.tell() stream.seek(0, os.SEEK_END) size = stream.tell() stream.seek(current_position) return size <= max_bytes