Initial commit: script de gestion des métadonnées EXIF pour photos argentiques

Script Python qui lit un fichier JSON généré par Exif Notes et écrit
les métadonnées EXIF dans les fichiers images (TIFF, JPEG) ou crée
des fichiers XMP sidecar pour les formats non supportés.

Fonctionnalités :
- Validation des fichiers JSON Exif Notes
- Mapping complet JSON → tags EXIF standard
- Écriture EXIF via exiftool avec fallback XMP
- Support des formats de vitesse (1/125, 2", B/Bulb)
- Support des dates ISO avec ou sans secondes
- CLI avec options --dry-run, --verbose, --force-xmp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 16:22:12 +01:00
commit 25bd8bc961
14 changed files with 2715 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
"""
Module d'écriture des métadonnées EXIF via exiftool.
Ce module gère l'écriture des tags EXIF dans les fichiers images
en utilisant l'outil en ligne de commande exiftool.
"""
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Extensions supportant l'EXIF intégré
EMBEDDED_EXIF_EXTENSIONS = {'.tif', '.tiff', '.jpg', '.jpeg'}
# Extensions avec support partiel (tentative d'intégration)
PARTIAL_SUPPORT_EXTENSIONS = {'.avif', '.heic', '.heif', '.webp'}
# Extensions nécessitant obligatoirement un fichier XMP sidecar
XMP_ONLY_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.pef', '.raw'}
class ExiftoolError(Exception):
"""Exception levée lors d'une erreur exiftool."""
pass
class ExiftoolNotFoundError(ExiftoolError):
"""Exception levée quand exiftool n'est pas installé."""
pass
def check_exiftool_available() -> bool:
"""
Vérifie que exiftool est installé et accessible.
Returns:
True si exiftool est disponible.
Raises:
ExiftoolNotFoundError: Si exiftool n'est pas trouvé.
"""
if shutil.which('exiftool') is None:
raise ExiftoolNotFoundError(
"exiftool n'est pas installé ou n'est pas dans le PATH.\n"
"Installation :\n"
" - Arch Linux : sudo pacman -S perl-image-exiftool\n"
" - Ubuntu/Debian : sudo apt install libimage-exiftool-perl\n"
" - macOS : brew install exiftool\n"
" - Windows : https://exiftool.org/"
)
# Vérifier que ça fonctionne
try:
result = subprocess.run(
['exiftool', '-ver'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
version = result.stdout.strip()
logger.debug(f"exiftool version {version} détecté")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError("exiftool ne répond pas (timeout)")
except Exception as e:
raise ExiftoolError(f"Erreur lors de la vérification de exiftool : {e}")
return False
def supports_embedded_exif(filepath: Path) -> bool:
"""
Détermine si le format du fichier supporte les métadonnées EXIF intégrées.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format supporte l'EXIF intégré, False sinon.
"""
ext = filepath.suffix.lower()
return ext in EMBEDDED_EXIF_EXTENSIONS
def supports_partial_exif(filepath: Path) -> bool:
"""
Détermine si le format a un support partiel des métadonnées.
Ces formats peuvent accepter les métadonnées mais avec des limitations.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format a un support partiel.
"""
ext = filepath.suffix.lower()
return ext in PARTIAL_SUPPORT_EXTENSIONS
def write_exif_to_file(filepath: Path, tags: dict, dry_run: bool = False) -> bool:
"""
Écrit les tags EXIF dans un fichier image via exiftool.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF à écrire.
dry_run: Si True, affiche les commandes sans les exécuter.
Returns:
True si l'écriture a réussi.
Raises:
ExiftoolError: Si l'écriture échoue.
FileNotFoundError: Si le fichier n'existe pas.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
if not tags:
logger.warning(f"Aucun tag à écrire pour {filepath}")
return True
# Construire les arguments exiftool
args = _build_exiftool_args(tags)
# Ajouter les options de base
cmd = [
'exiftool',
'-overwrite_original', # Ne pas créer de backup
'-charset', 'utf8', # Support UTF-8
]
cmd.extend(args)
cmd.append(str(filepath))
logger.debug(f"Commande exiftool : {' '.join(cmd)}")
if dry_run:
logger.info(f"[DRY-RUN] exiftool sur {filepath.name} avec {len(tags)} tags")
return True
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip()
raise ExiftoolError(f"Échec de l'écriture EXIF : {error_msg}")
# Vérifier le message de succès
if '1 image files updated' in result.stdout:
logger.info(f"Métadonnées écrites avec succès dans {filepath.name}")
return True
elif 'Warning' in result.stderr:
logger.warning(f"Avertissements lors de l'écriture : {result.stderr}")
return True
else:
logger.debug(f"Sortie exiftool : {result.stdout}")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de l'écriture dans {filepath}")
except Exception as e:
raise ExiftoolError(f"Erreur lors de l'écriture EXIF : {e}")
def write_exif_with_fallback(
filepath: Path,
tags: dict,
xmp_writer_func: callable,
dry_run: bool = False
) -> tuple[bool, str]:
"""
Tente d'écrire les métadonnées EXIF avec fallback sur XMP sidecar.
Pour les formats avec support partiel (AVIF, HEIC, WebP), tente
d'abord l'écriture directe, puis crée un XMP sidecar en cas d'échec.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF.
xmp_writer_func: Fonction pour écrire le fichier XMP sidecar.
dry_run: Mode simulation.
Returns:
Tuple (succès, méthode utilisée) où méthode est 'exif' ou 'xmp'.
"""
filepath = Path(filepath)
# Formats supportant pleinement l'EXIF intégré
if supports_embedded_exif(filepath):
success = write_exif_to_file(filepath, tags, dry_run)
return (success, 'exif')
# Formats avec support partiel : tentative puis fallback
if supports_partial_exif(filepath):
try:
success = write_exif_to_file(filepath, tags, dry_run)
if success:
return (True, 'exif')
except ExiftoolError as e:
logger.warning(
f"Échec de l'écriture EXIF pour {filepath.name} : {e}. "
f"Création d'un fichier XMP sidecar."
)
# Fallback : création du fichier XMP sidecar
try:
xmp_path = xmp_writer_func(filepath, tags, dry_run)
if xmp_path:
return (True, 'xmp')
except Exception as e:
logger.error(f"Échec de la création du XMP sidecar : {e}")
return (False, 'none')
return (False, 'none')
def _build_exiftool_args(tags: dict) -> list[str]:
"""
Construit les arguments de ligne de commande pour exiftool.
Args:
tags: Dictionnaire de tags EXIF.
Returns:
Liste d'arguments pour exiftool.
"""
args = []
for tag, value in tags.items():
if value is None:
continue
# Formatage de la valeur selon le type
formatted_value = _format_tag_value(tag, value)
# Construction de l'argument
args.append(f'-{tag}={formatted_value}')
return args
def _format_tag_value(tag: str, value: Any) -> str:
"""
Formate une valeur de tag pour exiftool.
Args:
tag: Nom du tag.
value: Valeur à formater.
Returns:
Valeur formatée en chaîne.
"""
# Gestion des types spéciaux
if isinstance(value, bool):
return '1' if value else '0'
if isinstance(value, float):
# Garder une précision raisonnable
if value == int(value):
return str(int(value))
return f'{value:.6g}'
if isinstance(value, (list, tuple)):
return ', '.join(str(v) for v in value)
return str(value)
def read_exif_from_file(filepath: Path) -> dict:
"""
Lit les métadonnées EXIF d'un fichier image.
Utile pour vérifier les métadonnées après écriture.
Args:
filepath: Chemin vers le fichier image.
Returns:
Dictionnaire des tags EXIF.
Raises:
ExiftoolError: Si la lecture échoue.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
try:
result = subprocess.run(
['exiftool', '-json', '-charset', 'utf8', str(filepath)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise ExiftoolError(f"Échec de la lecture EXIF : {result.stderr}")
import json
data = json.loads(result.stdout)
return data[0] if data else {}
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de la lecture de {filepath}")
except json.JSONDecodeError as e:
raise ExiftoolError(f"Erreur de parsing JSON : {e}")