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 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"})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue