feat: dimension libre pour le redimensionnement d'images

Ajout d'un champ W×H dans la carte resize, contraint à la résolution
source. Option "carré" synchronise les deux valeurs. Le bouton Générer
s'active si au moins un format est sélectionné et une taille valide
(prédéfinie ou libre) est renseignée.

Supprime le code mort dans la route /resize (errors_pre).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 10:03:48 +02:00
parent 8f6aa292ef
commit 3e8b18b127
3 changed files with 137 additions and 26 deletions

View file

@ -692,10 +692,19 @@ def check_resize():
stem = re.sub(r"_\d+x\d+$", "", target.stem)
parent = target.parent
all_dims = [(s, s) for s in sizes]
for cs in request.form.getlist("custom_sizes"):
try:
w, h = (int(x.strip()) for x in re.split(r"[x×]", cs.lower()))
if w >= 1 and h >= 1:
all_dims.append((w, h))
except (ValueError, TypeError):
pass
conflicts = []
for fmt in formats:
for size in sizes:
out_name = f"{stem}_{size}x{size}.{fmt}"
for w, h in all_dims:
out_name = f"{stem}_{w}x{h}.{fmt}"
if (parent / out_name).exists():
conflicts.append(out_name)
@ -713,8 +722,9 @@ def resize_image():
subpath = request.form.get("path", "").strip()
raw_sizes = request.form.getlist("sizes")
raw_formats = request.form.getlist("formats")
raw_customs = request.form.getlist("custom_sizes")
if not subpath or not raw_sizes or not raw_formats:
if not subpath or not raw_formats:
return jsonify({"error": "Paramètres manquants"}), 400
target = _safe_path(subpath)
@ -730,13 +740,32 @@ def resize_image():
abort(403)
try:
sizes = sorted({int(s) for s in raw_sizes if int(s) in RESIZE_SIZES})
square_dims = [(s, s) for s in sorted({int(s) for s in raw_sizes if int(s) in RESIZE_SIZES})]
except (ValueError, TypeError):
return jsonify({"error": "Tailles invalides"}), 400
square_dims = []
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
# Dimensions libres — validées contre la résolution source
src_img = Image.open(target)
max_w, max_h = src_img.width, src_img.height
src_img.close()
custom_dims = []
for cs in raw_customs:
try:
w, h = (int(x.strip()) for x in re.split(r"[x×]", cs.lower()))
if w < 1 or h < 1:
continue
if w > max_w or h > max_h:
continue
custom_dims.append((w, h))
except (ValueError, TypeError):
pass
all_dims = square_dims + custom_dims
if not all_dims or not formats:
return jsonify({"error": "Aucune dimension ou format valide"}), 400
conflict = request.form.get("conflict", "skip")
if conflict not in ("backup", "overwrite", "rename", "skip"):
@ -747,9 +776,20 @@ def resize_image():
parent = target.parent
created, errors = [], []
# Signaler les dimensions hors-bornes
for cs in raw_customs:
try:
w, h = (int(x.strip()) for x in re.split(r"[x×]", cs.lower()))
if w > max_w or h > max_h:
for f in formats:
errors.append({"name": f"{stem}_{w}x{h}.{f}",
"reason": f"{w}×{h} dépasse la résolution source ({max_w}×{max_h})"})
except (ValueError, TypeError):
pass
for fmt in formats:
for size in sizes:
out_name = f"{stem}_{size}x{size}.{fmt}"
for w, h in all_dims:
out_name = f"{stem}_{w}x{h}.{fmt}"
out_path = parent / out_name
out_rel = str(out_path.relative_to(ASSETS_ROOT))
@ -768,7 +808,6 @@ def resize_image():
out_path = _auto_rename(out_path)
out_name = out_path.name
out_rel = str(out_path.relative_to(ASSETS_ROOT))
# conflict == "overwrite" : on continue sans rien faire
try:
if fmt == "svg" and is_svg:
@ -785,17 +824,17 @@ def resize_image():
if fmt in ("png", "ico"):
buf = io.BytesIO()
_cairosvg.svg2png(url=str(target), write_to=buf,
output_width=size, output_height=size)
output_width=w, output_height=h)
buf.seek(0)
img = Image.open(buf).convert("RGBA")
if fmt == "ico":
img.save(out_path, format="ICO", sizes=[(size, size)])
img.save(out_path, format="ICO", sizes=[(w, h)])
else:
img.save(out_path, format="PNG")
else: # jpg
else:
buf = io.BytesIO()
_cairosvg.svg2png(url=str(target), write_to=buf,
output_width=size, output_height=size)
output_width=w, output_height=h)
buf.seek(0)
img = Image.open(buf).convert("RGB")
img.save(out_path, format="JPEG", quality=90)
@ -803,7 +842,7 @@ def resize_image():
else:
img = Image.open(target)
img = img.resize((size, size), Image.LANCZOS)
img = img.resize((w, h), Image.LANCZOS)
if fmt == "png":
if img.mode not in ("RGBA", "RGB", "L"):
img = img.convert("RGBA")
@ -813,7 +852,7 @@ def resize_image():
img.save(out_path, format="JPEG", quality=90)
elif fmt == "ico":
img = img.convert("RGBA")
img.save(out_path, format="ICO", sizes=[(size, size)])
img.save(out_path, format="ICO", sizes=[(w, h)])
created.append({"name": out_name, "path": out_rel})
except Exception as exc:

