""" Module de génération des fichiers XMP sidecar. Ce module crée des fichiers XMP sidecar pour les images dont le format ne supporte pas les métadonnées EXIF intégrées. """ import logging from datetime import datetime from pathlib import Path from typing import Any from xml.sax.saxutils import escape logger = logging.getLogger(__name__) # Template XMP de base XMP_TEMPLATE = ''' {properties} ''' def generate_xmp_content(tags: dict) -> str: """ Génère le contenu XML d'un fichier XMP à partir des tags EXIF. Args: tags: Dictionnaire de tags EXIF. Returns: Contenu XML du fichier XMP. """ properties = [] for tag, value in tags.items(): if value is None: continue xmp_property = _convert_exif_to_xmp(tag, value) if xmp_property: properties.append(xmp_property) # Ajouter la date de modification now = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') properties.append(f' {now}') properties_str = '\n'.join(properties) return XMP_TEMPLATE.format(properties=properties_str) def write_xmp_sidecar( image_path: Path, tags: dict, dry_run: bool = False ) -> Path: """ Crée un fichier XMP sidecar pour une image. Le fichier XMP est créé avec le même nom que l'image mais avec l'extension .xmp. Args: image_path: Chemin vers le fichier image. tags: Dictionnaire de tags EXIF. dry_run: Si True, affiche les actions sans les exécuter. Returns: Chemin vers le fichier XMP créé. Raises: IOError: Si l'écriture échoue. """ image_path = Path(image_path) xmp_path = image_path.with_suffix('.xmp') logger.debug(f"Génération du fichier XMP : {xmp_path}") xmp_content = generate_xmp_content(tags) if dry_run: logger.info(f"[DRY-RUN] Création de {xmp_path.name}") return xmp_path try: with open(xmp_path, 'w', encoding='utf-8') as f: f.write(xmp_content) logger.info(f"Fichier XMP créé : {xmp_path.name}") return xmp_path except IOError as e: logger.error(f"Impossible de créer le fichier XMP : {e}") raise def _convert_exif_to_xmp(tag: str, value: Any) -> str | None: """ Convertit un tag EXIF en propriété XMP. Args: tag: Nom du tag EXIF. value: Valeur du tag. Returns: Ligne XML pour la propriété XMP, ou None si non supporté. """ # Échapper les caractères spéciaux XML str_value = escape(str(value)) # Mapping des tags simples (valeur string directe) simple_tags = { 'Make': 'tiff:Make', 'Model': 'tiff:Model', 'LensMake': 'aux:LensMake', 'LensModel': 'aux:Lens', 'ShutterSpeedValue': 'exif:ShutterSpeedValue', 'GPSLatitude': 'exif:GPSLatitude', 'GPSLatitudeRef': 'exif:GPSLatitudeRef', 'GPSLongitude': 'exif:GPSLongitude', 'GPSLongitudeRef': 'exif:GPSLongitudeRef', 'UserComment': 'exif:UserComment', 'LightSource': 'exif:LightSource', 'ImageNumber': 'exif:ImageNumber', } if tag in simple_tags: xmp_tag = simple_tags[tag] return f' <{xmp_tag}>{str_value}' # Tags avec format rationnel rational_tags = { 'ExposureTime': 'exif:ExposureTime', 'FNumber': 'exif:FNumber', 'ApertureValue': 'exif:ApertureValue', 'FocalLength': 'exif:FocalLength', 'ExposureCompensation': 'exif:ExposureCompensation', 'GPSAltitude': 'exif:GPSAltitude', } if tag in rational_tags: xmp_tag = rational_tags[tag] return f' <{xmp_tag}>{_format_rational(value)}' # Tags avec format spécial if tag == 'ISO': return f' {_format_seq(value)}' if tag == 'DateTimeOriginal': return f' {_format_xmp_date(str(value))}' if tag == 'CreateDate': date_val = _format_xmp_date(str(value)) return f' {date_val}' if tag == 'GPSAltitudeRef': ref_value = '0' if 'Above' in str(value) else '1' return f' {ref_value}' if tag == 'ImageDescription': return f' {_format_alt(str_value)}' if tag == 'Flash': return f' {_format_flash(value)}' # Tags XMP personnalisés (déjà préfixés) if tag.startswith('XMP-'): # Convertir XMP-dc:Description en dc:description clean_tag = tag.replace('XMP-', '') return f' <{clean_tag}>{_format_alt(str_value)}' logger.debug(f"Tag EXIF non mappé vers XMP : {tag}") return None def _format_rational(value: Any) -> str: """Formate une valeur en rationnel XMP.""" if isinstance(value, int): return str(value) if isinstance(value, float): # Pour les temps d'exposition comme 1/125 if value < 1 and value > 0: denominator = round(1 / value) return f"1/{denominator}" # Valeurs entières flottantes if value == int(value): return str(int(value)) return str(value) return str(value) def _format_seq(value: Any) -> str: """Formate une valeur en séquence RDF.""" return f''' {value} ''' def _format_alt(value: str) -> str: """Formate une valeur en alternative RDF (pour les textes multilingues).""" return f''' {value} ''' def _format_xmp_date(value: str) -> str: """ Convertit une date EXIF en format XMP. EXIF: 2024:03:15 14:30:00 XMP: 2024-03-15T14:30:00 """ if 'T' in value: # Déjà au format ISO return value # Convertir le format EXIF try: parts = value.replace(':', '-', 2).split(' ') if len(parts) == 2: return f"{parts[0]}T{parts[1]}" return parts[0] except Exception: return value def _format_flash(value: Any) -> str: """ Formate la valeur du flash pour XMP. Retourne une structure Flash complète. """ fired = bool(value) if not isinstance(value, bool) else value return f''' {'True' if fired else 'False'} 0 '''.strip()