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:
parent
8f6aa292ef
commit
3e8b18b127
3 changed files with 137 additions and 26 deletions
71
app/app.py
71
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)
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue