From 3e8b18b1276c86a4530257d479f137ad8bcf948f Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 10:03:48 +0200 Subject: [PATCH] feat: dimension libre pour le redimensionnement d'images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/app.py | 73 ++++++++++++++++++++++------- app/static/app.css | 10 ++++ app/templates/preview_image.html | 80 ++++++++++++++++++++++++++++---- 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/app/app.py b/app/app.py index ce6f90e..e2d8467 100644 --- a/app/app.py +++ b/app/app.py @@ -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) @@ -710,11 +719,12 @@ def resize_image(): if redir: return redir - subpath = request.form.get("path", "").strip() + 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: diff --git a/app/static/app.css b/app/static/app.css index fa7f37e..c09e010 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -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; } diff --git a/app/templates/preview_image.html b/app/templates/preview_image.html index fb76e26..919ba87 100644 --- a/app/templates/preview_image.html +++ b/app/templates/preview_image.html @@ -122,6 +122,26 @@ + {% if meta and meta.width and meta.height %} +
+
Dimension libre + (max {{ meta.width }} × {{ meta.height }} px) +
+
+ + × + + px + +
+ +
+ {% endif %} +
Les fichiers sont créés dans le même dossier @@ -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 {