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 {