feat: affichage des métadonnées image dans la page de prévisualisation

Ajoute une carte de propriétés au-dessus de l'image :
- Dimensions (largeur × hauteur), format, mode couleur, résolution DPI
- Données EXIF complètes si présentes (appareil, exposition, ISO, focale,
  balance des blancs, auteur, copyright…)
- Coordonnées GPS avec lien OpenStreetMap si le champ est renseigné

Nouvelle fonction _image_meta() et _parse_gps() dans app.py (Pillow).
Grille CSS responsive, n'apparaît pas si aucune métadonnée disponible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 09:41:31 +02:00
parent fa5408bb03
commit 31ddff2a75
3 changed files with 127 additions and 0 deletions

View file

@ -97,6 +97,66 @@ def _humansize(n: int) -> str:
return f"{n:.1f} To"
_EXIF_WANTED = frozenset({
"Make", "Model", "Software", "DateTime", "DateTimeOriginal", "DateTimeDigitized",
"ExposureTime", "FNumber", "ISOSpeedRatings", "FocalLength", "Flash",
"WhiteBalance", "ExposureProgram", "MeteringMode", "Orientation",
"XResolution", "YResolution", "ResolutionUnit", "ColorSpace",
"PixelXDimension", "PixelYDimension", "Artist", "Copyright", "GPSInfo",
})
def _image_meta(path: Path) -> dict:
try:
from PIL import ExifTags
img = Image.open(path)
meta = {
"width": img.width,
"height": img.height,
"format": img.format or path.suffix.lstrip(".").upper(),
"mode": img.mode,
"dpi": img.info.get("dpi"),
}
exif = img.getexif()
if exif:
rows = {}
for tag_id, val in exif.items():
tag = ExifTags.TAGS.get(tag_id, str(tag_id))
if tag in _EXIF_WANTED and tag != "GPSInfo":
rows[tag] = str(val)
sub = exif.get_ifd(0x8769)
for tag_id, val in sub.items():
tag = ExifTags.TAGS.get(tag_id, str(tag_id))
if tag in _EXIF_WANTED and tag != "GPSInfo":
rows[tag] = str(val)
gps_ifd = exif.get_ifd(0x8825)
if gps_ifd:
rows["GPS"] = _parse_gps(gps_ifd)
if rows:
meta["exif"] = rows
return meta
except Exception:
return {}
def _parse_gps(gps_ifd: dict):
try:
from PIL import ExifTags
def _dms(coords):
d, m, s = coords
return float(d) + float(m) / 60 + float(s) / 3600
lat = _dms(gps_ifd.get(2, (0, 0, 0)))
lon = _dms(gps_ifd.get(4, (0, 0, 0)))
latR = gps_ifd.get(1, "N")
lonR = gps_ifd.get(3, "E")
if latR == "S": lat = -lat
if lonR == "W": lon = -lon
return f"{lat:.6f}, {lon:.6f}"
except Exception:
return None
def _backup_path(p: Path) -> Path:
ts = datetime.now().strftime("%Y%m%d%H%M%S")
return p.parent / f"{p.stem}_bak_{ts}{p.suffix}"
@ -304,6 +364,7 @@ def _file_preview(path: Path, subpath: str):
ext=ext,
)
if ext in IMAGE_EXT:
ctx["meta"] = _image_meta(path)
return render_template("preview_image.html", **ctx)
if ext in TEXT_EXT:
content = path.read_text(errors="replace")

View file

@ -176,6 +176,14 @@ main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: fl
.drop-text { font-size: .9rem; color: var(--muted); line-height: 1.5; pointer-events: none; }
.drop-names { font-size: .82rem; color: var(--blue-dark); font-style: italic; word-break: break-all; pointer-events: none; }
/* ── Métadonnées image ────────────────────────────────────────────── */
.meta-card { padding: 1rem 1.8rem; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: .4rem .8rem; }
.meta-item { display: flex; flex-direction: column; gap: .1rem; padding: .4rem .5rem; border-radius: 6px; background: var(--bg); }
.meta-label { font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.meta-value { font-size: .88rem; color: var(--text); font-weight: 500; word-break: break-word; }
.meta-value a { color: var(--blue); font-weight: 400; }
/* ── Redimensionnement ────────────────────────────────────────────── */
.resize-card h2 { margin-bottom: 1rem; }
.resize-body { display: flex; flex-direction: column; gap: 1rem; }

View file

@ -26,6 +26,64 @@
</div>
</section>
{% if meta %}
<section class="card meta-card">
<div class="meta-grid">
{% if meta.width and meta.height %}
<div class="meta-item">
<span class="meta-label">Dimensions</span>
<span class="meta-value">{{ meta.width }} × {{ meta.height }} px</span>
</div>
{% endif %}
{% if meta.format %}
<div class="meta-item">
<span class="meta-label">Format</span>
<span class="meta-value">{{ meta.format }}</span>
</div>
{% endif %}
{% if meta.mode %}
<div class="meta-item">
<span class="meta-label">Mode couleur</span>
<span class="meta-value">{{ meta.mode }}</span>
</div>
{% endif %}
{% if meta.dpi %}
<div class="meta-item">
<span class="meta-label">Résolution</span>
<span class="meta-value">{{ meta.dpi[0]|int }} × {{ meta.dpi[1]|int }} DPI</span>
</div>
{% endif %}
{% if meta.exif %}
{% set labels = {
'Make':'Appareil', 'Model':'Modèle',
'Software':'Logiciel', 'DateTime':'Modifié le',
'DateTimeOriginal':'Pris le','DateTimeDigitized':'Numérisé le',
'ExposureTime':'Exposition','FNumber':'Ouverture',
'ISOSpeedRatings':'ISO', 'FocalLength':'Focale',
'Flash':'Flash', 'WhiteBalance':'Balance blancs',
'ExposureProgram':'Programme','MeteringMode':'Mesure',
'Orientation':'Orientation','Artist':'Auteur',
'Copyright':'Copyright', 'ColorSpace':'Espace colorimétrique',
'GPS':'Coordonnées GPS',
} %}
{% for key, val in meta.exif.items() %}
<div class="meta-item">
<span class="meta-label">{{ labels.get(key, key) }}</span>
<span class="meta-value">
{% if key == 'GPS' and val %}
<a href="https://www.openstreetmap.org/?mlat={{ val.split(',')[0].strip() }}&mlon={{ val.split(',')[1].strip() }}&zoom=15"
target="_blank" rel="noopener">{{ val }}</a>
{% else %}
{{ val }}
{% endif %}
</span>
</div>
{% endfor %}
{% endif %}
</div>
</section>
{% endif %}
<div class="preview-image-wrap">
<img src="{{ raw_url }}" alt="{{ filename }}">
</div>