feat: redimensionnement d'images depuis la prévisualisation CDN

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 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 08:26:51 +02:00
parent 0079c9297a
commit c503f5e074
4 changed files with 250 additions and 0 deletions

View file

@ -1,11 +1,14 @@
import io
import json import json
import os import os
import shutil
import subprocess import subprocess
import threading import threading
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from PIL import Image
from flask import (Flask, redirect, url_for, session, request, from flask import (Flask, redirect, url_for, session, request,
render_template, abort, send_from_directory, jsonify) render_template, abort, send_from_directory, jsonify)
@ -48,7 +51,16 @@ _HIDDEN = frozenset({
"standard_index.html", "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"}) 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", TEXT_EXT = frozenset({".txt", ".html", ".htm", ".md", ".css", ".js", ".json",
".xml", ".conf", ".sh", ".robots", ".php"}) ".xml", ".conf", ".sh", ".robots", ".php"})
PDF_EXT = frozenset({".pdf"}) PDF_EXT = frozenset({".pdf"})
@ -275,6 +287,7 @@ def _file_preview(path: Path, subpath: str):
prev_path=prev_path, prev_path=prev_path,
next_path=next_path, next_path=next_path,
sibling_pos=f"{idx + 1}/{len(siblings)}" if idx is not None else "", sibling_pos=f"{idx + 1}/{len(siblings)}" if idx is not None else "",
ext=ext,
) )
if ext in IMAGE_EXT: if ext in IMAGE_EXT:
return render_template("preview_image.html", **ctx) 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")) 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) ─────────────────────── # ── Fichiers CDN publics (dev local uniquement) ───────────────────────
_PUBLIC_TOP = frozenset({"logo", "wiki", "error"}) _PUBLIC_TOP = frozenset({"logo", "wiki", "error"})

View file

@ -3,3 +3,4 @@ authlib>=1.3
requests>=2.31 requests>=2.31
gunicorn>=21.0 gunicorn>=21.0
python-dotenv>=1.0 python-dotenv>=1.0
Pillow>=10.0

View file

@ -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-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; } .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 ───────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 700px) { @media (max-width: 700px) {
.header-inner { flex-wrap: wrap; } .header-inner { flex-wrap: wrap; }

View file

@ -23,4 +23,109 @@
<img src="{{ raw_url }}" alt="{{ filename }}"> <img src="{{ raw_url }}" alt="{{ filename }}">
</div> </div>
<section class="card resize-card">
<h2>Créer des copies redimensionnées</h2>
<div class="resize-body">
<div class="resize-group">
<div class="resize-group-label">Tailles (px)</div>
<div class="resize-chips">
{% for size in [32, 64, 100, 128, 200, 300, 500, 600, 1024] %}
<label class="chip">
<input type="checkbox" class="resize-sz" value="{{ size }}">
<span>{{ size }}×{{ size }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="resize-group">
<div class="resize-group-label">Formats</div>
<div class="resize-chips">
{% for fmt in ['png', 'jpg', 'ico'] %}
<label class="chip">
<input type="checkbox" class="resize-fmt" value="{{ fmt }}">
<span>.{{ fmt }}</span>
</label>
{% endfor %}
<label class="chip {% if ext != '.svg' %}chip--disabled{% endif %}"
title="{% if ext != '.svg' %}SVG uniquement disponible si la source est SVG{% endif %}">
<input type="checkbox" class="resize-fmt" value="svg"
{% if ext != '.svg' %}disabled{% endif %}>
<span>.svg</span>
</label>
</div>
</div>
<div class="resize-actions">
<button id="resize-btn" class="btn btn-primary" disabled>Générer les copies</button>
<span class="resize-hint">Les fichiers sont créés dans le même dossier</span>
</div>
<div id="resize-result" class="resize-result" style="display:none"></div>
</div>
</section>
<script>
(function () {
const szCbs = document.querySelectorAll('.resize-sz');
const fmtCbs = document.querySelectorAll('.resize-fmt');
const btn = document.getElementById('resize-btn');
const result = document.getElementById('resize-result');
const PATH = {{ subpath | tojson }};
const URL_RESIZE = {{ url_for('resize_image') | tojson }};
function canSubmit() {
return Array.from(szCbs).some(c => c.checked)
&& Array.from(fmtCbs).some(c => c.checked);
}
[...szCbs, ...fmtCbs].forEach(c => c.addEventListener('change', () => {
btn.disabled = !canSubmit();
}));
btn.addEventListener('click', async () => {
const fd = new FormData();
fd.append('path', PATH);
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
btn.disabled = true;
btn.textContent = 'Génération en cours…';
result.style.display = 'none';
try {
const resp = await fetch(URL_RESIZE, { method: 'POST', body: fd });
const data = await resp.json();
let html = '';
if (data.created && data.created.length) {
html += '<p class="resize-ok-title">✓ ' + data.created.length + ' fichier(s) créé(s)</p>';
html += '<ul class="resize-list">';
data.created.forEach(f => {
html += '<li><a href="/browse/' + f.path + '">' + f.name + '</a></li>';
});
html += '</ul>';
}
if (data.errors && data.errors.length) {
html += '<p class="resize-err-title">⚠ ' + data.errors.length + ' erreur(s)</p>';
html += '<ul class="resize-list resize-list--err">';
data.errors.forEach(e => {
html += '<li><code>' + (e.name || '') + '</code> : ' + e.reason + '</li>';
});
html += '</ul>';
}
if (!html) html = '<p class="resize-none">Aucun fichier généré.</p>';
result.innerHTML = html;
} catch (_) {
result.innerHTML = '<p class="resize-err-title">Erreur réseau.</p>';
}
result.style.display = 'block';
btn.textContent = 'Générer les copies';
btn.disabled = !canSubmit();
});
})();
</script>
{% endblock %} {% endblock %}