From c503f5e0742b013d3f82e7ff91ff29997f1b4564 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 08:26:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redimensionnement=20d'images=20depuis?= =?UTF-8?q?=20la=20pr=C3=A9visualisation=20CDN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute une carte interactive sous chaque aperçu d'image permettant de générer des copies redimensionnées directement dans le même dossier CDN. - Route POST /resize avec Pillow (PNG, JPG, ICO) et cairosvg optionnel (SVG) - Tailles disponibles : 32, 64, 100, 128, 200, 300, 500, 600, 1024 px (carré) - Formats : png, jpg, ico (svg uniquement si la source est déjà SVG) - Nommage automatique : {nom}_{taille}x{taille}.{ext} - UI chips cliquables, soumission AJAX, retour avec liens directs Co-Authored-By: Claude Sonnet 4.6 --- app/app.py | 111 +++++++++++++++++++++++++++++++ app/requirements.txt | 1 + app/static/app.css | 33 +++++++++ app/templates/preview_image.html | 105 +++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) diff --git a/app/app.py b/app/app.py index 7fb7b9b..06a635f 100644 --- a/app/app.py +++ b/app/app.py @@ -1,11 +1,14 @@ +import io import json import os +import shutil import subprocess import threading from pathlib import Path from datetime import datetime from urllib.parse import urlencode +from PIL import Image from flask import (Flask, redirect, url_for, session, request, render_template, abort, send_from_directory, jsonify) @@ -48,7 +51,16 @@ _HIDDEN = frozenset({ "standard_index.html", }) +try: + import cairosvg as _cairosvg + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp"}) + +RESIZE_SIZES = frozenset({32, 64, 100, 128, 200, 300, 500, 600, 1024}) +RESIZE_FORMATS = frozenset({"png", "jpg", "ico", "svg"}) TEXT_EXT = frozenset({".txt", ".html", ".htm", ".md", ".css", ".js", ".json", ".xml", ".conf", ".sh", ".robots", ".php"}) PDF_EXT = frozenset({".pdf"}) @@ -275,6 +287,7 @@ def _file_preview(path: Path, subpath: str): prev_path=prev_path, next_path=next_path, sibling_pos=f"{idx + 1}/{len(siblings)}" if idx is not None else "", + ext=ext, ) if ext in IMAGE_EXT: return render_template("preview_image.html", **ctx) @@ -491,6 +504,104 @@ def upload_file(): return redirect(url_for("browse", subpath=subpath) if subpath else url_for("browse")) +# ── Redimensionnement d'images ──────────────────────────────────────── + +@app.route("/resize", methods=["POST"]) +def resize_image(): + redir = _require_admin() + if redir: + return redir + + subpath = request.form.get("path", "").strip() + raw_sizes = request.form.getlist("sizes") + raw_formats = request.form.getlist("formats") + + if not subpath or not raw_sizes or not raw_formats: + return jsonify({"error": "Paramètres manquants"}), 400 + + target = _safe_path(subpath) + if not target.is_file(): + abort(404) + + ext = target.suffix.lower() + if ext not in IMAGE_EXT: + abort(400) + + top = Path(subpath).parts[0] + if top in _HIDDEN or top.startswith("."): + abort(403) + + try: + sizes = sorted({int(s) for s in raw_sizes if int(s) in RESIZE_SIZES}) + except (ValueError, TypeError): + return jsonify({"error": "Tailles invalides"}), 400 + + formats = [f for f in raw_formats if f in RESIZE_FORMATS] + if not sizes or not formats: + return jsonify({"error": "Tailles ou formats invalides"}), 400 + + is_svg = ext == ".svg" + stem = target.stem + parent = target.parent + created, errors = [], [] + + for fmt in formats: + for size in sizes: + out_name = f"{stem}_{size}x{size}.{fmt}" + out_path = parent / out_name + out_rel = str(out_path.relative_to(ASSETS_ROOT)) + try: + if fmt == "svg" and is_svg: + shutil.copy2(target, out_path) + created.append({"name": out_name, "path": out_rel}) + + elif fmt == "svg" and not is_svg: + errors.append({"name": out_name, "reason": "Conversion raster→SVG non supportée"}) + + elif is_svg: + if not HAS_CAIROSVG: + errors.append({"name": out_name, "reason": "cairosvg non disponible sur ce serveur"}) + continue + if fmt in ("png", "ico"): + buf = io.BytesIO() + _cairosvg.svg2png(url=str(target), write_to=buf, + output_width=size, output_height=size) + buf.seek(0) + img = Image.open(buf).convert("RGBA") + if fmt == "ico": + img.save(out_path, format="ICO", sizes=[(size, size)]) + else: + img.save(out_path, format="PNG") + else: # jpg + buf = io.BytesIO() + _cairosvg.svg2png(url=str(target), write_to=buf, + output_width=size, output_height=size) + buf.seek(0) + img = Image.open(buf).convert("RGB") + img.save(out_path, format="JPEG", quality=90) + created.append({"name": out_name, "path": out_rel}) + + else: + img = Image.open(target) + img = img.resize((size, size), Image.LANCZOS) + if fmt == "png": + if img.mode not in ("RGBA", "RGB", "L"): + img = img.convert("RGBA") + img.save(out_path, format="PNG") + elif fmt == "jpg": + img = img.convert("RGB") + img.save(out_path, format="JPEG", quality=90) + elif fmt == "ico": + img = img.convert("RGBA") + img.save(out_path, format="ICO", sizes=[(size, size)]) + created.append({"name": out_name, "path": out_rel}) + + except Exception as exc: + errors.append({"name": out_name, "reason": str(exc)}) + + return jsonify({"created": created, "errors": errors}) + + # ── Fichiers CDN publics (dev local uniquement) ─────────────────────── _PUBLIC_TOP = frozenset({"logo", "wiki", "error"}) diff --git a/app/requirements.txt b/app/requirements.txt index ed4447a..ec36a02 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -3,3 +3,4 @@ authlib>=1.3 requests>=2.31 gunicorn>=21.0 python-dotenv>=1.0 +Pillow>=10.0 diff --git a/app/static/app.css b/app/static/app.css index a73af85..0e9b863 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -155,6 +155,39 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl .drop-text { font-size: .9rem; color: var(--muted); line-height: 1.5; pointer-events: none; } .drop-names { font-size: .82rem; color: var(--blue-dark); font-style: italic; word-break: break-all; pointer-events: none; } +/* ── Redimensionnement ────────────────────────────────────────────── */ +.resize-card h2 { margin-bottom: 1rem; } +.resize-body { display: flex; flex-direction: column; gap: 1rem; } +.resize-group { display: flex; flex-direction: column; gap: .5rem; } +.resize-group-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; } +.resize-chips { display: flex; flex-wrap: wrap; gap: .5rem; } + +.chip { display: inline-flex; align-items: center; cursor: pointer; } +.chip input { position: absolute; opacity: 0; width: 0; height: 0; } +.chip span { + display: inline-block; padding: .3rem .75rem; border-radius: 20px; + border: 2px solid var(--border); background: #fff; font-size: .85rem; + color: var(--text); transition: border-color .12s, background .12s, color .12s; + user-select: none; +} +.chip input:checked + span { border-color: var(--blue); background: var(--blue); color: #fff; } +.chip:hover:not(.chip--disabled) span { border-color: var(--blue); } +.chip--disabled { cursor: not-allowed; opacity: .45; } +.chip--disabled span { background: #f3f4f6; color: #9ca3af; } + +.resize-actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } +.resize-hint { font-size: .82rem; color: var(--muted); font-style: italic; } + +.resize-result { border-top: 1px solid var(--border); padding-top: .9rem; font-size: .88rem; } +.resize-ok-title { color: #15803d; font-weight: 600; margin-bottom: .35rem; } +.resize-err-title { color: #b91c1c; font-weight: 600; margin-bottom: .35rem; } +.resize-none { color: var(--muted); font-style: italic; } +.resize-list { list-style: none; margin: 0 0 .6rem 0; padding: 0; display: flex; flex-wrap: wrap; gap: .3rem .8rem; } +.resize-list li { font-size: .83rem; } +.resize-list a { color: var(--blue); font-family: monospace; } +.resize-list--err li { color: #b91c1c; } +.resize-list--err code { background: #fef2f2; border-radius: 3px; padding: .1rem .3rem; } + /* ── Responsive ───────────────────────────────────────────────────── */ @media (max-width: 700px) { .header-inner { flex-wrap: wrap; } diff --git a/app/templates/preview_image.html b/app/templates/preview_image.html index bacd511..5b93537 100644 --- a/app/templates/preview_image.html +++ b/app/templates/preview_image.html @@ -23,4 +23,109 @@ {{ filename }} +
+

Créer des copies redimensionnées

+
+ +
+
Tailles (px)
+
+ {% for size in [32, 64, 100, 128, 200, 300, 500, 600, 1024] %} + + {% endfor %} +
+
+ +
+
Formats
+
+ {% for fmt in ['png', 'jpg', 'ico'] %} + + {% endfor %} + +
+
+ +
+ + Les fichiers sont créés dans le même dossier +
+ + + +
+
+ + + {% endblock %}