View file

@ -212,6 +212,16 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.resize-actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.resize-hint { font-size: .82rem; color: var(--muted); font-style: italic; }
.resize-custom-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.custom-dim { width: 5.5rem; padding: .35rem .5rem; border: 1.5px solid var(--border); border-radius: 6px;
font-size: .9rem; text-align: center; background: var(--surface); color: var(--text); }
.custom-dim:focus { outline: none; border-color: var(--accent); }
.custom-dim:invalid { border-color: #ef4444; }
.custom-dim-sep { font-size: 1rem; color: var(--muted); }
.custom-dim-unit { font-size: .82rem; color: var(--muted); }
.custom-square-lock { display: flex; align-items: center; gap: .3rem; font-size: .82rem;
color: var(--muted); cursor: pointer; user-select: none; }
.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; }

View file

@ -122,6 +122,26 @@
</div>
</div>
{% if meta and meta.width and meta.height %}
<div class="resize-group">
<div class="resize-group-label">Dimension libre
<span class="resize-hint">(max {{ meta.width }} × {{ meta.height }} px)</span>
</div>
<div class="resize-custom-row">
<input type="number" id="custom-w" class="custom-dim" min="1" max="{{ meta.width }}"
placeholder="largeur" step="1">
<span class="custom-dim-sep">×</span>
<input type="number" id="custom-h" class="custom-dim" min="1" max="{{ meta.height }}"
placeholder="hauteur" step="1">
<span class="custom-dim-unit">px</span>
<label class="custom-square-lock">
<input type="checkbox" id="custom-square"> carré
</label>
</div>
<span id="custom-error" class="rename-error"></span>
</div>
{% endif %}
<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>
@ -207,24 +227,66 @@
const cancelConflict= document.getElementById('resize-cancel-conflict');
const RESIZE_URL = {{ url_for('resize_image') | tojson }};
const CHECK_RESIZE_URL= {{ url_for('check_resize') | tojson }};
const customW = document.getElementById('custom-w');
const customH = document.getElementById('custom-h');
const customSq = document.getElementById('custom-square');
const customErr = document.getElementById('custom-error');
let resizeResolved = false;
function canSubmit() {
return Array.from(szCbs).some(c => c.checked)
&& Array.from(fmtCbs).some(c => c.checked);
/* Sync W↔H when "carré" is checked */
if (customW && customH && customSq) {
customW.addEventListener('input', () => {
if (customSq.checked) customH.value = customW.value;
updateBtn(); customErr.textContent = '';
});
customH.addEventListener('input', () => {
if (customSq.checked) customW.value = customH.value;
updateBtn(); customErr.textContent = '';
});
customSq.addEventListener('change', () => {
if (customSq.checked && customW.value) customH.value = customW.value;
updateBtn();
});
}
[...szCbs, ...fmtCbs].forEach(c => c.addEventListener('change', () => {
function getCustomDim() {
if (!customW || !customH) return null;
const w = parseInt(customW.value, 10);
const h = parseInt(customH.value, 10);
if (!w || !h || w < 1 || h < 1) return null;
const maxW = parseInt(customW.max, 10);
const maxH = parseInt(customH.max, 10);
if (maxW && w > maxW) return null;
if (maxH && h > maxH) return null;
return `${w}x${h}`;
}
function canSubmit() {
const hasSizes = Array.from(szCbs).some(c => c.checked) || getCustomDim() !== null;
const hasFmts = Array.from(fmtCbs).some(c => c.checked);
return hasSizes && hasFmts;
}
function updateBtn() {
btn.disabled = !canSubmit();
conflictPanel.style.display = 'none';
resizeResolved = false;
}));
}
[...szCbs, ...fmtCbs].forEach(c => c.addEventListener('change', updateBtn));
function appendDims(fd) {
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
const cd = getCustomDim();
if (cd) fd.append('custom_sizes', cd);
}
async function doResize(conflict) {
const fd = new FormData();
fd.append('path', FILE_PATH);
fd.append('conflict', conflict);
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
appendDims(fd);
btn.disabled = true;
btn.textContent = 'Génération en cours…';
@ -262,10 +324,10 @@
}
btn.addEventListener('click', async () => {
if (customErr) customErr.textContent = '';
const fd = new FormData();
fd.append('path', FILE_PATH);
szCbs.forEach(c => { if (c.checked) fd.append('sizes', c.value); });
fmtCbs.forEach(c => { if (c.checked) fd.append('formats', c.value); });
appendDims(fd);
let conflicts = [];
try {