"""
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}{xmp_tag}>'
# 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)}{xmp_tag}>'
# 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)}{clean_tag}>'
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()