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

218
tests/test_xmp_writer.py Normal file
View File

@@ -0,0 +1,218 @@
"""Tests pour le module xmp_writer."""
from json_to_metadata.xmp_writer import (
_convert_exif_to_xmp,
_format_rational,
_format_xmp_date,
generate_xmp_content,
write_xmp_sidecar,
)
class TestGenerateXmpContent:
"""Tests pour la fonction generate_xmp_content."""
def test_basic_structure(self):
"""Génère une structure XMP valide."""
content = generate_xmp_content({})
assert '<?xml version="1.0"' in content
assert 'x:xmpmeta' in content
assert 'rdf:RDF' in content
assert 'rdf:Description' in content
def test_includes_namespaces(self):
"""Inclut les namespaces requis."""
content = generate_xmp_content({})
assert 'xmlns:dc=' in content
assert 'xmlns:exif=' in content
assert 'xmlns:tiff=' in content
assert 'xmlns:xmp=' in content
def test_maps_camera_info(self):
"""Mappe les informations de l'appareil."""
tags = {'Make': 'Nikon', 'Model': 'FM2'}
content = generate_xmp_content(tags)
assert '<tiff:Make>Nikon</tiff:Make>' in content
assert '<tiff:Model>FM2</tiff:Model>' in content
def test_maps_exposure(self):
"""Mappe les paramètres d'exposition."""
tags = {'ISO': 400, 'FNumber': 2.8}
content = generate_xmp_content(tags)
assert 'ISOSpeedRatings' in content
assert '400' in content
assert 'FNumber' in content
def test_escapes_special_characters(self):
"""Échappe les caractères spéciaux XML."""
tags = {'ImageDescription': 'Test <avec> des & caractères "spéciaux"'}
content = generate_xmp_content(tags)
assert '&lt;avec&gt;' in content
assert '&amp;' in content
def test_includes_modify_date(self):
"""Inclut la date de modification."""
content = generate_xmp_content({})
assert 'xmp:ModifyDate' in content
class TestWriteXmpSidecar:
"""Tests pour la fonction write_xmp_sidecar."""
def test_creates_xmp_file(self, tmp_path):
"""Crée un fichier XMP à côté de l'image."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon'}
xmp_path = write_xmp_sidecar(image, tags)
assert xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
assert xmp_path.parent == tmp_path
def test_xmp_content_valid(self, tmp_path):
"""Le contenu du fichier XMP est valide."""
image = tmp_path / 'photo.jpg'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon', 'Model': 'FM2'}
xmp_path = write_xmp_sidecar(image, tags)
content = xmp_path.read_text(encoding='utf-8')
assert '<?xml version="1.0"' in content
assert '<tiff:Make>Nikon</tiff:Make>' in content
def test_dry_run_mode(self, tmp_path):
"""Le mode dry-run ne crée pas de fichier."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Nikon'}, dry_run=True)
assert not xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
def test_overwrites_existing_xmp(self, tmp_path):
"""Écrase un fichier XMP existant."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = tmp_path / 'photo.xmp'
xmp_path.write_text('old content')
write_xmp_sidecar(image, {'Make': 'Canon'})
content = xmp_path.read_text()
assert 'Canon' in content
assert 'old content' not in content
def test_handles_different_extensions(self, tmp_path):
"""Fonctionne avec différentes extensions d'images."""
for ext in ['.tif', '.avif', '.heic', '.webp', '.png']:
image = tmp_path / f'photo{ext}'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Test'})
assert xmp_path.suffix == '.xmp'
assert xmp_path.stem == 'photo'
xmp_path.unlink() # Nettoyer
class TestConvertExifToXmp:
"""Tests pour la fonction _convert_exif_to_xmp."""
def test_make_mapping(self):
"""Mappe Make vers tiff:Make."""
result = _convert_exif_to_xmp('Make', 'Nikon')
assert '<tiff:Make>Nikon</tiff:Make>' in result
def test_iso_mapping(self):
"""Mappe ISO avec structure rdf:Seq."""
result = _convert_exif_to_xmp('ISO', 400)
assert 'ISOSpeedRatings' in result
assert 'rdf:Seq' in result
assert '400' in result
def test_date_mapping(self):
"""Mappe DateTimeOriginal vers xmp:CreateDate."""
result = _convert_exif_to_xmp('DateTimeOriginal', '2024:03:15 14:30:00')
assert 'xmp:CreateDate' in result
assert '2024-03-15T14:30:00' in result
def test_gps_mapping(self):
"""Mappe les coordonnées GPS."""
lat = _convert_exif_to_xmp('GPSLatitude', 48.8584)
lat_ref = _convert_exif_to_xmp('GPSLatitudeRef', 'N')
assert 'exif:GPSLatitude' in lat
assert '48.8584' in lat
assert 'exif:GPSLatitudeRef' in lat_ref
assert 'N' in lat_ref
def test_description_uses_alt(self):
"""ImageDescription utilise rdf:Alt pour le multilingue."""
result = _convert_exif_to_xmp('ImageDescription', 'Ma photo')
assert 'dc:description' in result
assert 'rdf:Alt' in result
assert 'x-default' in result
def test_unknown_tag_returns_none(self):
"""Les tags inconnus retournent None."""
result = _convert_exif_to_xmp('UnknownTag', 'value')
assert result is None
class TestFormatRational:
"""Tests pour la fonction _format_rational."""
def test_integer_value(self):
"""Formate un entier comme chaîne."""
assert _format_rational(50) == '50'
assert _format_rational(100.0) == '100'
def test_fraction_value(self):
"""Formate une fraction correctement."""
result = _format_rational(0.008) # 1/125
assert '1/125' in result
def test_decimal_passthrough(self):
"""Retourne les décimaux non-fractions tels quels."""
result = _format_rational(2.8)
assert '2.8' in result
class TestFormatXmpDate:
"""Tests pour la fonction _format_xmp_date."""
def test_exif_to_xmp_format(self):
"""Convertit le format EXIF en ISO."""
result = _format_xmp_date('2024:03:15 14:30:00')
assert result == '2024-03-15T14:30:00'
def test_iso_passthrough(self):
"""Laisse passer les dates déjà au format ISO."""
result = _format_xmp_date('2024-03-15T14:30:00')
assert result == '2024-03-15T14:30:00'
def test_date_only(self):
"""Gère les dates sans heure."""
result = _format_xmp_date('2024-03-15')
assert '2024-03-15' in result