176 lines
6.2 KiB
Python
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
|