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:
parent
0079c9297a
commit
c503f5e074
4 changed files with 250 additions and 0 deletions
111
app/app.py
111
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"})
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ authlib>=1.3
|
|||
requests>=2.31
|
||||
gunicorn>=21.0
|
||||
python-dotenv>=1.0
|
||||
Pillow>=10.0
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -23,4 +23,109 @@
|
|||
<img src="{{ raw_url }}" alt="{{ filename }}">
|
||||
</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 %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue