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>
255 lines
7.2 KiB
Python
255 lines
7.2 KiB
Python
"""
|
|
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()
|