""" 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}")