475 lines
20 KiB
Python
475 lines
20 KiB
Python
"""Export multi-dalles : mosaïque d'images adjacentes pour consultation terrain.
|
||
|
||
Assemble les visualisations de plusieurs dalles LiDAR contiguës (1 km chacune,
|
||
Lambert 93) en une seule image (PNG/JPEG/WebP) ou un PDF à une page par
|
||
visualisation, avec habillage (bandeau titre, échelle, nord) et légende de la
|
||
couche exportée : titre, dégradé de la colormap avec bornes, lecture du rendu
|
||
couleurs ↔ terrain et méthode de calcul (textes de VIZ_LEGENDS, index.py).
|
||
Les dalles sont collées bord à bord, sans aucun trait ni espace entre elles :
|
||
les images sources (tif_to_crop) sont des carrés purs de 1 km qui se
|
||
joignent parfaitement.
|
||
|
||
Pensé pour un usage sur téléphone depuis la carte (webapp.py /api/export) :
|
||
Pillow seul, aucune dépendance au pipeline — fonctionne sur la webapp légère
|
||
(Raspberry Pi) depuis le cache local des tuiles.
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger("lidar")
|
||
|
||
# Formats de sortie acceptés (clé API → format Pillow).
|
||
EXPORT_FORMATS = {"png": "PNG", "jpeg": "JPEG", "webp": "WEBP", "pdf": "PDF"}
|
||
# Extension de fichier par format.
|
||
EXPORT_EXTS = {"png": "png", "jpeg": "jpg", "webp": "webp", "pdf": "pdf"}
|
||
|
||
# Garde-fou mémoire : mosaïque RGB au-delà de ~1,6 Go (Raspberry Pi) refusée
|
||
# en pleine résolution — l'utilisateur choisit alors une taille réduite.
|
||
_MAX_CANVAS_BYTES = 1_600_000_000
|
||
# PDF multi-pages : toutes les pages vivent en mémoire au moment de
|
||
# l'enregistrement (save_all) — côté maximal ramené à cette valeur.
|
||
_PDF_PAGE_MAX_PX = 6000
|
||
|
||
|
||
def _load_font(size, bold=False):
|
||
"""Police TrueType si disponible, sinon police embarquée de Pillow."""
|
||
from PIL import ImageFont
|
||
names = ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf") if bold else ("DejaVuSans.ttf",)
|
||
for name in names:
|
||
for directory in ("/usr/share/fonts/truetype/dejavu/",
|
||
"/usr/share/fonts/dejavu/",
|
||
"/usr/share/fonts/TTF/"):
|
||
path = directory + name
|
||
if Path(path).exists():
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
return ImageFont.load_default(size=size) # Pillow >= 10.1
|
||
except TypeError:
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def _hex_rgb(hx):
|
||
"""'#rrggbb' → (r, g, b)."""
|
||
hx = hx.lstrip('#')
|
||
return tuple(int(hx[i:i + 2], 16) for i in (0, 2, 4))
|
||
|
||
|
||
def _gradient_bar(gradient, width, height):
|
||
"""Barre de dégradé interpolée linéairement entre les arrêts de colormap."""
|
||
from PIL import Image
|
||
stops = [_hex_rgb(c) for c in gradient]
|
||
bar = Image.new("RGB", (width, height))
|
||
n = len(stops) - 1
|
||
for x in range(width):
|
||
t = x / max(width - 1, 1) * n
|
||
i = min(int(t), n - 1)
|
||
f = t - i
|
||
color = tuple(int(round(stops[i][k] + (stops[i + 1][k] - stops[i][k]) * f))
|
||
for k in range(3))
|
||
for y in range(height):
|
||
bar.putpixel((x, y), color)
|
||
return bar
|
||
|
||
|
||
def _wrap_text(measure, text, font, max_w):
|
||
"""Découpe un texte en lignes ≤ max_w px (les \\n explicites sont conservés)."""
|
||
lines = []
|
||
for para in text.split("\n"):
|
||
words = para.split()
|
||
if not words:
|
||
lines.append("")
|
||
continue
|
||
cur = words[0]
|
||
for word in words[1:]:
|
||
if measure.textlength(cur + " " + word, font=font) <= max_w:
|
||
cur += " " + word
|
||
else:
|
||
lines.append(cur)
|
||
cur = word
|
||
lines.append(cur)
|
||
return lines
|
||
|
||
|
||
def _find_tile_images(vis_dir, cells, viz_keys, resolution):
|
||
"""Chemins d'image par dalle : {(col, row): {viz_key: Path}}.
|
||
|
||
Raises:
|
||
ValueError: dalles absentes à la résolution demandée, ou visualisations
|
||
manquantes sur certaines dalles.
|
||
"""
|
||
from .index import scan_tiles
|
||
by_cell = {(t["col"], t["row"]): t for t in scan_tiles(vis_dir)
|
||
if t["resolution"] == resolution}
|
||
missing = [f"{c},{r}" for (c, r) in cells if (c, r) not in by_cell]
|
||
if missing:
|
||
raise ValueError(
|
||
f"dale(s) sans visualisation à {str(resolution).replace('.', ',')} m/px : "
|
||
f"{', '.join(missing)}")
|
||
images, missing_viz = {}, []
|
||
for cell in cells:
|
||
t = by_cell[cell]
|
||
per_viz = {}
|
||
for key in viz_keys:
|
||
info = t["viz"].get(key)
|
||
if not info:
|
||
missing_viz.append(f"{cell[0]},{cell[1]} ({key})")
|
||
continue
|
||
per_viz[key] = Path(t["dir_path"]) / info["filename"]
|
||
images[cell] = per_viz
|
||
if missing_viz:
|
||
raise ValueError("visualisation manquante sur : " + ", ".join(missing_viz))
|
||
return images
|
||
|
||
|
||
def _nice_scale_m(width_m):
|
||
"""Longueur d'échelle ronde (m) tenant dans ~25 % de la largeur."""
|
||
for cand in (25, 50, 100, 200, 250, 500, 1000, 2000, 5000, 10000):
|
||
if cand <= 0.25 * width_m:
|
||
return cand
|
||
return 10000
|
||
|
||
|
||
def _fmt_scale(v):
|
||
return f"{v / 1000:g} km" if v >= 1000 else f"{v} m"
|
||
|
||
|
||
def _build_mosaic_into(canvas, origin_y, images, viz_key, tile_px,
|
||
n_cols, n_rows, col_min, row_max):
|
||
"""Colle les dalles bord à bord dans un canevas existant (offset vertical).
|
||
|
||
Aucun trait, aucun espace entre dalles. Coller directement dans le canevas
|
||
final évite de garder deux mosaïques pleine résolution en mémoire.
|
||
"""
|
||
from PIL import Image
|
||
for (col, row), per_viz in images.items():
|
||
img = Image.open(str(per_viz[viz_key]))
|
||
if img.mode != "RGB":
|
||
img = img.convert("RGB")
|
||
if img.size != (tile_px, tile_px):
|
||
img = img.resize((tile_px, tile_px))
|
||
# Dalle (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||
# Ligne 0 de l'image = nord → les rows décroissantes descendent.
|
||
x = (col - col_min) * tile_px
|
||
y = origin_y + (row_max - row) * tile_px
|
||
canvas.paste(img, (x, y))
|
||
img.close()
|
||
|
||
|
||
def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max):
|
||
"""Colle les dalles bord à bord dans un canevas neuf dédié à la mosaïque."""
|
||
from PIL import Image
|
||
canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white")
|
||
_build_mosaic_into(canvas, 0, images, viz_key, tile_px, n_cols, n_rows,
|
||
col_min, row_max)
|
||
return canvas
|
||
|
||
|
||
def _legend_layout(legend, mosaic, w):
|
||
"""Mise en page du bandeau légende (polices, lignes, hauteur), ou None.
|
||
|
||
Mesure le texte sur la mosaïque (sans la modifier) pour dimensionner le
|
||
bandeau avant la création du canevas final.
|
||
"""
|
||
from PIL import ImageDraw
|
||
measure = ImageDraw.Draw(mosaic)
|
||
# Police corps du bandeau : généreuse (lecture de terrain sur téléphone),
|
||
# bornée pour rester proportionnée aux petits exports réduits.
|
||
body = max(16, min(30, w // 100))
|
||
gap = max(8, int(body * 0.55))
|
||
line_h = int(body * 1.45)
|
||
x0 = max(18, w // 60)
|
||
max_w = max(40, w - 2 * x0)
|
||
text_font = _load_font(body)
|
||
lines = _wrap_text(measure, legend["legend"], text_font, max_w)
|
||
desc_lines = _wrap_text(measure, "Calcul : " + legend["description"],
|
||
text_font, max_w)
|
||
has_bar = bool(legend.get("gradient") and legend.get("ticks"))
|
||
bar_w = max(100, min(w // 3, 640))
|
||
bar_h = max(10, int(body * 0.9))
|
||
height = (gap + int(body * 1.75) + gap
|
||
+ (bar_h + line_h if has_bar else 0) + gap
|
||
+ len(lines) * line_h + gap + len(desc_lines) * line_h + gap)
|
||
return {"body": body, "gap": gap, "line_h": line_h, "x0": x0,
|
||
"title_font": _load_font(int(body * 1.35), bold=True),
|
||
"text_font": text_font, "lines": lines, "desc_lines": desc_lines,
|
||
"has_bar": has_bar, "bar_w": bar_w, "bar_h": bar_h, "height": height}
|
||
|
||
|
||
def _draw_legend(out, draw, legend, layout, y_base):
|
||
"""Dessine le bandeau légende sous le pied : titre, dégradé + bornes,
|
||
lecture du rendu (lignes de la légende) et méthode de calcul."""
|
||
y = y_base + layout["gap"]
|
||
draw.text((layout["x0"], y), legend["title"], fill=(20, 20, 20),
|
||
font=layout["title_font"], anchor="la")
|
||
y += int(layout["body"] * 1.75) + layout["gap"]
|
||
if layout["has_bar"]:
|
||
bar = _gradient_bar(legend["gradient"], layout["bar_w"], layout["bar_h"])
|
||
out.paste(bar, (layout["x0"], y))
|
||
# Cadre dessiné juste à l'extérieur de la barre : les pixels
|
||
# extrêmes du dégradé (bornes de l'échelle) restent visibles.
|
||
draw.rectangle((layout["x0"] - 1, y - 1,
|
||
layout["x0"] + layout["bar_w"], y + layout["bar_h"]),
|
||
outline=(90, 90, 90), width=1)
|
||
tick_y = y + layout["bar_h"] + 2
|
||
draw.text((layout["x0"], tick_y), legend["ticks"][0], fill=(20, 20, 20),
|
||
font=layout["text_font"], anchor="la")
|
||
draw.text((layout["x0"] + layout["bar_w"], tick_y), legend["ticks"][1],
|
||
fill=(20, 20, 20), font=layout["text_font"], anchor="ra")
|
||
y = tick_y + layout["line_h"]
|
||
y += layout["gap"]
|
||
for line in layout["lines"]:
|
||
draw.text((layout["x0"], y), line, fill=(20, 20, 20),
|
||
font=layout["text_font"], anchor="la")
|
||
y += layout["line_h"]
|
||
y += layout["gap"]
|
||
for line in layout["desc_lines"]:
|
||
draw.text((layout["x0"], y), line, fill=(90, 90, 90),
|
||
font=layout["text_font"], anchor="la")
|
||
y += layout["line_h"]
|
||
|
||
|
||
def _chrome_layout(w, h, legend):
|
||
"""Dimensions des bandeaux (titre, pied, légende) autour d'une mosaïque w×h."""
|
||
from PIL import Image
|
||
top_h = max(90, h // 16)
|
||
foot_h = max(84, h // 20)
|
||
# Image factice pour la mesure : seule la police compte, pas le contenu
|
||
layout = _legend_layout(legend, Image.new("RGB", (1, 1)), w) if legend else None
|
||
legend_h = layout["height"] if layout else 0
|
||
return top_h, foot_h, layout, legend_h
|
||
|
||
|
||
def _draw_chrome(out, mosaic_w, mosaic_h, top_h, foot_h, m_per_px, title,
|
||
subtitle, layout, legend):
|
||
"""Dessine sur `out` les bandeaux autour de la zone mosaïque (offset top_h)."""
|
||
from PIL import ImageDraw
|
||
w, h = mosaic_w, mosaic_h
|
||
d = ImageDraw.Draw(out)
|
||
|
||
title_font = _load_font(max(22, int(top_h * 0.34)))
|
||
sub_font = _load_font(max(14, int(top_h * 0.20)))
|
||
foot_font = _load_font(max(14, int(foot_h * 0.26)))
|
||
for text, font, cy in ((title, title_font, top_h * 0.40),
|
||
(subtitle, sub_font, top_h * 0.76)):
|
||
tw = d.textlength(text, font=font)
|
||
d.text(((w - tw) / 2, cy), text, fill=(20, 20, 20), font=font,
|
||
anchor="lm")
|
||
|
||
# Pied : échelle à gauche, nord à droite.
|
||
width_m = w * m_per_px
|
||
scale_m = _nice_scale_m(width_m)
|
||
bar_px = max(40, int(scale_m / m_per_px))
|
||
bar_y = h + top_h + foot_h * 0.42
|
||
x0, y0 = max(18, w // 60), bar_y - foot_h * 0.16
|
||
seg = bar_px / 4
|
||
for i in range(4):
|
||
cx = x0 + i * seg
|
||
d.rectangle((cx, y0, cx + seg, y0 + foot_h * 0.20),
|
||
fill=(15, 15, 15) if i % 2 == 0 else "white",
|
||
outline=(15, 15, 15), width=2)
|
||
d.text((x0 + bar_px / 2, y0 - foot_h * 0.22), _fmt_scale(scale_m),
|
||
fill=(20, 20, 20), font=foot_font, anchor="ms")
|
||
|
||
nx = w - max(30, w // 24)
|
||
ny = h + top_h + foot_h * 0.55
|
||
s = foot_h * 0.30
|
||
d.polygon([(nx, ny - s * 1.5), (nx - s, ny + s), (nx, ny + s * 0.4),
|
||
(nx + s, ny + s)], fill=(15, 15, 15))
|
||
d.text((nx, ny - s * 2.2), "N", fill=(20, 20, 20), font=foot_font,
|
||
anchor="ms")
|
||
if layout:
|
||
y_base = h + top_h + foot_h
|
||
d.line((0, y_base, w, y_base), fill=(210, 210, 210), width=1)
|
||
_draw_legend(out, d, legend, layout, y_base)
|
||
|
||
|
||
def _decorate(mosaic, m_per_px, title, subtitle, legend=None):
|
||
"""Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous.
|
||
|
||
Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche
|
||
est ajouté sous le pied : titre, dégradé de la colormap avec bornes,
|
||
lecture du rendu et méthode de calcul.
|
||
|
||
L'habillage ne touche jamais la mosaïque : les dalles restent jointives.
|
||
"""
|
||
from PIL import Image
|
||
w, h = mosaic.size
|
||
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
|
||
out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white")
|
||
out.paste(mosaic, (0, top_h))
|
||
_draw_chrome(out, w, h, top_h, foot_h, m_per_px, title, subtitle,
|
||
layout, legend)
|
||
return out
|
||
|
||
|
||
def export_filename(cells, resolution, viz_keys, fmt):
|
||
"""Nom de fichier déterministe + horodatage (évite les collisions)."""
|
||
cols = [c for c, _ in cells]
|
||
rows = [r for _, r in cells]
|
||
res = str(resolution).replace(".", "p")
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
viz = "-".join(viz_keys)[:60]
|
||
return (f"export_{min(cols)}-{max(cols)}_{min(rows)}-{max(rows)}"
|
||
f"_r{res}_{viz}_{stamp}.{EXPORT_EXTS[fmt]}")
|
||
|
||
|
||
def build_export(vis_dir, cells, viz_keys, resolution, fmt, out_dir,
|
||
max_side=0):
|
||
"""Assemble des dalles adjacentes en une image ou un PDF.
|
||
|
||
Args:
|
||
vis_dir: dossier output/visualisations (dalles scannées via index).
|
||
cells: liste (col, row) des dalles à assembler (km L93).
|
||
viz_keys: mots-clés de visualisation (ex: slope, hillshade_multi).
|
||
Plusieurs clés → un export par couche : PDF multi-pages (une page
|
||
par visualisation) ou une image par visualisation.
|
||
resolution: résolution des dalles en m/px (0.5 ou 0.2).
|
||
fmt: png, jpeg, webp ou pdf.
|
||
out_dir: dossier de sortie (output/exports, créé si besoin).
|
||
max_side: réduire le plus grand côté de la mosaïque à N pixels
|
||
(0 = pleine résolution).
|
||
|
||
Returns:
|
||
{file: Path du premier fichier, width, height, pages,
|
||
files: [{file, width, height, pages}, …] — 1 par couche en image,
|
||
1 entrée pour le PDF multi-pages}
|
||
|
||
Raises:
|
||
ValueError: dalles ou visualisations manquantes, format invalide,
|
||
zone trop grande en mémoire.
|
||
"""
|
||
from PIL import Image
|
||
resample = getattr(Image, 'LANCZOS', 1)
|
||
if fmt not in EXPORT_FORMATS:
|
||
raise ValueError(f"format invalide : {fmt!r} "
|
||
f"(attendus : {', '.join(EXPORT_FORMATS)})")
|
||
viz_keys = [str(k) for k in viz_keys if k]
|
||
if not viz_keys:
|
||
raise ValueError("aucune visualisation demandée")
|
||
|
||
images = _find_tile_images(vis_dir, cells, viz_keys, resolution)
|
||
cols = [c for c, _ in cells]
|
||
rows = [r for _, r in cells]
|
||
col_min, col_max = min(cols), max(cols)
|
||
row_min, row_max = min(rows), max(rows)
|
||
n_cols = col_max - col_min + 1
|
||
n_rows = row_max - row_min + 1
|
||
# Côté réel d'une dalle (2000 px à 0,5 m, 5000 à 0,2 m) : lu sur la
|
||
# première image plutôt que recalculé, pour coller aux fichiers réels.
|
||
first_path = next(iter(images.values()))[viz_keys[0]]
|
||
with Image.open(str(first_path)) as _im:
|
||
tile_px = max(_im.size)
|
||
tile_px = max(1, tile_px)
|
||
canvas_bytes = n_cols * tile_px * n_rows * tile_px * 3
|
||
if canvas_bytes > _MAX_CANVAS_BYTES:
|
||
raise ValueError(
|
||
f"zone trop grande en pleine résolution "
|
||
f"({n_cols}×{n_rows} dalles à {resolution} m/px ≈ "
|
||
f"{canvas_bytes / 1e9:.1f} Go) — choisissez une taille réduite")
|
||
|
||
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||
res_txt = str(resolution).replace(".", ",")
|
||
date_txt = datetime.now().strftime("%d/%m/%Y")
|
||
subtitle = (f"Lambert 93 · X {col_min}–{col_max + 1} km · "
|
||
f"Y {row_min - 1}–{row_max} km · {res_txt} m/px · "
|
||
f"{len(cells)} dalle{'s' if len(cells) > 1 else ''} · {date_txt}")
|
||
|
||
out_dir = Path(out_dir)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
logger.info(f"Export : {n_cols}×{n_rows} dalles à {res_txt} m/px, "
|
||
f"{' × '.join(viz_keys)} ({fmt})")
|
||
|
||
# PDF multi-pages : toutes les pages sont retenues pour save_all —
|
||
# côté plafonné pour rester en mémoire raisonnable.
|
||
page_cap = _PDF_PAGE_MAX_PX if fmt == "pdf" and len(viz_keys) > 1 else None
|
||
|
||
# Options d'enregistrement hétérogènes (str/int/float/bool)
|
||
save_kw: dict = {"format": EXPORT_FORMATS[fmt]}
|
||
if fmt == "jpeg":
|
||
save_kw["quality"] = 90
|
||
elif fmt == "webp":
|
||
save_kw["quality"] = 90
|
||
elif fmt == "pdf":
|
||
save_kw["resolution"] = 150.0
|
||
|
||
# Une couche = un export : page du PDF multi-pages, ou fichier image
|
||
# dédié (chacun porte la légende de SA couche).
|
||
pages = [] # pages PDF décorées (save_all)
|
||
files = [] # fichiers écrits (1 par couche en image, le PDF sinon)
|
||
try:
|
||
for key in viz_keys:
|
||
m_per_px = resolution
|
||
caps = [c for c in (int(max_side), page_cap) if c]
|
||
cap = min(caps) if caps else 0
|
||
w = n_cols * tile_px
|
||
h = n_rows * tile_px
|
||
if cap and max(w, h) > cap:
|
||
# Mosaïque réduite (téléphone / pages PDF) : taille bornée par
|
||
# le cap, le chemin mosaïque → resize → habillage reste simple
|
||
ratio = cap / max(w, h)
|
||
size = (max(1, round(w * ratio)), max(1, round(h * ratio)))
|
||
mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows,
|
||
col_min, row_max)
|
||
mosaic = mosaic.resize(size, resample)
|
||
m_per_px = resolution / ratio
|
||
decorated = _decorate(mosaic, m_per_px,
|
||
VIZ_LABELS.get(key, key), subtitle,
|
||
legend=VIZ_LEGENDS.get(key))
|
||
mosaic.close()
|
||
else:
|
||
# Pleine résolution : collage DIRECT dans le canevas habillé —
|
||
# jamais deux mosaïques entières en mémoire (la garde
|
||
# _MAX_CANVAS_BYTES correspond alors à la taille réelle)
|
||
legend = VIZ_LEGENDS.get(key)
|
||
top_h, foot_h, layout, legend_h = _chrome_layout(w, h, legend)
|
||
decorated = Image.new(
|
||
"RGB", (w, h + top_h + foot_h + legend_h), "white")
|
||
_build_mosaic_into(decorated, top_h, images, key, tile_px,
|
||
n_cols, n_rows, col_min, row_max)
|
||
_draw_chrome(decorated, w, h, top_h, foot_h, m_per_px,
|
||
VIZ_LABELS.get(key, key), subtitle, layout, legend)
|
||
if fmt != "pdf":
|
||
key_file = out_dir / export_filename(cells, resolution, [key], fmt)
|
||
decorated.save(str(key_file), **save_kw)
|
||
decorated.close()
|
||
files.append(key_file)
|
||
else:
|
||
pages.append(decorated)
|
||
if fmt == "pdf":
|
||
out_file = out_dir / export_filename(cells, resolution, viz_keys, fmt)
|
||
if len(pages) > 1:
|
||
save_kw["save_all"] = True
|
||
save_kw["append_images"] = pages[1:]
|
||
pages[0].save(str(out_file), **save_kw)
|
||
files.append(out_file)
|
||
finally:
|
||
for p in pages:
|
||
p.close()
|
||
|
||
result_files = []
|
||
for f in files:
|
||
entry = {"file": f, "width": 0, "height": 0, "pages": 1}
|
||
if fmt == "pdf":
|
||
# Pages de même dimension que la première mosaïque décorée.
|
||
first = pages[0] if pages else None
|
||
entry["width"], entry["height"] = first.size if first else (0, 0)
|
||
entry["pages"] = len(pages)
|
||
else:
|
||
with Image.open(str(f)) as img:
|
||
entry["width"], entry["height"] = img.size
|
||
result_files.append(entry)
|
||
head = result_files[0] if result_files else {
|
||
"file": None, "width": 0, "height": 0, "pages": 0}
|
||
result = {"file": head["file"], "width": head["width"],
|
||
"height": head["height"], "pages": head["pages"],
|
||
"files": result_files}
|
||
total = sum(f.stat().st_size for f in files)
|
||
logger.info(f"✓ Export terminé : {len(files)} fichier"
|
||
f"{'s' if len(files) > 1 else ''} ({total / 1e6:.1f} Mo)")
|
||
return result
|