From 25bd8bc9613366aa7e960575283c584d03963871 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Tue, 10 Feb 2026 16:22:12 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20script=20de=20gestion=20des?= =?UTF-8?q?=20m=C3=A9tadonn=C3=A9es=20EXIF=20pour=20photos=20argentiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 41 +++ json_to_metadata/__init__.py | 8 + json_to_metadata/cli.py | 323 +++++++++++++++++++++++ json_to_metadata/exif_writer.py | 320 +++++++++++++++++++++++ json_to_metadata/json_parser.py | 261 +++++++++++++++++++ json_to_metadata/metadata_mapper.py | 386 ++++++++++++++++++++++++++++ json_to_metadata/xmp_writer.py | 254 ++++++++++++++++++ pyproject.toml | 54 ++++ requirements.txt | 2 + tests/__init__.py | 1 + tests/test_exif_writer.py | 257 ++++++++++++++++++ tests/test_json_parser.py | 263 +++++++++++++++++++ tests/test_metadata_mapper.py | 327 +++++++++++++++++++++++ tests/test_xmp_writer.py | 218 ++++++++++++++++ 14 files changed, 2715 insertions(+) create mode 100644 .gitignore create mode 100644 json_to_metadata/__init__.py create mode 100644 json_to_metadata/cli.py create mode 100644 json_to_metadata/exif_writer.py create mode 100644 json_to_metadata/json_parser.py create mode 100644 json_to_metadata/metadata_mapper.py create mode 100644 json_to_metadata/xmp_writer.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_exif_writer.py create mode 100644 tests/test_json_parser.py create mode 100644 tests/test_metadata_mapper.py create mode 100644 tests/test_xmp_writer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..526cd75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +.venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/json_to_metadata/__init__.py b/json_to_metadata/__init__.py new file mode 100644 index 0000000..d43da5a --- /dev/null +++ b/json_to_metadata/__init__.py @@ -0,0 +1,8 @@ +""" +json-to-metadata : Script de gestion des métadonnées EXIF pour photos argentiques. + +Ce package permet de lire un fichier JSON généré par Exif Notes et d'écrire +les métadonnées EXIF dans les fichiers images correspondants. +""" + +__version__ = "0.1.0" diff --git a/json_to_metadata/cli.py b/json_to_metadata/cli.py new file mode 100644 index 0000000..ad8de47 --- /dev/null +++ b/json_to_metadata/cli.py @@ -0,0 +1,323 @@ +""" +Interface en ligne de commande pour json-to-metadata. + +Ce module fournit le point d'entrée CLI pour le script de gestion +des métadonnées EXIF. +""" + +import argparse +import logging +import sys +from pathlib import Path + +from . import __version__ +from .exif_writer import ( + ExiftoolError, + ExiftoolNotFoundError, + check_exiftool_available, + write_exif_with_fallback, +) +from .json_parser import ValidationError, load_json, validate_roll +from .metadata_mapper import map_frame_to_exif +from .xmp_writer import write_xmp_sidecar + +# Configuration du logging +logger = logging.getLogger('json_to_metadata') + + +def setup_logging(verbose: bool = False) -> None: + """ + Configure le système de logging. + + Args: + verbose: Si True, active le niveau DEBUG. + """ + level = logging.DEBUG if verbose else logging.INFO + format_str = '%(levelname)s: %(message)s' + + if verbose: + format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + logging.basicConfig( + level=level, + format=format_str, + handlers=[logging.StreamHandler(sys.stderr)] + ) + + +def find_image_for_frame( + frame: dict, + directory: Path, + roll_id: str | int +) -> Path | None: + """ + Recherche le fichier image correspondant à un frame. + + Essaie plusieurs conventions de nommage courantes. + + Args: + frame: Dictionnaire du frame. + directory: Répertoire contenant les images. + roll_id: Identifiant du roll. + + Returns: + Chemin vers l'image trouvée, ou None si non trouvée. + """ + frame_id = frame.get('id', '') + + # Extensions d'images supportées + extensions = ['.tif', '.tiff', '.jpg', '.jpeg', '.avif', '.heic', '.webp', '.png'] + + # Patterns de noms à essayer + patterns = [ + f"{frame_id}", # 1, 2, 3... + f"{frame_id:02d}" if isinstance(frame_id, int) else frame_id, # 01, 02... + f"{frame_id:03d}" if isinstance(frame_id, int) else frame_id, # 001, 002... + f"frame_{frame_id}", # frame_1, frame_2... + f"frame{frame_id}", # frame1, frame2... + f"{roll_id}_{frame_id}", # roll_frame + f"img_{frame_id}", # img_1, img_2... + f"IMG_{frame_id}", # IMG_1, IMG_2... + ] + + for pattern in patterns: + for ext in extensions: + # Essayer en minuscule et majuscule + for name in [f"{pattern}{ext}", f"{pattern}{ext.upper()}"]: + filepath = directory / name + if filepath.exists(): + return filepath + + return None + + +def process_roll( + roll: dict, + image_dir: Path, + dry_run: bool = False, + force_xmp: bool = False +) -> tuple[int, int, int]: + """ + Traite un roll complet : valide et écrit les métadonnées pour chaque frame. + + Args: + roll: Dictionnaire du roll validé. + image_dir: Répertoire contenant les images. + dry_run: Mode simulation. + force_xmp: Force la création de fichiers XMP. + + Returns: + Tuple (succès, échecs, images non trouvées). + """ + success_count = 0 + failure_count = 0 + not_found_count = 0 + + roll_id = roll.get('id', 'unknown') + frames = roll.get('frames', []) + + logger.info(f"Traitement du roll '{roll_id}' : {len(frames)} frames") + + for frame in frames: + frame_id = frame.get('id', '?') + + # Rechercher l'image + image_path = find_image_for_frame(frame, image_dir, roll_id) + + if image_path is None: + logger.warning(f"Image non trouvée pour le frame {frame_id}") + not_found_count += 1 + continue + + logger.debug(f"Image trouvée : {image_path.name}") + + # Mapper les métadonnées + try: + tags = map_frame_to_exif(frame, roll) + except Exception as e: + logger.error(f"Erreur de mapping pour le frame {frame_id} : {e}") + failure_count += 1 + continue + + if not tags: + logger.warning(f"Aucune métadonnée à écrire pour le frame {frame_id}") + continue + + # Écrire les métadonnées + try: + if force_xmp: + xmp_path = write_xmp_sidecar(image_path, tags, dry_run) + if xmp_path: + success_count += 1 + logger.info(f"Frame {frame_id} : XMP créé ({image_path.name})") + else: + success, method = write_exif_with_fallback( + image_path, + tags, + lambda p, t, d: write_xmp_sidecar(p, t, d), + dry_run + ) + if success: + success_count += 1 + logger.info(f"Frame {frame_id} : métadonnées écrites via {method}") + else: + failure_count += 1 + logger.error(f"Frame {frame_id} : échec de l'écriture") + + except ExiftoolError as e: + logger.error(f"Erreur exiftool pour le frame {frame_id} : {e}") + failure_count += 1 + except IOError as e: + logger.error(f"Erreur I/O pour le frame {frame_id} : {e}") + failure_count += 1 + + return success_count, failure_count, not_found_count + + +def main(args: list[str] | None = None) -> int: + """ + Point d'entrée principal du CLI. + + Args: + args: Arguments de ligne de commande (utilise sys.argv si None). + + Returns: + Code de retour (0 = succès, 1 = erreur). + """ + parser = argparse.ArgumentParser( + prog='json-to-metadata', + description='Écrit les métadonnées EXIF depuis un fichier JSON Exif Notes.', + epilog='Exemple : python -m json_to_metadata roll.json -d ./images' + ) + + parser.add_argument( + 'json_file', + type=Path, + help='Chemin vers le fichier JSON Exif Notes' + ) + + parser.add_argument( + '-d', '--directory', + type=Path, + default=None, + help='Répertoire contenant les images (défaut : même répertoire que le JSON)' + ) + + parser.add_argument( + '-v', '--verbose', + action='store_true', + help='Mode verbeux (niveau DEBUG)' + ) + + parser.add_argument( + '--dry-run', + action='store_true', + help='Affiche les actions sans les exécuter' + ) + + parser.add_argument( + '--force-xmp', + action='store_true', + help='Force la création de fichiers XMP même pour les formats supportant EXIF' + ) + + parser.add_argument( + '--version', + action='version', + version=f'%(prog)s {__version__}' + ) + + parsed_args = parser.parse_args(args) + + # Configuration du logging + setup_logging(parsed_args.verbose) + + # Vérification de exiftool (sauf si force-xmp) + if not parsed_args.force_xmp: + try: + check_exiftool_available() + except ExiftoolNotFoundError as e: + logger.error(str(e)) + return 1 + + # Chargement et validation du JSON + try: + data = load_json(parsed_args.json_file) + except FileNotFoundError as e: + logger.error(str(e)) + return 1 + except Exception as e: + logger.error(f"Erreur lors du chargement du JSON : {e}") + return 1 + + # Le JSON peut contenir un roll unique ou une liste de rolls + if isinstance(data, list): + rolls = data + elif isinstance(data, dict): + # Si c'est un objet avec une clé 'rolls', l'utiliser + if 'rolls' in data: + rolls = data['rolls'] + else: + # Sinon, c'est un roll unique + rolls = [data] + else: + logger.error("Format JSON non reconnu : attendu un objet ou une liste") + return 1 + + # Validation des rolls + valid_rolls = [] + for i, roll in enumerate(rolls): + try: + validate_roll(roll) + valid_rolls.append(roll) + except ValidationError as e: + logger.error(f"Roll {i + 1} invalide : {e}") + + if not valid_rolls: + logger.error("Aucun roll valide trouvé dans le fichier JSON") + return 1 + + # Déterminer le répertoire des images + if parsed_args.directory: + image_dir = parsed_args.directory + else: + image_dir = parsed_args.json_file.parent + + if not image_dir.is_dir(): + logger.error(f"Répertoire d'images invalide : {image_dir}") + return 1 + + logger.info(f"Répertoire des images : {image_dir}") + + if parsed_args.dry_run: + logger.info("=== MODE DRY-RUN : aucune modification ne sera effectuée ===") + + # Traitement de chaque roll + total_success = 0 + total_failure = 0 + total_not_found = 0 + + for roll in valid_rolls: + success, failure, not_found = process_roll( + roll, + image_dir, + dry_run=parsed_args.dry_run, + force_xmp=parsed_args.force_xmp + ) + total_success += success + total_failure += failure + total_not_found += not_found + + # Résumé + logger.info("=" * 50) + logger.info(f"Résumé : {total_success} succès, {total_failure} échecs, " + f"{total_not_found} images non trouvées") + + if total_failure > 0: + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/json_to_metadata/exif_writer.py b/json_to_metadata/exif_writer.py new file mode 100644 index 0000000..2e66824 --- /dev/null +++ b/json_to_metadata/exif_writer.py @@ -0,0 +1,320 @@ +""" +Module d'écriture des métadonnées EXIF via exiftool. + +Ce module gère l'écriture des tags EXIF dans les fichiers images +en utilisant l'outil en ligne de commande exiftool. +""" + +import logging +import shutil +import subprocess +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Extensions supportant l'EXIF intégré +EMBEDDED_EXIF_EXTENSIONS = {'.tif', '.tiff', '.jpg', '.jpeg'} + +# Extensions avec support partiel (tentative d'intégration) +PARTIAL_SUPPORT_EXTENSIONS = {'.avif', '.heic', '.heif', '.webp'} + +# Extensions nécessitant obligatoirement un fichier XMP sidecar +XMP_ONLY_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.pef', '.raw'} + + +class ExiftoolError(Exception): + """Exception levée lors d'une erreur exiftool.""" + pass + + +class ExiftoolNotFoundError(ExiftoolError): + """Exception levée quand exiftool n'est pas installé.""" + pass + + +def check_exiftool_available() -> bool: + """ + Vérifie que exiftool est installé et accessible. + + Returns: + True si exiftool est disponible. + + Raises: + ExiftoolNotFoundError: Si exiftool n'est pas trouvé. + """ + if shutil.which('exiftool') is None: + raise ExiftoolNotFoundError( + "exiftool n'est pas installé ou n'est pas dans le PATH.\n" + "Installation :\n" + " - Arch Linux : sudo pacman -S perl-image-exiftool\n" + " - Ubuntu/Debian : sudo apt install libimage-exiftool-perl\n" + " - macOS : brew install exiftool\n" + " - Windows : https://exiftool.org/" + ) + + # Vérifier que ça fonctionne + try: + result = subprocess.run( + ['exiftool', '-ver'], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + version = result.stdout.strip() + logger.debug(f"exiftool version {version} détecté") + return True + except subprocess.TimeoutExpired: + raise ExiftoolError("exiftool ne répond pas (timeout)") + except Exception as e: + raise ExiftoolError(f"Erreur lors de la vérification de exiftool : {e}") + + return False + + +def supports_embedded_exif(filepath: Path) -> bool: + """ + Détermine si le format du fichier supporte les métadonnées EXIF intégrées. + + Args: + filepath: Chemin vers le fichier image. + + Returns: + True si le format supporte l'EXIF intégré, False sinon. + """ + ext = filepath.suffix.lower() + return ext in EMBEDDED_EXIF_EXTENSIONS + + +def supports_partial_exif(filepath: Path) -> bool: + """ + Détermine si le format a un support partiel des métadonnées. + + Ces formats peuvent accepter les métadonnées mais avec des limitations. + + Args: + filepath: Chemin vers le fichier image. + + Returns: + True si le format a un support partiel. + """ + ext = filepath.suffix.lower() + return ext in PARTIAL_SUPPORT_EXTENSIONS + + +def write_exif_to_file(filepath: Path, tags: dict, dry_run: bool = False) -> bool: + """ + Écrit les tags EXIF dans un fichier image via exiftool. + + Args: + filepath: Chemin vers le fichier image. + tags: Dictionnaire de tags EXIF à écrire. + dry_run: Si True, affiche les commandes sans les exécuter. + + Returns: + True si l'écriture a réussi. + + Raises: + ExiftoolError: Si l'écriture échoue. + FileNotFoundError: Si le fichier n'existe pas. + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise FileNotFoundError(f"Fichier image introuvable : {filepath}") + + if not tags: + logger.warning(f"Aucun tag à écrire pour {filepath}") + return True + + # Construire les arguments exiftool + args = _build_exiftool_args(tags) + + # Ajouter les options de base + cmd = [ + 'exiftool', + '-overwrite_original', # Ne pas créer de backup + '-charset', 'utf8', # Support UTF-8 + ] + cmd.extend(args) + cmd.append(str(filepath)) + + logger.debug(f"Commande exiftool : {' '.join(cmd)}") + + if dry_run: + logger.info(f"[DRY-RUN] exiftool sur {filepath.name} avec {len(tags)} tags") + return True + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + raise ExiftoolError(f"Échec de l'écriture EXIF : {error_msg}") + + # Vérifier le message de succès + if '1 image files updated' in result.stdout: + logger.info(f"Métadonnées écrites avec succès dans {filepath.name}") + return True + elif 'Warning' in result.stderr: + logger.warning(f"Avertissements lors de l'écriture : {result.stderr}") + return True + else: + logger.debug(f"Sortie exiftool : {result.stdout}") + return True + + except subprocess.TimeoutExpired: + raise ExiftoolError(f"Timeout lors de l'écriture dans {filepath}") + except Exception as e: + raise ExiftoolError(f"Erreur lors de l'écriture EXIF : {e}") + + +def write_exif_with_fallback( + filepath: Path, + tags: dict, + xmp_writer_func: callable, + dry_run: bool = False +) -> tuple[bool, str]: + """ + Tente d'écrire les métadonnées EXIF avec fallback sur XMP sidecar. + + Pour les formats avec support partiel (AVIF, HEIC, WebP), tente + d'abord l'écriture directe, puis crée un XMP sidecar en cas d'échec. + + Args: + filepath: Chemin vers le fichier image. + tags: Dictionnaire de tags EXIF. + xmp_writer_func: Fonction pour écrire le fichier XMP sidecar. + dry_run: Mode simulation. + + Returns: + Tuple (succès, méthode utilisée) où méthode est 'exif' ou 'xmp'. + """ + filepath = Path(filepath) + + # Formats supportant pleinement l'EXIF intégré + if supports_embedded_exif(filepath): + success = write_exif_to_file(filepath, tags, dry_run) + return (success, 'exif') + + # Formats avec support partiel : tentative puis fallback + if supports_partial_exif(filepath): + try: + success = write_exif_to_file(filepath, tags, dry_run) + if success: + return (True, 'exif') + except ExiftoolError as e: + logger.warning( + f"Échec de l'écriture EXIF pour {filepath.name} : {e}. " + f"Création d'un fichier XMP sidecar." + ) + + # Fallback : création du fichier XMP sidecar + try: + xmp_path = xmp_writer_func(filepath, tags, dry_run) + if xmp_path: + return (True, 'xmp') + except Exception as e: + logger.error(f"Échec de la création du XMP sidecar : {e}") + return (False, 'none') + + return (False, 'none') + + +def _build_exiftool_args(tags: dict) -> list[str]: + """ + Construit les arguments de ligne de commande pour exiftool. + + Args: + tags: Dictionnaire de tags EXIF. + + Returns: + Liste d'arguments pour exiftool. + """ + args = [] + + for tag, value in tags.items(): + if value is None: + continue + + # Formatage de la valeur selon le type + formatted_value = _format_tag_value(tag, value) + + # Construction de l'argument + args.append(f'-{tag}={formatted_value}') + + return args + + +def _format_tag_value(tag: str, value: Any) -> str: + """ + Formate une valeur de tag pour exiftool. + + Args: + tag: Nom du tag. + value: Valeur à formater. + + Returns: + Valeur formatée en chaîne. + """ + # Gestion des types spéciaux + if isinstance(value, bool): + return '1' if value else '0' + + if isinstance(value, float): + # Garder une précision raisonnable + if value == int(value): + return str(int(value)) + return f'{value:.6g}' + + if isinstance(value, (list, tuple)): + return ', '.join(str(v) for v in value) + + return str(value) + + +def read_exif_from_file(filepath: Path) -> dict: + """ + Lit les métadonnées EXIF d'un fichier image. + + Utile pour vérifier les métadonnées après écriture. + + Args: + filepath: Chemin vers le fichier image. + + Returns: + Dictionnaire des tags EXIF. + + Raises: + ExiftoolError: Si la lecture échoue. + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise FileNotFoundError(f"Fichier image introuvable : {filepath}") + + try: + result = subprocess.run( + ['exiftool', '-json', '-charset', 'utf8', str(filepath)], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise ExiftoolError(f"Échec de la lecture EXIF : {result.stderr}") + + import json + data = json.loads(result.stdout) + return data[0] if data else {} + + except subprocess.TimeoutExpired: + raise ExiftoolError(f"Timeout lors de la lecture de {filepath}") + except json.JSONDecodeError as e: + raise ExiftoolError(f"Erreur de parsing JSON : {e}") diff --git a/json_to_metadata/json_parser.py b/json_to_metadata/json_parser.py new file mode 100644 index 0000000..a18d19a --- /dev/null +++ b/json_to_metadata/json_parser.py @@ -0,0 +1,261 @@ +""" +Module de lecture et validation des fichiers JSON Exif Notes. + +Ce module fournit les fonctions nécessaires pour charger et valider +les fichiers JSON exportés par l'application Exif Notes. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +class ValidationError(Exception): + """Exception levée lors d'une erreur de validation du JSON.""" + pass + + +def load_json(filepath: Path) -> dict: + """ + Charge et parse un fichier JSON. + + Args: + filepath: Chemin vers le fichier JSON à charger. + + Returns: + Le contenu du fichier JSON sous forme de dictionnaire. + + Raises: + FileNotFoundError: Si le fichier n'existe pas. + json.JSONDecodeError: Si le fichier n'est pas un JSON valide. + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise FileNotFoundError(f"Fichier JSON introuvable : {filepath}") + + logger.info(f"Chargement du fichier JSON : {filepath}") + + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + logger.debug(f"JSON chargé avec succès : {len(str(data))} caractères") + return data + + +def validate_roll(data: dict) -> bool: + """ + Valide la structure d'un roll (pellicule) dans le JSON. + + Vérifie la présence des champs obligatoires et leur type. + + Args: + data: Dictionnaire représentant un roll. + + Returns: + True si le roll est valide. + + Raises: + ValidationError: Si la validation échoue. + """ + required_fields = ['id', 'frames'] + + for field in required_fields: + if field not in data: + raise ValidationError(f"Champ obligatoire manquant dans le roll : '{field}'") + + # Validation de l'ID + if not isinstance(data['id'], (int, str)): + raise ValidationError(f"Le champ 'id' doit être un entier ou une chaîne, " + f"reçu : {type(data['id']).__name__}") + + # Validation des frames + if not isinstance(data['frames'], list): + raise ValidationError(f"Le champ 'frames' doit être une liste, " + f"reçu : {type(data['frames']).__name__}") + + if len(data['frames']) == 0: + raise ValidationError("Le roll ne contient aucun frame") + + # Validation de la caméra (optionnel mais recommandé) + if 'camera' in data and data['camera'] is not None: + _validate_camera(data['camera']) + + # Validation du film stock (optionnel) + if 'filmStock' in data and data['filmStock'] is not None: + _validate_film_stock(data['filmStock']) + + # Validation de l'ISO (optionnel) + if 'iso' in data and data['iso'] is not None: + if not isinstance(data['iso'], (int, float)): + raise ValidationError(f"Le champ 'iso' doit être numérique, " + f"reçu : {type(data['iso']).__name__}") + + # Validation de chaque frame + for i, frame in enumerate(data['frames']): + try: + validate_frame(frame) + except ValidationError as e: + raise ValidationError(f"Erreur dans le frame {i + 1} : {e}") + + logger.info(f"Roll validé avec succès : {len(data['frames'])} frames") + return True + + +def validate_frame(frame: dict) -> bool: + """ + Valide un frame individuel. + + Vérifie la présence des champs obligatoires et le format des données. + + Args: + frame: Dictionnaire représentant un frame. + + Returns: + True si le frame est valide. + + Raises: + ValidationError: Si la validation échoue. + """ + if not isinstance(frame, dict): + raise ValidationError(f"Le frame doit être un dictionnaire, " + f"reçu : {type(frame).__name__}") + + # Champs obligatoires + required_fields = ['id'] + for field in required_fields: + if field not in frame: + raise ValidationError(f"Champ obligatoire manquant : '{field}'") + + # Validation de l'ID du frame + if not isinstance(frame['id'], (int, str)): + raise ValidationError("Le champ 'id' doit être un entier ou une chaîne") + + # Validation de la date (optionnel mais important) + if 'date' in frame and frame['date'] is not None: + _validate_date(frame['date']) + + # Validation des coordonnées GPS (optionnel) + if 'location' in frame and frame['location'] is not None: + _validate_location(frame['location']) + + # Validation de l'ouverture (optionnel) + if 'aperture' in frame and frame['aperture'] is not None: + _validate_aperture(frame['aperture']) + + # Validation de la vitesse d'obturation (optionnel) + if 'shutter' in frame and frame['shutter'] is not None: + _validate_shutter(frame['shutter']) + + # Validation de la focale (optionnel) + if 'focalLength' in frame and frame['focalLength'] is not None: + if not isinstance(frame['focalLength'], (int, float)): + raise ValidationError("Le champ 'focalLength' doit être numérique") + + return True + + +def _validate_camera(camera: Any) -> None: + """Valide les données de l'appareil photo.""" + if not isinstance(camera, dict): + raise ValidationError("Le champ 'camera' doit être un dictionnaire") + + # Make et model sont optionnels mais doivent être des chaînes si présents + if 'make' in camera and camera['make'] is not None: + if not isinstance(camera['make'], str): + raise ValidationError("camera.make doit être une chaîne") + + if 'model' in camera and camera['model'] is not None: + if not isinstance(camera['model'], str): + raise ValidationError("camera.model doit être une chaîne") + + +def _validate_film_stock(film_stock: Any) -> None: + """Valide les données du film.""" + if not isinstance(film_stock, dict): + raise ValidationError("Le champ 'filmStock' doit être un dictionnaire") + + if 'make' in film_stock and film_stock['make'] is not None: + if not isinstance(film_stock['make'], str): + raise ValidationError("filmStock.make doit être une chaîne") + + if 'model' in film_stock and film_stock['model'] is not None: + if not isinstance(film_stock['model'], str): + raise ValidationError("filmStock.model doit être une chaîne") + + +def _validate_date(date: Any) -> None: + """Valide le format de la date.""" + if not isinstance(date, str): + raise ValidationError(f"La date doit être une chaîne, reçu : {type(date).__name__}") + + # On accepte les formats ISO 8601 courants + # La validation complète sera faite lors du parsing + + +def _validate_location(location: Any) -> None: + """Valide les coordonnées GPS.""" + if not isinstance(location, dict): + raise ValidationError("Le champ 'location' doit être un dictionnaire") + + if 'latitude' in location and location['latitude'] is not None: + if not isinstance(location['latitude'], (int, float)): + raise ValidationError("latitude doit être numérique") + if not -90 <= location['latitude'] <= 90: + raise ValidationError(f"latitude hors limites : {location['latitude']}") + + if 'longitude' in location and location['longitude'] is not None: + if not isinstance(location['longitude'], (int, float)): + raise ValidationError("longitude doit être numérique") + if not -180 <= location['longitude'] <= 180: + raise ValidationError(f"longitude hors limites : {location['longitude']}") + + +def _validate_aperture(aperture: Any) -> None: + """Valide l'ouverture.""" + if isinstance(aperture, (int, float)): + if aperture <= 0: + raise ValidationError(f"L'ouverture doit être positive : {aperture}") + elif isinstance(aperture, str): + # Format accepté : "f/2.8" ou "2.8" + value = aperture.lower().replace('f/', '').replace('f', '') + try: + float(value) + except ValueError: + raise ValidationError(f"Format d'ouverture invalide : {aperture}") + else: + raise ValidationError("L'ouverture doit être numérique ou une chaîne") + + +def _validate_shutter(shutter: Any) -> None: + """Valide la vitesse d'obturation.""" + if isinstance(shutter, (int, float)): + if shutter <= 0: + raise ValidationError(f"La vitesse doit être positive : {shutter}") + elif isinstance(shutter, str): + # Mode Bulb : accepté tel quel + if shutter.lower() in ('b', 'bulb'): + return + + # Formats acceptés : "1/125", "1/125s", "2s", "2", "2"" + # Supprime les suffixes de temps (s, ", '') + value = shutter.lower().replace('"', '').replace("'", '').replace('s', '').strip() + if '/' in value: + parts = value.split('/') + if len(parts) != 2: + raise ValidationError(f"Format de vitesse invalide : {shutter}") + try: + float(parts[0]) + float(parts[1]) + except ValueError: + raise ValidationError(f"Format de vitesse invalide : {shutter}") + else: + try: + float(value) + except ValueError: + raise ValidationError(f"Format de vitesse invalide : {shutter}") + else: + raise ValidationError("La vitesse doit être numérique ou une chaîne") diff --git a/json_to_metadata/metadata_mapper.py b/json_to_metadata/metadata_mapper.py new file mode 100644 index 0000000..f990de5 --- /dev/null +++ b/json_to_metadata/metadata_mapper.py @@ -0,0 +1,386 @@ +""" +Module de mapping des données JSON vers les tags EXIF. + +Ce module convertit les données du format JSON Exif Notes vers +les tags EXIF standard utilisables par exiftool. +""" + +import logging +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +logger = logging.getLogger(__name__) + +# Fuseau horaire par défaut pour les dates +DEFAULT_TIMEZONE = ZoneInfo("Europe/Paris") + +# Mapping des sources de lumière vers les valeurs EXIF standard +LIGHT_SOURCE_MAP = { + 'daylight': 1, + 'lumière du jour': 1, + 'fluorescent': 2, + 'tungsten': 3, + 'incandescent': 3, + 'flash': 4, + 'fine weather': 9, + 'beau temps': 9, + 'cloudy': 10, + 'nuageux': 10, + 'shade': 11, + 'ombre': 11, + 'daylight fluorescent': 12, + 'day white fluorescent': 13, + 'cool white fluorescent': 14, + 'white fluorescent': 15, + 'warm white fluorescent': 16, + 'standard light a': 17, + 'standard light b': 18, + 'standard light c': 19, + 'iso studio tungsten': 24, + 'other': 255, + 'autre': 255, +} + + +def map_frame_to_exif(frame: dict, roll: dict) -> dict: + """ + Convertit un frame et son roll parent en dictionnaire de tags EXIF. + + Args: + frame: Dictionnaire représentant un frame individuel. + roll: Dictionnaire représentant le roll parent. + + Returns: + Dictionnaire de tags EXIF prêts pour exiftool. + """ + tags = {} + + # Informations de l'appareil photo (depuis le roll) + camera = roll.get('camera') or {} + if camera.get('make'): + tags['Make'] = camera['make'] + if camera.get('model'): + tags['Model'] = camera['model'] + + # Informations de l'objectif (depuis le roll ou le frame) + lens = frame.get('lens') or roll.get('lens') or {} + if lens.get('make'): + tags['LensMake'] = lens['make'] + if lens.get('model'): + tags['LensModel'] = lens['model'] + elif lens.get('make') and lens.get('model'): + tags['LensModel'] = f"{lens['make']} {lens['model']}" + + # ISO (depuis le roll) + if roll.get('iso') is not None: + tags['ISO'] = int(roll['iso']) + + # Date et heure + if frame.get('date'): + try: + dt = parse_date(frame['date']) + date_str = dt.strftime('%Y:%m:%d %H:%M:%S') + tags['DateTimeOriginal'] = date_str + tags['CreateDate'] = date_str + + # Offset du fuseau horaire + offset = dt.strftime('%z') + if offset: + offset_formatted = f"{offset[:3]}:{offset[3:]}" + tags['OffsetTimeOriginal'] = offset_formatted + except (ValueError, TypeError) as e: + logger.warning(f"Impossible de parser la date '{frame['date']}' : {e}") + + # Vitesse d'obturation + if frame.get('shutter'): + try: + exposure_time = parse_shutter_speed(frame['shutter']) + if exposure_time is not None: + tags['ExposureTime'] = exposure_time + tags['ShutterSpeedValue'] = frame['shutter'] + except ValueError as e: + logger.warning(f"Vitesse d'obturation invalide '{frame['shutter']}' : {e}") + + # Ouverture + if frame.get('aperture') is not None: + try: + aperture = parse_aperture(frame['aperture']) + tags['FNumber'] = aperture + tags['ApertureValue'] = aperture + except ValueError as e: + logger.warning(f"Ouverture invalide '{frame['aperture']}' : {e}") + + # Focale + if frame.get('focalLength') is not None: + tags['FocalLength'] = float(frame['focalLength']) + + # Coordonnées GPS + if frame.get('location'): + location = frame['location'] + lat = location.get('latitude') + lon = location.get('longitude') + if lat is not None and lon is not None: + gps_tags = format_gps_for_exif(lat, lon) + tags.update(gps_tags) + + # Altitude GPS (optionnel) + if location.get('altitude') is not None: + alt = float(location['altitude']) + tags['GPSAltitude'] = abs(alt) + tags['GPSAltitudeRef'] = 'Below Sea Level' if alt < 0 else 'Above Sea Level' + + # Note / Description + if frame.get('note'): + tags['ImageDescription'] = frame['note'] + tags['UserComment'] = frame['note'] + + # Flash + if frame.get('flashUsed') is not None: + # Valeurs EXIF Flash : 0 = non déclenché, 1 = déclenché + tags['Flash'] = 1 if frame['flashUsed'] else 0 + + # Source de lumière + if frame.get('lightSource'): + light_value = _parse_light_source(frame['lightSource']) + if light_value is not None: + tags['LightSource'] = light_value + + # Film stock (métadonnées personnalisées) + film_stock = roll.get('filmStock') or {} + film_info = _format_film_stock(film_stock) + if film_info: + # Ajout dans un tag XMP personnalisé + tags['XMP-dc:Description'] = film_info + # Aussi dans le sujet pour la compatibilité + if 'ImageDescription' in tags: + tags['ImageDescription'] = f"{tags['ImageDescription']} | Film: {film_info}" + else: + tags['ImageDescription'] = f"Film: {film_info}" + + # Numéro de frame + if frame.get('id') is not None: + tags['ImageNumber'] = frame['id'] + + # Compensation d'exposition + if frame.get('exposureComp') is not None: + tags['ExposureCompensation'] = float(frame['exposureComp']) + + logger.debug(f"Mapping terminé : {len(tags)} tags générés") + return tags + + +def parse_shutter_speed(shutter: Any) -> float | None: + """ + Convertit une vitesse d'obturation en valeur décimale. + + Args: + shutter: Vitesse sous forme de fraction ("1/125"), nombre, ou "B" pour bulb. + + Returns: + Temps d'exposition en secondes, ou None pour le mode Bulb. + + Raises: + ValueError: Si le format est invalide. + + Examples: + >>> parse_shutter_speed("1/125") + 0.008 + >>> parse_shutter_speed("1/1000") + 0.001 + >>> parse_shutter_speed("2") + 2.0 + >>> parse_shutter_speed('2"') + 2.0 + >>> parse_shutter_speed("B") + None + >>> parse_shutter_speed(0.5) + 0.5 + """ + if isinstance(shutter, (int, float)): + return float(shutter) + + if not isinstance(shutter, str): + raise ValueError(f"Format de vitesse non supporté : {type(shutter)}") + + # Mode Bulb : pas de valeur numérique + if shutter.lower() in ('b', 'bulb'): + return None + + # Nettoyage de la chaîne (supprime s, ", ') + value = shutter.lower().replace('s', '').replace('"', '').replace("'", '').strip() + + if '/' in value: + parts = value.split('/') + if len(parts) != 2: + raise ValueError(f"Format de fraction invalide : {shutter}") + try: + numerator = float(parts[0]) + denominator = float(parts[1]) + if denominator == 0: + raise ValueError("Division par zéro") + return numerator / denominator + except (ValueError, ZeroDivisionError) as e: + raise ValueError(f"Impossible de parser '{shutter}' : {e}") + else: + try: + return float(value) + except ValueError: + raise ValueError(f"Format de vitesse invalide : {shutter}") + + +def parse_aperture(aperture: Any) -> float: + """ + Convertit une ouverture en valeur numérique f-stop. + + Args: + aperture: Ouverture sous forme de nombre ou chaîne ("f/2.8", "2.8"). + + Returns: + Valeur f-stop. + + Raises: + ValueError: Si le format est invalide. + + Examples: + >>> parse_aperture("f/2.8") + 2.8 + >>> parse_aperture("f2.8") + 2.8 + >>> parse_aperture(5.6) + 5.6 + """ + if isinstance(aperture, (int, float)): + return float(aperture) + + if not isinstance(aperture, str): + raise ValueError(f"Format d'ouverture non supporté : {type(aperture)}") + + # Nettoyage : supprime "f/", "f" et les espaces + value = aperture.lower().replace('f/', '').replace('f', '').strip() + + try: + return float(value) + except ValueError: + raise ValueError(f"Format d'ouverture invalide : {aperture}") + + +def parse_date(date_str: str) -> datetime: + """ + Parse une date ISO 8601 avec gestion du fuseau horaire. + + Args: + date_str: Date au format ISO 8601. + + Returns: + Objet datetime avec fuseau horaire. + + Raises: + ValueError: Si le format est invalide. + + Examples: + >>> parse_date("2024-03-15T14:30:00") + datetime(2024, 3, 15, 14, 30, tzinfo=ZoneInfo('Europe/Paris')) + >>> parse_date("2024-03-15T14:30:00+02:00") + datetime(2024, 3, 15, 14, 30, tzinfo=timezone(timedelta(hours=2))) + """ + if not isinstance(date_str, str): + raise ValueError(f"La date doit être une chaîne : {type(date_str)}") + + # Formats à essayer + formats = [ + '%Y-%m-%dT%H:%M:%S%z', # ISO avec timezone offset + '%Y-%m-%dT%H:%M:%S.%f%z', # ISO avec millisecondes et timezone + '%Y-%m-%dT%H:%M%z', # ISO sans secondes avec timezone + '%Y-%m-%dT%H:%M:%S', # ISO sans timezone + '%Y-%m-%dT%H:%M:%S.%f', # ISO avec millisecondes + '%Y-%m-%dT%H:%M', # ISO sans secondes + '%Y-%m-%d %H:%M:%S', # Format simple + '%Y-%m-%d %H:%M', # Format simple sans secondes + '%Y-%m-%d', # Date seule + ] + + # Normalisation du format timezone (remplace Z par +00:00) + normalized = date_str.replace('Z', '+00:00') + + for fmt in formats: + try: + dt = datetime.strptime(normalized, fmt) + # Si pas de timezone, utiliser le fuseau par défaut + if dt.tzinfo is None: + dt = dt.replace(tzinfo=DEFAULT_TIMEZONE) + return dt + except ValueError: + continue + + raise ValueError(f"Format de date non reconnu : {date_str}") + + +def format_gps_for_exif(lat: float, lon: float) -> dict: + """ + Formate les coordonnées GPS pour exiftool. + + Args: + lat: Latitude en degrés décimaux (-90 à 90). + lon: Longitude en degrés décimaux (-180 à 180). + + Returns: + Dictionnaire avec les tags GPS formatés. + + Examples: + >>> format_gps_for_exif(48.8584, 2.2945) + {'GPSLatitude': 48.8584, 'GPSLatitudeRef': 'N', + 'GPSLongitude': 2.2945, 'GPSLongitudeRef': 'E'} + """ + tags = {} + + # Latitude + tags['GPSLatitude'] = abs(lat) + tags['GPSLatitudeRef'] = 'N' if lat >= 0 else 'S' + + # Longitude + tags['GPSLongitude'] = abs(lon) + tags['GPSLongitudeRef'] = 'E' if lon >= 0 else 'W' + + return tags + + +def _parse_light_source(light_source: Any) -> int | None: + """ + Convertit une source de lumière en valeur EXIF. + + Args: + light_source: Source de lumière (chaîne ou entier). + + Returns: + Valeur EXIF ou None si non reconnu. + """ + if isinstance(light_source, int): + return light_source + + if isinstance(light_source, str): + key = light_source.lower().strip() + return LIGHT_SOURCE_MAP.get(key) + + return None + + +def _format_film_stock(film_stock: dict) -> str: + """ + Formate les informations du film pour inclusion dans les métadonnées. + + Args: + film_stock: Dictionnaire avec make et model du film. + + Returns: + Chaîne formatée ou chaîne vide. + """ + parts = [] + + if film_stock.get('make'): + parts.append(film_stock['make']) + + if film_stock.get('model'): + parts.append(film_stock['model']) + + return ' '.join(parts) diff --git a/json_to_metadata/xmp_writer.py b/json_to_metadata/xmp_writer.py new file mode 100644 index 0000000..c5fe132 --- /dev/null +++ b/json_to_metadata/xmp_writer.py @@ -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 = ''' + + + +{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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9acf1fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "json-to-metadata" +version = "0.1.0" +description = "Script de gestion des métadonnées EXIF pour photos argentiques" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Antoine"} +] +keywords = ["exif", "metadata", "photography", "film"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics", +] + +[project.scripts] +json-to-metadata = "json_to_metadata.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "flake8>=6.0", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["json_to_metadata*"] + +[tool.flake8] +max-line-length = 100 +exclude = [ + ".git", + "__pycache__", + "venv", + ".venv", + "build", + "dist", +] +ignore = ["E501", "W503"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..80211f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest>=7.0 +flake8>=6.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..281e248 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests pour json-to-metadata.""" diff --git a/tests/test_exif_writer.py b/tests/test_exif_writer.py new file mode 100644 index 0000000..a700f7c --- /dev/null +++ b/tests/test_exif_writer.py @@ -0,0 +1,257 @@ +"""Tests pour le module exif_writer.""" + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from json_to_metadata.exif_writer import ( + ExiftoolError, + ExiftoolNotFoundError, + _build_exiftool_args, + _format_tag_value, + check_exiftool_available, + supports_embedded_exif, + supports_partial_exif, + write_exif_to_file, + write_exif_with_fallback, +) + + +class TestCheckExiftoolAvailable: + """Tests pour la fonction check_exiftool_available.""" + + @patch('shutil.which') + @patch('subprocess.run') + def test_exiftool_available(self, mock_run, mock_which): + """Retourne True si exiftool est disponible.""" + mock_which.return_value = '/usr/bin/exiftool' + mock_run.return_value = MagicMock(returncode=0, stdout='12.50') + + assert check_exiftool_available() is True + + @patch('shutil.which') + def test_exiftool_not_found(self, mock_which): + """Lève une exception si exiftool n'est pas trouvé.""" + mock_which.return_value = None + + with pytest.raises(ExiftoolNotFoundError): + check_exiftool_available() + + @patch('shutil.which') + @patch('subprocess.run') + def test_exiftool_timeout(self, mock_run, mock_which): + """Lève une exception en cas de timeout.""" + mock_which.return_value = '/usr/bin/exiftool' + mock_run.side_effect = subprocess.TimeoutExpired('exiftool', 10) + + with pytest.raises(ExiftoolError, match='timeout'): + check_exiftool_available() + + +class TestSupportsEmbeddedExif: + """Tests pour la fonction supports_embedded_exif.""" + + def test_tiff_supported(self): + """Les fichiers TIFF supportent l'EXIF intégré.""" + assert supports_embedded_exif(Path('photo.tif')) is True + assert supports_embedded_exif(Path('photo.tiff')) is True + assert supports_embedded_exif(Path('photo.TIF')) is True + + def test_jpeg_supported(self): + """Les fichiers JPEG supportent l'EXIF intégré.""" + assert supports_embedded_exif(Path('photo.jpg')) is True + assert supports_embedded_exif(Path('photo.jpeg')) is True + assert supports_embedded_exif(Path('photo.JPG')) is True + + def test_avif_not_fully_supported(self): + """Les fichiers AVIF n'ont pas un support complet.""" + assert supports_embedded_exif(Path('photo.avif')) is False + + def test_png_not_supported(self): + """Les fichiers PNG ne supportent pas l'EXIF intégré.""" + assert supports_embedded_exif(Path('photo.png')) is False + + +class TestSupportsPartialExif: + """Tests pour la fonction supports_partial_exif.""" + + def test_avif_partial_support(self): + """Les fichiers AVIF ont un support partiel.""" + assert supports_partial_exif(Path('photo.avif')) is True + + def test_heic_partial_support(self): + """Les fichiers HEIC ont un support partiel.""" + assert supports_partial_exif(Path('photo.heic')) is True + + def test_webp_partial_support(self): + """Les fichiers WebP ont un support partiel.""" + assert supports_partial_exif(Path('photo.webp')) is True + + def test_jpeg_full_support(self): + """Les fichiers JPEG n'ont pas un support 'partiel'.""" + assert supports_partial_exif(Path('photo.jpg')) is False + + +class TestBuildExiftoolArgs: + """Tests pour la fonction _build_exiftool_args.""" + + def test_simple_tags(self): + """Construit des arguments pour des tags simples.""" + tags = { + 'Make': 'Nikon', + 'Model': 'FM2', + 'ISO': 400 + } + + args = _build_exiftool_args(tags) + + assert '-Make=Nikon' in args + assert '-Model=FM2' in args + assert '-ISO=400' in args + + def test_skip_none_values(self): + """Ignore les valeurs None.""" + tags = {'Make': 'Nikon', 'Model': None} + + args = _build_exiftool_args(tags) + + assert '-Make=Nikon' in args + assert len([a for a in args if 'Model' in a]) == 0 + + def test_float_formatting(self): + """Formate correctement les nombres flottants.""" + tags = {'FNumber': 2.8, 'ExposureTime': 0.008} + + args = _build_exiftool_args(tags) + + assert '-FNumber=2.8' in args + assert any('ExposureTime' in a for a in args) + + +class TestFormatTagValue: + """Tests pour la fonction _format_tag_value.""" + + def test_boolean_true(self): + """Formate True en '1'.""" + assert _format_tag_value('Flash', True) == '1' + + def test_boolean_false(self): + """Formate False en '0'.""" + assert _format_tag_value('Flash', False) == '0' + + def test_integer_float(self): + """Formate les floats entiers sans décimales.""" + assert _format_tag_value('ISO', 400.0) == '400' + + def test_decimal_float(self): + """Formate les floats décimaux avec précision.""" + result = _format_tag_value('FNumber', 2.8) + assert '2.8' in result + + def test_list_formatting(self): + """Formate les listes en chaîne séparée par virgules.""" + assert _format_tag_value('Keywords', ['a', 'b', 'c']) == 'a, b, c' + + +class TestWriteExifToFile: + """Tests pour la fonction write_exif_to_file.""" + + def test_file_not_found(self): + """Lève une exception si le fichier n'existe pas.""" + with pytest.raises(FileNotFoundError): + write_exif_to_file(Path('/nonexistent/photo.jpg'), {'Make': 'Test'}) + + def test_empty_tags(self, tmp_path): + """Retourne True pour des tags vides.""" + image = tmp_path / 'photo.jpg' + image.write_bytes(b'fake image content') + + result = write_exif_to_file(image, {}) + + assert result is True + + @patch('subprocess.run') + def test_dry_run_mode(self, mock_run, tmp_path): + """Le mode dry-run n'exécute pas exiftool.""" + image = tmp_path / 'photo.jpg' + image.write_bytes(b'fake image content') + + result = write_exif_to_file(image, {'Make': 'Nikon'}, dry_run=True) + + assert result is True + mock_run.assert_not_called() + + @patch('subprocess.run') + def test_successful_write(self, mock_run, tmp_path): + """Écrit les métadonnées avec succès.""" + image = tmp_path / 'photo.jpg' + image.write_bytes(b'fake image content') + mock_run.return_value = MagicMock( + returncode=0, + stdout='1 image files updated', + stderr='' + ) + + result = write_exif_to_file(image, {'Make': 'Nikon'}) + + assert result is True + mock_run.assert_called_once() + + @patch('subprocess.run') + def test_exiftool_error(self, mock_run, tmp_path): + """Lève une exception en cas d'erreur exiftool.""" + image = tmp_path / 'photo.jpg' + image.write_bytes(b'fake image content') + mock_run.return_value = MagicMock( + returncode=1, + stdout='', + stderr='Error: Invalid file format' + ) + + with pytest.raises(ExiftoolError, match='Invalid file format'): + write_exif_to_file(image, {'Make': 'Nikon'}) + + +class TestWriteExifWithFallback: + """Tests pour la fonction write_exif_with_fallback.""" + + @patch('json_to_metadata.exif_writer.write_exif_to_file') + def test_jpeg_uses_exif(self, mock_write, tmp_path): + """Les JPEG utilisent l'écriture EXIF directe.""" + image = tmp_path / 'photo.jpg' + image.write_bytes(b'fake image content') + mock_write.return_value = True + mock_xmp = MagicMock() + + success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) + + assert success is True + assert method == 'exif' + mock_write.assert_called_once() + + @patch('json_to_metadata.exif_writer.write_exif_to_file') + def test_avif_fallback_to_xmp(self, mock_write, tmp_path): + """Les AVIF utilisent XMP en fallback si EXIF échoue.""" + image = tmp_path / 'photo.avif' + image.write_bytes(b'fake image content') + mock_write.side_effect = ExiftoolError('Not supported') + mock_xmp = MagicMock(return_value=image.with_suffix('.xmp')) + + success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) + + assert success is True + assert method == 'xmp' + + def test_png_uses_xmp_directly(self, tmp_path): + """Les PNG utilisent directement XMP.""" + image = tmp_path / 'photo.png' + image.write_bytes(b'fake image content') + xmp_path = image.with_suffix('.xmp') + mock_xmp = MagicMock(return_value=xmp_path) + + success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp) + + assert success is True + assert method == 'xmp' diff --git a/tests/test_json_parser.py b/tests/test_json_parser.py new file mode 100644 index 0000000..325925e --- /dev/null +++ b/tests/test_json_parser.py @@ -0,0 +1,263 @@ +"""Tests pour le module json_parser.""" + +import json +from pathlib import Path + +import pytest + +from json_to_metadata.json_parser import ( + ValidationError, + load_json, + validate_frame, + validate_roll, +) + + +class TestLoadJson: + """Tests pour la fonction load_json.""" + + def test_load_valid_json(self, tmp_path): + """Charge un fichier JSON valide.""" + data = {'id': 1, 'frames': []} + json_file = tmp_path / 'test.json' + json_file.write_text(json.dumps(data), encoding='utf-8') + + result = load_json(json_file) + + assert result == data + + def test_load_json_file_not_found(self): + """Lève une exception si le fichier n'existe pas.""" + with pytest.raises(FileNotFoundError): + load_json(Path('/nonexistent/file.json')) + + def test_load_invalid_json(self, tmp_path): + """Lève une exception pour un JSON malformé.""" + json_file = tmp_path / 'invalid.json' + json_file.write_text('{ invalid json }', encoding='utf-8') + + with pytest.raises(json.JSONDecodeError): + load_json(json_file) + + def test_load_json_with_unicode(self, tmp_path): + """Charge un JSON avec des caractères Unicode.""" + data = {'note': 'Café à Paris 🇫🇷'} + json_file = tmp_path / 'unicode.json' + json_file.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8') + + result = load_json(json_file) + + assert result['note'] == 'Café à Paris 🇫🇷' + + +class TestValidateRoll: + """Tests pour la fonction validate_roll.""" + + def test_valid_roll_minimal(self): + """Valide un roll avec les champs minimaux.""" + roll = { + 'id': 1, + 'frames': [{'id': 1}] + } + + assert validate_roll(roll) is True + + def test_valid_roll_complete(self): + """Valide un roll complet avec tous les champs optionnels.""" + roll = { + 'id': 'roll-001', + 'frames': [ + {'id': 1, 'date': '2024-03-15T14:30:00'} + ], + 'camera': {'make': 'Nikon', 'model': 'FM2'}, + 'filmStock': {'make': 'Kodak', 'model': 'Portra 400'}, + 'iso': 400 + } + + assert validate_roll(roll) is True + + def test_missing_id(self): + """Lève une exception si l'id est manquant.""" + roll = {'frames': [{'id': 1}]} + + with pytest.raises(ValidationError, match="'id'"): + validate_roll(roll) + + def test_missing_frames(self): + """Lève une exception si frames est manquant.""" + roll = {'id': 1} + + with pytest.raises(ValidationError, match="'frames'"): + validate_roll(roll) + + def test_empty_frames(self): + """Lève une exception si frames est vide.""" + roll = {'id': 1, 'frames': []} + + with pytest.raises(ValidationError, match="aucun frame"): + validate_roll(roll) + + def test_frames_not_list(self): + """Lève une exception si frames n'est pas une liste.""" + roll = {'id': 1, 'frames': 'invalid'} + + with pytest.raises(ValidationError, match="liste"): + validate_roll(roll) + + def test_invalid_camera_type(self): + """Lève une exception si camera n'est pas un dict.""" + roll = { + 'id': 1, + 'frames': [{'id': 1}], + 'camera': 'Nikon FM2' + } + + with pytest.raises(ValidationError, match="dictionnaire"): + validate_roll(roll) + + def test_invalid_iso_type(self): + """Lève une exception si iso n'est pas numérique.""" + roll = { + 'id': 1, + 'frames': [{'id': 1}], + 'iso': 'four hundred' + } + + with pytest.raises(ValidationError, match="numérique"): + validate_roll(roll) + + def test_frame_validation_error_propagation(self): + """Les erreurs de validation des frames sont propagées.""" + roll = { + 'id': 1, + 'frames': [ + {'id': 1}, + {'note': 'missing id'} # Frame invalide + ] + } + + with pytest.raises(ValidationError, match="frame 2"): + validate_roll(roll) + + +class TestValidateFrame: + """Tests pour la fonction validate_frame.""" + + def test_valid_frame_minimal(self): + """Valide un frame avec les champs minimaux.""" + frame = {'id': 1} + + assert validate_frame(frame) is True + + def test_valid_frame_complete(self): + """Valide un frame complet.""" + frame = { + 'id': 1, + 'date': '2024-03-15T14:30:00', + 'aperture': 'f/2.8', + 'shutter': '1/125', + 'focalLength': 50, + 'location': {'latitude': 48.8584, 'longitude': 2.2945}, + 'note': 'Tour Eiffel' + } + + assert validate_frame(frame) is True + + def test_missing_id(self): + """Lève une exception si l'id est manquant.""" + frame = {'date': '2024-03-15'} + + with pytest.raises(ValidationError, match="'id'"): + validate_frame(frame) + + def test_invalid_frame_type(self): + """Lève une exception si le frame n'est pas un dict.""" + with pytest.raises(ValidationError, match="dictionnaire"): + validate_frame("not a frame") + + def test_valid_aperture_formats(self): + """Accepte différents formats d'ouverture.""" + frames = [ + {'id': 1, 'aperture': 2.8}, + {'id': 2, 'aperture': 'f/2.8'}, + {'id': 3, 'aperture': 'f2.8'}, + {'id': 4, 'aperture': '5.6'}, + ] + + for frame in frames: + assert validate_frame(frame) is True + + def test_invalid_aperture(self): + """Lève une exception pour une ouverture invalide.""" + frame = {'id': 1, 'aperture': 'wide open'} + + with pytest.raises(ValidationError, match="ouverture"): + validate_frame(frame) + + def test_valid_shutter_formats(self): + """Accepte différents formats de vitesse.""" + frames = [ + {'id': 1, 'shutter': '1/125'}, + {'id': 2, 'shutter': '1/125s'}, + {'id': 3, 'shutter': '2'}, + {'id': 4, 'shutter': '2s'}, + {'id': 5, 'shutter': 0.5}, + {'id': 6, 'shutter': '2"'}, # 2 secondes avec guillemet + {'id': 7, 'shutter': "4'"}, # 4 secondes avec apostrophe + {'id': 8, 'shutter': 'B'}, # Mode Bulb + {'id': 9, 'shutter': 'bulb'}, # Mode Bulb (minuscule) + ] + + for frame in frames: + assert validate_frame(frame) is True + + def test_invalid_shutter(self): + """Lève une exception pour une vitesse invalide.""" + frame = {'id': 1, 'shutter': 'fast'} + + with pytest.raises(ValidationError, match="vitesse"): + validate_frame(frame) + + def test_valid_gps_coordinates(self): + """Accepte des coordonnées GPS valides.""" + frame = { + 'id': 1, + 'location': { + 'latitude': 48.8584, + 'longitude': 2.2945 + } + } + + assert validate_frame(frame) is True + + def test_invalid_latitude_range(self): + """Lève une exception pour une latitude hors limites.""" + frame = { + 'id': 1, + 'location': {'latitude': 91.0, 'longitude': 0} + } + + with pytest.raises(ValidationError, match="latitude"): + validate_frame(frame) + + def test_invalid_longitude_range(self): + """Lève une exception pour une longitude hors limites.""" + frame = { + 'id': 1, + 'location': {'latitude': 0, 'longitude': 181.0} + } + + with pytest.raises(ValidationError, match="longitude"): + validate_frame(frame) + + def test_null_optional_fields(self): + """Accepte des champs optionnels à None.""" + frame = { + 'id': 1, + 'date': None, + 'aperture': None, + 'shutter': None, + 'location': None + } + + assert validate_frame(frame) is True diff --git a/tests/test_metadata_mapper.py b/tests/test_metadata_mapper.py new file mode 100644 index 0000000..13d1b5a --- /dev/null +++ b/tests/test_metadata_mapper.py @@ -0,0 +1,327 @@ +"""Tests pour le module metadata_mapper.""" + +from zoneinfo import ZoneInfo + +import pytest + +from json_to_metadata.metadata_mapper import ( + format_gps_for_exif, + map_frame_to_exif, + parse_aperture, + parse_date, + parse_shutter_speed, +) + + +class TestParseShutterSpeed: + """Tests pour la fonction parse_shutter_speed.""" + + def test_fraction_format(self): + """Parse une fraction simple.""" + assert parse_shutter_speed('1/125') == pytest.approx(0.008, rel=0.01) + assert parse_shutter_speed('1/1000') == pytest.approx(0.001, rel=0.01) + assert parse_shutter_speed('1/60') == pytest.approx(0.0167, rel=0.01) + + def test_fraction_with_suffix(self): + """Parse une fraction avec suffixe 's'.""" + assert parse_shutter_speed('1/125s') == pytest.approx(0.008, rel=0.01) + + def test_whole_seconds(self): + """Parse des temps en secondes entières.""" + assert parse_shutter_speed('2') == 2.0 + assert parse_shutter_speed('2s') == 2.0 + assert parse_shutter_speed('30') == 30.0 + + def test_seconds_with_quote(self): + """Parse des temps avec guillemet (notation photographique).""" + assert parse_shutter_speed('2"') == 2.0 + assert parse_shutter_speed("4'") == 4.0 + assert parse_shutter_speed('30"') == 30.0 + + def test_bulb_mode(self): + """Retourne None pour le mode Bulb.""" + assert parse_shutter_speed('B') is None + assert parse_shutter_speed('b') is None + assert parse_shutter_speed('Bulb') is None + assert parse_shutter_speed('bulb') is None + + def test_numeric_input(self): + """Accepte des valeurs numériques directes.""" + assert parse_shutter_speed(0.5) == 0.5 + assert parse_shutter_speed(2) == 2.0 + + def test_decimal_string(self): + """Parse des chaînes décimales.""" + assert parse_shutter_speed('0.5') == 0.5 + + def test_invalid_format(self): + """Lève une exception pour un format invalide.""" + with pytest.raises(ValueError): + parse_shutter_speed('fast') + + def test_division_by_zero(self): + """Lève une exception pour une division par zéro.""" + with pytest.raises(ValueError): + parse_shutter_speed('1/0') + + +class TestParseAperture: + """Tests pour la fonction parse_aperture.""" + + def test_f_stop_format(self): + """Parse le format f/X.X.""" + assert parse_aperture('f/2.8') == 2.8 + assert parse_aperture('f/5.6') == 5.6 + assert parse_aperture('f/16') == 16.0 + + def test_f_format_no_slash(self): + """Parse le format fX.X.""" + assert parse_aperture('f2.8') == 2.8 + assert parse_aperture('F5.6') == 5.6 + + def test_numeric_string(self): + """Parse une chaîne numérique simple.""" + assert parse_aperture('2.8') == 2.8 + assert parse_aperture('11') == 11.0 + + def test_numeric_input(self): + """Accepte des valeurs numériques directes.""" + assert parse_aperture(2.8) == 2.8 + assert parse_aperture(8) == 8.0 + + def test_invalid_format(self): + """Lève une exception pour un format invalide.""" + with pytest.raises(ValueError): + parse_aperture('wide') + + +class TestParseDate: + """Tests pour la fonction parse_date.""" + + def test_iso_format_with_timezone(self): + """Parse le format ISO avec timezone.""" + result = parse_date('2024-03-15T14:30:00+02:00') + + assert result.year == 2024 + assert result.month == 3 + assert result.day == 15 + assert result.hour == 14 + assert result.minute == 30 + assert result.tzinfo is not None + + def test_iso_format_without_timezone(self): + """Parse le format ISO sans timezone (utilise Europe/Paris).""" + result = parse_date('2024-03-15T14:30:00') + + assert result.year == 2024 + assert result.month == 3 + assert result.day == 15 + assert result.tzinfo == ZoneInfo('Europe/Paris') + + def test_iso_format_with_z(self): + """Parse le format ISO avec Z pour UTC.""" + result = parse_date('2024-03-15T14:30:00Z') + + assert result.tzinfo is not None + + def test_iso_format_without_seconds(self): + """Parse le format ISO sans secondes.""" + result = parse_date('2025-11-16T16:47') + + assert result.year == 2025 + assert result.month == 11 + assert result.day == 16 + assert result.hour == 16 + assert result.minute == 47 + assert result.second == 0 + + def test_date_only(self): + """Parse une date sans heure.""" + result = parse_date('2024-03-15') + + assert result.year == 2024 + assert result.month == 3 + assert result.day == 15 + + def test_simple_format(self): + """Parse le format simple avec espace.""" + result = parse_date('2024-03-15 14:30:00') + + assert result.hour == 14 + assert result.minute == 30 + + def test_invalid_format(self): + """Lève une exception pour un format invalide.""" + with pytest.raises(ValueError): + parse_date('15/03/2024') + + +class TestFormatGpsForExif: + """Tests pour la fonction format_gps_for_exif.""" + + def test_north_east_coordinates(self): + """Formate des coordonnées Nord-Est.""" + result = format_gps_for_exif(48.8584, 2.2945) + + assert result['GPSLatitude'] == 48.8584 + assert result['GPSLatitudeRef'] == 'N' + assert result['GPSLongitude'] == 2.2945 + assert result['GPSLongitudeRef'] == 'E' + + def test_south_west_coordinates(self): + """Formate des coordonnées Sud-Ouest.""" + result = format_gps_for_exif(-22.9068, -43.1729) + + assert result['GPSLatitude'] == 22.9068 + assert result['GPSLatitudeRef'] == 'S' + assert result['GPSLongitude'] == 43.1729 + assert result['GPSLongitudeRef'] == 'W' + + def test_zero_coordinates(self): + """Formate des coordonnées à zéro (équateur/méridien).""" + result = format_gps_for_exif(0, 0) + + assert result['GPSLatitude'] == 0 + assert result['GPSLatitudeRef'] == 'N' + assert result['GPSLongitude'] == 0 + assert result['GPSLongitudeRef'] == 'E' + + +class TestMapFrameToExif: + """Tests pour la fonction map_frame_to_exif.""" + + def test_basic_mapping(self): + """Mappe les champs de base.""" + frame = {'id': 1} + roll = { + 'id': 'roll-1', + 'frames': [frame], + 'camera': {'make': 'Nikon', 'model': 'FM2'}, + 'iso': 400 + } + + result = map_frame_to_exif(frame, roll) + + assert result['Make'] == 'Nikon' + assert result['Model'] == 'FM2' + assert result['ISO'] == 400 + + def test_exposure_mapping(self): + """Mappe les paramètres d'exposition.""" + frame = { + 'id': 1, + 'aperture': 'f/2.8', + 'shutter': '1/125', + 'focalLength': 50 + } + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert result['FNumber'] == 2.8 + assert result['ExposureTime'] == pytest.approx(0.008, rel=0.01) + assert result['FocalLength'] == 50.0 + + def test_date_mapping(self): + """Mappe la date correctement.""" + frame = { + 'id': 1, + 'date': '2024-03-15T14:30:00' + } + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert result['DateTimeOriginal'] == '2024:03:15 14:30:00' + assert result['CreateDate'] == '2024:03:15 14:30:00' + + def test_gps_mapping(self): + """Mappe les coordonnées GPS.""" + frame = { + 'id': 1, + 'location': { + 'latitude': 48.8584, + 'longitude': 2.2945 + } + } + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert result['GPSLatitude'] == 48.8584 + assert result['GPSLatitudeRef'] == 'N' + assert result['GPSLongitude'] == 2.2945 + assert result['GPSLongitudeRef'] == 'E' + + def test_note_mapping(self): + """Mappe la note en description.""" + frame = { + 'id': 1, + 'note': 'Belle photo du coucher de soleil' + } + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert result['ImageDescription'] == 'Belle photo du coucher de soleil' + assert result['UserComment'] == 'Belle photo du coucher de soleil' + + def test_flash_mapping(self): + """Mappe l'utilisation du flash.""" + frame_with_flash = {'id': 1, 'flashUsed': True} + frame_without_flash = {'id': 2, 'flashUsed': False} + roll = {'id': 1, 'frames': []} + + result_with = map_frame_to_exif(frame_with_flash, roll) + result_without = map_frame_to_exif(frame_without_flash, roll) + + assert result_with['Flash'] == 1 + assert result_without['Flash'] == 0 + + def test_lens_mapping(self): + """Mappe les informations de l'objectif.""" + frame = {'id': 1} + roll = { + 'id': 1, + 'frames': [frame], + 'lens': {'make': 'Nikon', 'model': 'Nikkor 50mm f/1.4'} + } + + result = map_frame_to_exif(frame, roll) + + assert result['LensMake'] == 'Nikon' + assert result['LensModel'] == 'Nikkor 50mm f/1.4' + + def test_film_stock_mapping(self): + """Mappe les informations du film.""" + frame = {'id': 1} + roll = { + 'id': 1, + 'frames': [frame], + 'filmStock': {'make': 'Kodak', 'model': 'Portra 400'} + } + + result = map_frame_to_exif(frame, roll) + + assert 'Kodak Portra 400' in result['ImageDescription'] + + def test_empty_frame(self): + """Gère un frame vide.""" + frame = {'id': 1} + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert result['ImageNumber'] == 1 + + def test_invalid_shutter_logged(self): + """Les vitesses invalides sont ignorées avec un warning.""" + frame = { + 'id': 1, + 'shutter': 'invalid' + } + roll = {'id': 1, 'frames': [frame]} + + result = map_frame_to_exif(frame, roll) + + assert 'ExposureTime' not in result diff --git a/tests/test_xmp_writer.py b/tests/test_xmp_writer.py new file mode 100644 index 0000000..35d4c94 --- /dev/null +++ b/tests/test_xmp_writer.py @@ -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 'Nikon' in content + assert 'FM2' 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 des & caractères "spéciaux"'} + + content = generate_xmp_content(tags) + + assert '<avec>' in content + assert '&' 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 'Nikon' 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 'Nikon' 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