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,254 @@
"""
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 = '''<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="json-to-metadata">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:aux="http://ns.adobe.com/exif/1.0/aux/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
{properties}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>'''
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' <xmp:ModifyDate>{now}</xmp:ModifyDate>')
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' <exif:ISOSpeedRatings>{_format_seq(value)}</exif:ISOSpeedRatings>'
if tag == 'DateTimeOriginal':
return f' <xmp:CreateDate>{_format_xmp_date(str(value))}</xmp:CreateDate>'
if tag == 'CreateDate':
date_val = _format_xmp_date(str(value))
return f' <photoshop:DateCreated>{date_val}</photoshop:DateCreated>'
if tag == 'GPSAltitudeRef':
ref_value = '0' if 'Above' in str(value) else '1'
return f' <exif:GPSAltitudeRef>{ref_value}</exif:GPSAltitudeRef>'
if tag == 'ImageDescription':
return f' <dc:description>{_format_alt(str_value)}</dc:description>'
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'''
<rdf:Seq>
<rdf:li>{value}</rdf:li>
</rdf:Seq>
'''
def _format_alt(value: str) -> str:
"""Formate une valeur en alternative RDF (pour les textes multilingues)."""
return f'''
<rdf:Alt>
<rdf:li xml:lang="x-default">{value}</rdf:li>
</rdf:Alt>
'''
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'''
<exif:Flash>
<rdf:Description>
<exif:Fired>{'True' if fired else 'False'}</exif:Fired>
<exif:Mode>0</exif:Mode>
</rdf:Description>
</exif:Flash>
'''.strip()