Files

176 lines
6.2 KiB
Python

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