From a25b285e7705fb53cd4ff75165a9240ddd3aa087 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20build(composer):=20?= =?UTF-8?q?requ=C3=A9rir=20raw-preview-extractor=20^1.2=20(m=C3=A9tadonn?= =?UTF-8?q?=C3=A9es=20EXIF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index e2ee7a45..d4fb17fc 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "nelmio/cors-bundle": "^2.6.1", "phpdocumentor/reflection-docblock": "^6.0.3", "phpstan/phpdoc-parser": "^2.3.3", - "ronanlenouvel/raw-preview-extractor": "^1.1", + "ronanlenouvel/raw-preview-extractor": "^1.2", "symfony/asset": "8.0.*", "symfony/asset-mapper": "8.0.*", "symfony/console": "8.0.*", diff --git a/composer.lock b/composer.lock index 7e53c99b..98b4b0f9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "69f3766a67bb90f7473f93657474e690", + "content-hash": "9d7fb7f7eff0524e36a7aceceb052463", "packages": [ { "name": "api-platform/doctrine-common", @@ -3296,16 +3296,16 @@ }, { "name": "ronanlenouvel/raw-preview-extractor", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/ronan-develop/raw-preview-extractor.git", - "reference": "765933a699a0323910cc6b9beb5a6fc7388b68e4" + "reference": "4382600abc5340bd3865b423953ebd62d777a397" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ronan-develop/raw-preview-extractor/zipball/765933a699a0323910cc6b9beb5a6fc7388b68e4", - "reference": "765933a699a0323910cc6b9beb5a6fc7388b68e4", + "url": "https://api.github.com/repos/ronan-develop/raw-preview-extractor/zipball/4382600abc5340bd3865b423953ebd62d777a397", + "reference": "4382600abc5340bd3865b423953ebd62d777a397", "shasum": "" }, "require": { @@ -3352,9 +3352,9 @@ ], "support": { "issues": "https://github.com/ronan-develop/raw-preview-extractor/issues", - "source": "https://github.com/ronan-develop/raw-preview-extractor/tree/1.1.0" + "source": "https://github.com/ronan-develop/raw-preview-extractor/tree/1.2.0" }, - "time": "2026-07-17T07:36:10+00:00" + "time": "2026-07-19T19:24:46+00:00" }, { "name": "symfony/asset", From 3903a3558f7aef855676a99843b8622e1c823e8d Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat(Media):=20colonnes=20ouver?= =?UTF-8?q?ture,=20vitesse,=20ISO,=20focale,=20objectif=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260719194000.php | 30 ++++++++++++++ src/Entity/Media.php | 60 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 migrations/Version20260719194000.php diff --git a/migrations/Version20260719194000.php b/migrations/Version20260719194000.php new file mode 100644 index 00000000..621b4d15 --- /dev/null +++ b/migrations/Version20260719194000.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE medias ADD aperture VARCHAR(16) DEFAULT NULL, ADD shutter_speed VARCHAR(16) DEFAULT NULL, ADD iso INT DEFAULT NULL, ADD focal_length VARCHAR(16) DEFAULT NULL, ADD lens VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE medias DROP aperture, DROP shutter_speed, DROP iso, DROP focal_length, DROP lens'); + } +} diff --git a/src/Entity/Media.php b/src/Entity/Media.php index 5cb27089..b92eee51 100644 --- a/src/Entity/Media.php +++ b/src/Entity/Media.php @@ -60,6 +60,26 @@ class Media #[ORM\Column(length: 255, nullable: true)] private ?string $cameraModel = null; + /** Ouverture (f-number EXIF), ex: "2.8" */ + #[ORM\Column(length: 16, nullable: true)] + private ?string $aperture = null; + + /** Vitesse d'obturation (EXIF), ex: "1/250" */ + #[ORM\Column(length: 16, nullable: true)] + private ?string $shutterSpeed = null; + + /** Sensibilité ISO (EXIF) */ + #[ORM\Column(nullable: true)] + private ?int $iso = null; + + /** Longueur focale en mm (EXIF), ex: "50" */ + #[ORM\Column(length: 16, nullable: true)] + private ?string $focalLength = null; + + /** Modèle d'objectif (EXIF LensModel) */ + #[ORM\Column(length: 255, nullable: true)] + private ?string $lens = null; + /** Chemin relatif du thumbnail dans var/storage/ */ #[ORM\Column(length: 1024, nullable: true)] private ?string $thumbnailPath = null; @@ -121,6 +141,26 @@ public function getCameraModel(): ?string { return $this->cameraModel; } + public function getAperture(): ?string + { + return $this->aperture; + } + public function getShutterSpeed(): ?string + { + return $this->shutterSpeed; + } + public function getIso(): ?int + { + return $this->iso; + } + public function getFocalLength(): ?string + { + return $this->focalLength; + } + public function getLens(): ?string + { + return $this->lens; + } public function getThumbnailPath(): ?string { return $this->thumbnailPath; @@ -154,6 +194,26 @@ public function setCameraModel(?string $cameraModel): void { $this->cameraModel = $cameraModel; } + public function setAperture(?string $aperture): void + { + $this->aperture = $aperture; + } + public function setShutterSpeed(?string $shutterSpeed): void + { + $this->shutterSpeed = $shutterSpeed; + } + public function setIso(?int $iso): void + { + $this->iso = $iso; + } + public function setFocalLength(?string $focalLength): void + { + $this->focalLength = $focalLength; + } + public function setLens(?string $lens): void + { + $this->lens = $lens; + } public function setThumbnailPath(?string $thumbnailPath): void { $this->thumbnailPath = $thumbnailPath; From cf70973683ef07b089edcda4a3a699e849f383e1 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat(ExifService):=20extraire?= =?UTF-8?q?=20les=20r=C3=A9glages=20de=20prise=20de=20vue=20des=20JPEG=20(?= =?UTF-8?q?#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/ExifService.php | 30 ++++++- src/Service/ExifValueFormatter.php | 86 +++++++++++++++++++ tests/Unit/Service/ExifValueFormatterTest.php | 74 ++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/Service/ExifValueFormatter.php create mode 100644 tests/Unit/Service/ExifValueFormatterTest.php diff --git a/src/Service/ExifService.php b/src/Service/ExifService.php index b15c8d59..68734b3d 100644 --- a/src/Service/ExifService.php +++ b/src/Service/ExifService.php @@ -19,7 +19,9 @@ */ class ExifService { - public function __construct() {} + public function __construct( + private readonly ExifValueFormatter $formatter = new ExifValueFormatter(), + ) {} /** * Extrait les métadonnées EXIF d'une image. @@ -32,6 +34,11 @@ public function __construct() {} * cameraModel: string|null, * gpsLat: string|null, * gpsLon: string|null, + * aperture: string|null, + * shutterSpeed: string|null, + * iso: int|null, + * focalLength: string|null, + * lens: string|null, * } */ public function extract(string $absolutePath): array @@ -43,6 +50,11 @@ public function extract(string $absolutePath): array 'cameraModel' => null, 'gpsLat' => null, 'gpsLon' => null, + 'aperture' => null, + 'shutterSpeed' => null, + 'iso' => null, + 'focalLength' => null, + 'lens' => null, ]; if (!function_exists('exif_read_data') || !file_exists($absolutePath)) { @@ -77,6 +89,22 @@ public function extract(string $absolutePath): array $result['gpsLon'] = number_format($this->gpsToDecimal($exif['GPSLongitude'], $exif['GPSLongitudeRef'] ?? 'E'), 7); } + // Réglages de prise de vue (pack photographe) : rationnels bruts mis en + // forme par ExifValueFormatter. + $result['aperture'] = $this->formatter->fNumber(isset($exif['FNumber']) ? (string) $exif['FNumber'] : null); + $result['shutterSpeed'] = $this->formatter->exposure(isset($exif['ExposureTime']) ? (string) $exif['ExposureTime'] : null); + $result['focalLength'] = $this->formatter->focalLength(isset($exif['FocalLength']) ? (string) $exif['FocalLength'] : null); + + if (isset($exif['ISOSpeedRatings'])) { + $iso = is_array($exif['ISOSpeedRatings']) ? ($exif['ISOSpeedRatings'][0] ?? null) : $exif['ISOSpeedRatings']; + $result['iso'] = null !== $iso ? (int) $iso : null; + } + + $lens = $exif['UndefinedTag:0xA434'] ?? $exif['LensModel'] ?? null; + if (is_string($lens) && '' !== trim($lens)) { + $result['lens'] = trim($lens); + } + return $result; } diff --git a/src/Service/ExifValueFormatter.php b/src/Service/ExifValueFormatter.php new file mode 100644 index 00000000..659e7035 --- /dev/null +++ b/src/Service/ExifValueFormatter.php @@ -0,0 +1,86 @@ +toFloat($raw); + + return null === $value ? null : $this->trimDecimal($value); + } + + /** + * Longueur focale (mm) → nombre court : "50/1" → "50". + */ + public function focalLength(?string $raw): ?string + { + $value = $this->toFloat($raw); + + return null === $value ? null : $this->trimDecimal($value); + } + + /** + * Vitesse d'obturation : sous la seconde, on garde la fraction ("1/250") ; + * à une seconde ou plus, on affiche des secondes décimales ("2", "1.3"). + */ + public function exposure(?string $raw): ?string + { + $value = $this->toFloat($raw); + + if (null === $value || $value <= 0.0) { + return null; + } + + if ($value < 1.0) { + return '1/'.(int) round(1 / $value); + } + + return $this->trimDecimal($value); + } + + /** + * Convertit une valeur EXIF ("num/denom" ou décimal) en float, ou null. + */ + private function toFloat(?string $raw): ?float + { + if (null === $raw || '' === $raw) { + return null; + } + + if (str_contains($raw, '/')) { + [$numerator, $denominator] = array_pad(explode('/', $raw, 2), 2, '1'); + + if (!is_numeric($numerator) || !is_numeric($denominator) || 0.0 === (float) $denominator) { + return null; + } + + return (float) $numerator / (float) $denominator; + } + + return is_numeric($raw) ? (float) $raw : null; + } + + /** + * Formate un float sans zéros superflus : 2.80 → "2.8", 4.0 → "4". + */ + private function trimDecimal(float $value): string + { + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/tests/Unit/Service/ExifValueFormatterTest.php b/tests/Unit/Service/ExifValueFormatterTest.php new file mode 100644 index 00000000..61b6b12a --- /dev/null +++ b/tests/Unit/Service/ExifValueFormatterTest.php @@ -0,0 +1,74 @@ +formatter = new ExifValueFormatter(); + } + + public function testFNumberFromRational(): void + { + self::assertSame('2.8', $this->formatter->fNumber('28/10')); + self::assertSame('4', $this->formatter->fNumber('4/1')); + self::assertSame('5.6', $this->formatter->fNumber('56/10')); + } + + public function testFNumberHandlesPlainDecimal(): void + { + self::assertSame('2.8', $this->formatter->fNumber('2.8')); + } + + public function testFNumberNullWhenEmptyOrZeroDenominator(): void + { + self::assertNull($this->formatter->fNumber(null)); + self::assertNull($this->formatter->fNumber('')); + self::assertNull($this->formatter->fNumber('28/0')); + } + + public function testExposureKeepsSubSecondFraction(): void + { + self::assertSame('1/250', $this->formatter->exposure('1/250')); + // Rationnel non réduit → réduit à 1/250. + self::assertSame('1/250', $this->formatter->exposure('10/2500')); + } + + public function testExposureAtOrAboveOneSecondIsDecimal(): void + { + self::assertSame('2', $this->formatter->exposure('2/1')); + self::assertSame('1.3', $this->formatter->exposure('13/10')); + } + + public function testExposureNull(): void + { + self::assertNull($this->formatter->exposure(null)); + self::assertNull($this->formatter->exposure('0/0')); + } + + public function testFocalLengthFromRational(): void + { + self::assertSame('50', $this->formatter->focalLength('50/1')); + self::assertSame('35', $this->formatter->focalLength('350/10')); + self::assertSame('24.5', $this->formatter->focalLength('245/10')); + } + + public function testFocalLengthNull(): void + { + self::assertNull($this->formatter->focalLength(null)); + self::assertNull($this->formatter->focalLength('50/0')); + } +} From 23ba62ede07444d61530f21a644313b65af4baa0 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20feat(MediaProcessor):=20lire=20?= =?UTF-8?q?les=20m=C3=A9tadonn=C3=A9es=20RAW=20via=20le=20package,=20mappe?= =?UTF-8?q?r=20les=20r=C3=A9glages=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/MediaProcessor.php | 89 ++++- tests/Service/MediaProcessorTest.php | 305 ++++++++++++------ .../Unit/Service/UploadRoutingDeciderTest.php | 2 + 3 files changed, 294 insertions(+), 102 deletions(-) diff --git a/src/Service/MediaProcessor.php b/src/Service/MediaProcessor.php index 3bc2fa46..721e2bd9 100644 --- a/src/Service/MediaProcessor.php +++ b/src/Service/MediaProcessor.php @@ -10,6 +10,7 @@ use App\Interface\StorageServiceInterface; use App\Repository\MediaRepository; use Doctrine\ORM\EntityManagerInterface; +use RonanLenouvel\RawPreviewExtractor\RawPreviewExtractorInterface; /** * Crée l'entité Media d'un File en extrayant les EXIF et en générant le @@ -49,6 +50,8 @@ public function __construct( private readonly ExifService $exifService, private readonly ThumbnailService $thumbnailService, private readonly StorageServiceInterface $storageService, + private readonly RawPreviewExtractorInterface $rawPreviewExtractor, + private readonly ExifValueFormatter $exifValueFormatter = new ExifValueFormatter(), ) {} public function process(File $file): ?Media @@ -68,14 +71,16 @@ public function process(File $file): ?Media if ($mediaType === 'photo') { $absolutePath = $this->storageService->getAbsolutePath($file->getPath()); - $exif = $this->exifService->extract($absolutePath); - $media->setWidth($exif['width']); - $media->setHeight($exif['height']); - $media->setTakenAt($exif['takenAt']); - $media->setCameraModel($exif['cameraModel']); - $media->setGpsLat($exif['gpsLat']); - $media->setGpsLon($exif['gpsLon']); + // JPEG : EXIF natif ; RAW : exif_read_data ne sait pas ouvrir le + // conteneur, on lit alors les métadonnées via le package (preview EXIF). + if ($this->isRaw($file->getOriginalName())) { + $this->applyRawMetadata($media, $absolutePath); + } else { + $this->applyExif($media, $this->exifService->extract($absolutePath)); + } + // Vignette inchangée : toujours générée depuis le fichier d'origine + // (ThumbnailService gère le RAW et son orientation). $thumb = $this->thumbnailService->generate($absolutePath); $media->setThumbnailPath($thumb); } @@ -86,6 +91,76 @@ public function process(File $file): ?Media return $media; } + /** + * Applique les EXIF d'un JPEG (tableau normalisé d'ExifService) au Media. + * + * @param array $exif + */ + private function applyExif(Media $media, array $exif): void + { + $media->setWidth($exif['width'] ?? null); + $media->setHeight($exif['height'] ?? null); + $media->setTakenAt($exif['takenAt'] ?? null); + $media->setCameraModel($exif['cameraModel'] ?? null); + $media->setGpsLat($exif['gpsLat'] ?? null); + $media->setGpsLon($exif['gpsLon'] ?? null); + $media->setAperture($exif['aperture'] ?? null); + $media->setShutterSpeed($exif['shutterSpeed'] ?? null); + $media->setIso($exif['iso'] ?? null); + $media->setFocalLength($exif['focalLength'] ?? null); + $media->setLens($exif['lens'] ?? null); + } + + /** + * Applique les métadonnées d'un RAW, lues via RawPreviewExtractor (EXIF de + * la preview embarquée). Dégradation gracieuse : un RAW illisible ou sans + * métadonnées laisse simplement les champs à null. + */ + private function applyRawMetadata(Media $media, string $absolutePath): void + { + try { + $meta = $this->rawPreviewExtractor->extract($absolutePath)->metadata; + } catch (\Throwable) { + return; + } + + if ($meta === null) { + return; + } + + $media->setTakenAt($this->parseExifDate($meta->dateTimeOriginal)); + $media->setCameraModel($this->cameraName($meta->cameraMake, $meta->cameraModel)); + $media->setAperture($meta->fNumber !== null ? $this->exifValueFormatter->fNumber((string) $meta->fNumber) : null); + $media->setShutterSpeed($meta->exposureTime); + $media->setIso($meta->iso); + $media->setFocalLength($meta->focalLength !== null ? $this->exifValueFormatter->focalLength((string) $meta->focalLength) : null); + $media->setLens($meta->lensModel); + } + + /** + * Convertit une date EXIF "YYYY:MM:DD HH:MM:SS" en DateTimeImmutable, ou null. + */ + private function parseExifDate(?string $raw): ?\DateTimeImmutable + { + if ($raw === null || $raw === '') { + return null; + } + + $date = \DateTimeImmutable::createFromFormat('Y:m:d H:i:s', $raw); + + return $date === false ? null : $date; + } + + /** + * Assemble "Make Model" en un libellé d'appareil, ou null si les deux manquent. + */ + private function cameraName(?string $make, ?string $model): ?string + { + $name = trim(($make ?? '').' '.($model ?? '')); + + return $name === '' ? null : $name; + } + /** * Un fichier mérite-t-il un Media (photo/vidéo), sans toucher au disque ? * diff --git a/tests/Service/MediaProcessorTest.php b/tests/Service/MediaProcessorTest.php index 063b1e4a..99375ac6 100644 --- a/tests/Service/MediaProcessorTest.php +++ b/tests/Service/MediaProcessorTest.php @@ -14,132 +14,220 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RonanLenouvel\RawPreviewExtractor\ExtractedPreview; +use RonanLenouvel\RawPreviewExtractor\Format\Format; +use RonanLenouvel\RawPreviewExtractor\RawMetadata; +use RonanLenouvel\RawPreviewExtractor\RawPreviewExtractorInterface; /** - * Tests unitaires MediaProcessor — logique de création de Media (EXIF + - * thumbnail) extraite de MediaProcessHandler pour être appelable aussi bien - * en asynchrone (handler Messenger) qu'en synchrone (import direct). + * Tests unitaires MediaProcessor — création de Media (EXIF + thumbnail). + * + * JPEG : EXIF via ExifService. RAW : exif_read_data ne sait pas ouvrir le + * conteneur, les métadonnées viennent de RawPreviewExtractor (preview EXIF). */ final class MediaProcessorTest extends TestCase { + private function baseExif(array $overrides = []): array + { + return array_merge([ + 'width' => null, 'height' => null, 'takenAt' => null, + 'cameraModel' => null, 'gpsLat' => null, 'gpsLon' => null, + 'aperture' => null, 'shutterSpeed' => null, 'iso' => null, + 'focalLength' => null, 'lens' => null, + ], $overrides); + } + + private function processor( + MediaRepository $mediaRepo, + EntityManagerInterface $em, + ExifService $exifService, + ThumbnailService $thumbnailService, + StorageServiceInterface $storageService, + ?RawPreviewExtractorInterface $rawExtractor = null, + ): MediaProcessor { + return new MediaProcessor( + $mediaRepo, + $em, + $exifService, + $thumbnailService, + $storageService, + $rawExtractor ?? $this->createStub(RawPreviewExtractorInterface::class), + ); + } + public function testProcessCreatesMediaForImage(): void { - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn('image/jpeg'); + $file->method('getOriginalName')->willReturn('photo.jpg'); $file->method('getPath')->willReturn('2026/02/test.jpg'); - $mediaRepo = $this->createMock(MediaRepository::class); + $mediaRepo = $this->createStub(MediaRepository::class); $mediaRepo->method('findOneBy')->willReturn(null); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->once())->method('persist'); $em->expects($this->once())->method('flush'); - $storageService = $this->createMock(StorageServiceInterface::class); - $storageService->expects($this->once())->method('getAbsolutePath')->with('2026/02/test.jpg')->willReturn('/var/storage/2026/02/test.jpg'); + $storageService = $this->createStub(StorageServiceInterface::class); + $storageService->method('getAbsolutePath')->willReturn('/var/storage/2026/02/test.jpg'); - $exifService = $this->createMock(ExifService::class); - $exifService->expects($this->once())->method('extract')->with('/var/storage/2026/02/test.jpg')->willReturn([ - 'width' => 1920, - 'height' => 1080, + $exifService = $this->createStub(ExifService::class); + $exifService->method('extract')->willReturn($this->baseExif([ + 'width' => 1920, 'height' => 1080, 'takenAt' => new \DateTimeImmutable('2024-06-15 12:00:00'), 'cameraModel' => 'Apple iPhone 15', - 'gpsLat' => '48.8566000', - 'gpsLon' => '2.3522000', - ]); + 'gpsLat' => '48.8566000', 'gpsLon' => '2.3522000', + ])); - $thumbnailService = $this->createMock(ThumbnailService::class); - $thumbnailService->expects($this->once())->method('generate')->with('/var/storage/2026/02/test.jpg')->willReturn('thumbs/test.jpg'); + $thumbnailService = $this->createStub(ThumbnailService::class); + $thumbnailService->method('generate')->willReturn('thumbs/test.jpg'); - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); - $media = $processor->process($file); + $media = $this->processor($mediaRepo, $em, $exifService, $thumbnailService, $storageService)->process($file); $this->assertInstanceOf(Media::class, $media); $this->assertSame('photo', $media->getMediaType()); } + /** + * Pack photographe : un JPEG expose ouverture/vitesse/ISO/focale/objectif. + */ + public function testProcessMapsPhotographerSettingsForJpeg(): void + { + $file = $this->createStub(File::class); + $file->method('getMimeType')->willReturn('image/jpeg'); + $file->method('getOriginalName')->willReturn('photo.jpg'); + $file->method('getPath')->willReturn('2026/02/test.jpg'); + + $mediaRepo = $this->createStub(MediaRepository::class); + $mediaRepo->method('findOneBy')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $storageService = $this->createStub(StorageServiceInterface::class); + $storageService->method('getAbsolutePath')->willReturn('/var/storage/2026/02/test.jpg'); + + $exifService = $this->createStub(ExifService::class); + $exifService->method('extract')->willReturn($this->baseExif([ + 'aperture' => '2.8', 'shutterSpeed' => '1/250', 'iso' => 400, + 'focalLength' => '50', 'lens' => 'RF50mm F1.8 STM', + ])); + + $thumbnailService = $this->createStub(ThumbnailService::class); + $thumbnailService->method('generate')->willReturn('thumbs/test.jpg'); + + $media = $this->processor($mediaRepo, $em, $exifService, $thumbnailService, $storageService)->process($file); + + $this->assertSame('2.8', $media->getAperture()); + $this->assertSame('1/250', $media->getShutterSpeed()); + $this->assertSame(400, $media->getIso()); + $this->assertSame('50', $media->getFocalLength()); + $this->assertSame('RF50mm F1.8 STM', $media->getLens()); + } + public function testProcessReturnsNullForNonMediaFile(): void { - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn('application/pdf'); + $file->method('getOriginalName')->willReturn('doc.pdf'); - $mediaRepo = $this->createMock(MediaRepository::class); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->never())->method('persist'); - $exifService = $this->createMock(ExifService::class); - $thumbnailService = $this->createMock(ThumbnailService::class); - $storageService = $this->createMock(StorageServiceInterface::class); - - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); - $media = $processor->process($file); + $media = $this->processor( + $this->createStub(MediaRepository::class), + $em, + $this->createStub(ExifService::class), + $this->createStub(ThumbnailService::class), + $this->createStub(StorageServiceInterface::class), + )->process($file); $this->assertNull($media); } public function testProcessReturnsExistingMediaIfAlreadyProcessed(): void { - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn('image/jpeg'); + $file->method('getOriginalName')->willReturn('photo.jpg'); - $existingMedia = $this->createMock(Media::class); + $existingMedia = $this->createStub(Media::class); - $mediaRepo = $this->createMock(MediaRepository::class); + $mediaRepo = $this->createStub(MediaRepository::class); $mediaRepo->method('findOneBy')->willReturn($existingMedia); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->never())->method('persist'); - $exifService = $this->createMock(ExifService::class); - $thumbnailService = $this->createMock(ThumbnailService::class); - $storageService = $this->createMock(StorageServiceInterface::class); - - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); - $media = $processor->process($file); + $media = $this->processor( + $mediaRepo, + $em, + $this->createStub(ExifService::class), + $this->createStub(ThumbnailService::class), + $this->createStub(StorageServiceInterface::class), + )->process($file); $this->assertSame($existingMedia, $media); } /** - * Les navigateurs n'ont pas de mimeType pour les RAW : un .NEF arrive en - * "application/octet-stream", que resolveMediaType() rejetait — aucun Media - * n'était créé, donc aucune vignette n'était même tentée. L'extension prend - * le relais quand le mimeType ne dit rien. + * RAW : les métadonnées (date, réglages, appareil, objectif) viennent de + * RawPreviewExtractor, pas d'exif_read_data. La vignette reste générée + * depuis le fichier RAW d'origine. */ #[DataProvider('rawFileProvider')] - public function testProcessCreatesPhotoMediaForRawFile(string $mimeType, string $filename): void + public function testProcessMapsRawMetadataFromPackage(string $mimeType, string $filename): void { - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn($mimeType); $file->method('getOriginalName')->willReturn($filename); - $file->method('getPath')->willReturn('2026/02/' . $filename); + $file->method('getPath')->willReturn('2026/02/'.$filename); - $mediaRepo = $this->createMock(MediaRepository::class); + $mediaRepo = $this->createStub(MediaRepository::class); $mediaRepo->method('findOneBy')->willReturn(null); - $em = $this->createMock(EntityManagerInterface::class); - $em->expects($this->once())->method('persist'); - $em->expects($this->once())->method('flush'); + $em = $this->createStub(EntityManagerInterface::class); - $storageService = $this->createMock(StorageServiceInterface::class); - $storageService->method('getAbsolutePath')->willReturn('/var/storage/2026/02/' . $filename); + $storageService = $this->createStub(StorageServiceInterface::class); + $storageService->method('getAbsolutePath')->willReturn('/var/storage/2026/02/'.$filename); + // Sur un RAW, exif_read_data ne doit pas être la source : ExifService + // n'est jamais consulté. $exifService = $this->createMock(ExifService::class); - $exifService->method('extract')->willReturn([ - 'width' => null, 'height' => null, 'takenAt' => null, - 'cameraModel' => null, 'gpsLat' => null, 'gpsLon' => null, - ]); + $exifService->expects($this->never())->method('extract'); - // Le cœur du besoin : la vignette doit être tentée sur un RAW. - $thumbnailService = $this->createMock(ThumbnailService::class); - $thumbnailService->expects($this->once())->method('generate')->willReturn('thumbs/raw.jpg'); + $preview = new ExtractedPreview( + 'jpegbytes', 6000, 4000, Format::NEF, + metadata: new RawMetadata( + dateTimeOriginal: '2024:06:15 12:30:45', + fNumber: 2.8, + exposureTime: '1/250', + iso: 400, + focalLength: 50.0, + lensModel: 'NIKKOR Z 50mm f/1.8 S', + cameraMake: 'NIKON', + cameraModel: 'Z 6', + ), + ); + + $rawExtractor = $this->createStub(RawPreviewExtractorInterface::class); + $rawExtractor->method('extract')->willReturn($preview); - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); - $media = $processor->process($file); + $thumbnailService = $this->createStub(ThumbnailService::class); + $thumbnailService->method('generate')->willReturn('thumbs/raw.jpg'); + + $media = $this->processor($mediaRepo, $em, $exifService, $thumbnailService, $storageService, $rawExtractor) + ->process($file); $this->assertInstanceOf(Media::class, $media); $this->assertSame('photo', $media->getMediaType()); $this->assertSame('thumbs/raw.jpg', $media->getThumbnailPath()); + $this->assertSame('2.8', $media->getAperture()); + $this->assertSame('1/250', $media->getShutterSpeed()); + $this->assertSame(400, $media->getIso()); + $this->assertSame('50', $media->getFocalLength()); + $this->assertSame('NIKKOR Z 50mm f/1.8 S', $media->getLens()); + $this->assertSame('NIKON Z 6', $media->getCameraModel()); + $this->assertEquals(new \DateTimeImmutable('2024-06-15 12:30:45'), $media->getTakenAt()); } /** @@ -147,47 +235,76 @@ public function testProcessCreatesPhotoMediaForRawFile(string $mimeType, string */ public static function rawFileProvider(): iterable { - // Ce que les navigateurs envoient réellement pour un RAW. - yield 'NEF en octet-stream' => ['application/octet-stream', 'DSC_0001.NEF']; - yield 'CR2 en octet-stream' => ['application/octet-stream', 'IMG_0042.CR2']; - yield 'CR3 en octet-stream' => ['application/octet-stream', 'IMG_0043.CR3']; - yield 'ARW en octet-stream' => ['application/octet-stream', 'DSC01234.ARW']; - yield 'DNG en octet-stream' => ['application/octet-stream', 'shot.dng']; - // Certains clients détectent le conteneur TIFF sous-jacent. - yield 'NEF vu comme tiff' => ['image/tiff', 'DSC_0002.NEF']; - // Extension en minuscules ou casse mixte. - yield 'extension minuscule' => ['application/octet-stream', 'photo.nef']; - yield 'casse mixte' => ['application/octet-stream', 'photo.Cr2']; + yield 'NEF en octet-stream' => ['application/octet-stream', 'DSC_0001.NEF']; + yield 'CR2 en octet-stream' => ['application/octet-stream', 'IMG_0042.CR2']; + yield 'extension minuscule' => ['application/octet-stream', 'photo.nef']; + } + + /** + * RAW illisible ou sans métadonnées : dégradation gracieuse, Media créé + * sans réglages, vignette tout de même tentée. + */ + public function testProcessToleratesRawWithoutMetadata(): void + { + $file = $this->createStub(File::class); + $file->method('getMimeType')->willReturn('application/octet-stream'); + $file->method('getOriginalName')->willReturn('broken.nef'); + $file->method('getPath')->willReturn('2026/02/broken.nef'); + + $mediaRepo = $this->createStub(MediaRepository::class); + $mediaRepo->method('findOneBy')->willReturn(null); + + $storageService = $this->createStub(StorageServiceInterface::class); + $storageService->method('getAbsolutePath')->willReturn('/var/storage/2026/02/broken.nef'); + + $rawExtractor = $this->createStub(RawPreviewExtractorInterface::class); + $rawExtractor->method('extract')->willThrowException(new \RuntimeException('unreadable')); + + $thumbnailService = $this->createStub(ThumbnailService::class); + $thumbnailService->method('generate')->willReturn(null); + + $media = $this->processor( + $mediaRepo, + $this->createStub(EntityManagerInterface::class), + $this->createStub(ExifService::class), + $thumbnailService, + $storageService, + $rawExtractor, + )->process($file); + + $this->assertInstanceOf(Media::class, $media); + $this->assertSame('photo', $media->getMediaType()); + $this->assertNull($media->getAperture()); } public function testProcessStillRejectsNonRawOctetStream(): void { - // Garde-fou : élargir la détection ne doit pas transformer n'importe quel - // binaire inconnu en photo. - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn('application/octet-stream'); $file->method('getOriginalName')->willReturn('archive.zip'); - $mediaRepo = $this->createMock(MediaRepository::class); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->never())->method('persist'); - $exifService = $this->createMock(ExifService::class); - $thumbnailService = $this->createMock(ThumbnailService::class); - $storageService = $this->createMock(StorageServiceInterface::class); - - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); + $media = $this->processor( + $this->createStub(MediaRepository::class), + $em, + $this->createStub(ExifService::class), + $this->createStub(ThumbnailService::class), + $this->createStub(StorageServiceInterface::class), + )->process($file); - $this->assertNull($processor->process($file)); + $this->assertNull($media); } public function testProcessCreatesVideoMediaWithoutExifOrThumbnail(): void { - $file = $this->createMock(File::class); + $file = $this->createStub(File::class); $file->method('getMimeType')->willReturn('video/mp4'); + $file->method('getOriginalName')->willReturn('clip.mp4'); $file->method('getPath')->willReturn('2026/02/clip.mp4'); - $mediaRepo = $this->createMock(MediaRepository::class); + $mediaRepo = $this->createStub(MediaRepository::class); $mediaRepo->method('findOneBy')->willReturn(null); $em = $this->createMock(EntityManagerInterface::class); @@ -198,24 +315,26 @@ public function testProcessCreatesVideoMediaWithoutExifOrThumbnail(): void $exifService->expects($this->never())->method('extract'); $thumbnailService = $this->createMock(ThumbnailService::class); $thumbnailService->expects($this->never())->method('generate'); - $storageService = $this->createMock(StorageServiceInterface::class); - $processor = new MediaProcessor($mediaRepo, $em, $exifService, $thumbnailService, $storageService); - $media = $processor->process($file); + $media = $this->processor( + $mediaRepo, + $em, + $exifService, + $thumbnailService, + $this->createStub(StorageServiceInterface::class), + )->process($file); $this->assertInstanceOf(Media::class, $media); $this->assertSame('video', $media->getMediaType()); } /** - * `isRaw()` expose publiquement la reconnaissance des RAW (jusqu'ici privée) - * pour que la décision de routage (UploadRoutingDecider) s'appuie sur cette - * seule source de vérité plutôt que de redupliquer la liste RAW_EXTENSIONS. + * @see \App\Service\MediaProcessor::isRaw */ #[DataProvider('isRawProvider')] public function testIsRawRecognisesRawExtensionsCaseInsensitively(string $filename, bool $expected): void { - $processor = new MediaProcessor( + $processor = $this->processor( $this->createStub(MediaRepository::class), $this->createStub(EntityManagerInterface::class), $this->createStub(ExifService::class), @@ -231,14 +350,10 @@ public function testIsRawRecognisesRawExtensionsCaseInsensitively(string $filena */ public static function isRawProvider(): iterable { - yield 'nef minuscule' => ['photo.nef', true]; - yield 'NEF majuscule' => ['PHOTO.NEF', true]; + yield 'nef minuscule' => ['photo.nef', true]; + yield 'NEF majuscule' => ['PHOTO.NEF', true]; yield 'cr2 casse mixte' => ['img.Cr2', true]; - yield 'cr3' => ['clip.cr3', true]; - yield 'arw' => ['DSC01234.arw', true]; - yield 'dng' => ['shot.dng', true]; - yield 'jpg non raw' => ['photo.jpg', false]; - yield 'tiff hors liste' => ['scan.tiff', false]; - yield 'sans extension' => ['README', false]; + yield 'jpg non raw' => ['photo.jpg', false]; + yield 'sans extension' => ['README', false]; } } diff --git a/tests/Unit/Service/UploadRoutingDeciderTest.php b/tests/Unit/Service/UploadRoutingDeciderTest.php index b231736f..90015986 100644 --- a/tests/Unit/Service/UploadRoutingDeciderTest.php +++ b/tests/Unit/Service/UploadRoutingDeciderTest.php @@ -13,6 +13,7 @@ use App\Service\UploadRoutingDecider; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use RonanLenouvel\RawPreviewExtractor\RawPreviewExtractorInterface; /** * Le worker Messenger ne doit tourner que pour les lots lourds. UploadRoutingDecider @@ -33,6 +34,7 @@ private function decider(): UploadRoutingDecider $this->createStub(ExifService::class), $this->createStub(ThumbnailService::class), $this->createStub(StorageServiceInterface::class), + $this->createStub(RawPreviewExtractorInterface::class), ); return new UploadRoutingDecider($mediaProcessor, self::THRESHOLD); From 6852638b4fbd0f2feb712d51b4b366671b642e09 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20feat(MediaOutput):=20exposer=20?= =?UTF-8?q?les=20r=C3=A9glages=20de=20prise=20de=20vue=20dans=20l'API=20(#?= =?UTF-8?q?268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ApiResource/MediaOutput.php | 5 +++++ src/State/MediaProvider.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/ApiResource/MediaOutput.php b/src/ApiResource/MediaOutput.php index 91b81818..7bed6c57 100644 --- a/src/ApiResource/MediaOutput.php +++ b/src/ApiResource/MediaOutput.php @@ -62,6 +62,11 @@ public function __construct( public readonly ?string $gpsLat, public readonly ?string $gpsLon, public readonly ?string $cameraModel, + public readonly ?string $aperture, + public readonly ?string $shutterSpeed, + public readonly ?int $iso, + public readonly ?string $focalLength, + public readonly ?string $lens, /** URL de l'endpoint thumbnail, null si pas encore généré */ public readonly ?string $thumbnailUrl, public readonly string $createdAt, diff --git a/src/State/MediaProvider.php b/src/State/MediaProvider.php index 308c1d1d..6b1931de 100644 --- a/src/State/MediaProvider.php +++ b/src/State/MediaProvider.php @@ -87,6 +87,11 @@ public function toOutput(Media $media): MediaOutput gpsLat: $media->getGpsLat(), gpsLon: $media->getGpsLon(), cameraModel: $media->getCameraModel(), + aperture: $media->getAperture(), + shutterSpeed: $media->getShutterSpeed(), + iso: $media->getIso(), + focalLength: $media->getFocalLength(), + lens: $media->getLens(), thumbnailUrl: $media->getThumbnailPath() !== null ? '/api/v1/medias/'.$media->getId().'/thumbnail' : null, From f813f8bcd0555df8ec9d2d51c60e75d15a085aec Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 19 Jul 2026 21:39:39 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20feat(galerie):=20panneau=20EXIF?= =?UTF-8?q?=20dans=20le=20lightbox=20et=20tri=20par=20date=20de=20prise=20?= =?UTF-8?q?de=20vue=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gallery_lightbox_controller.js | 25 +++++++- assets/js/exif-panel.js | 53 ++++++++++++++++ assets/styles/gallery.css | 30 +++++++++ assets/tests/exif-panel.test.js | 61 +++++++++++++++++++ src/Repository/MediaRepository.php | 4 +- .../components/GalleryLightbox.html.twig | 2 + templates/web/gallery.html.twig | 13 ++++ tests/Web/MediaGalleryTest.php | 2 +- 8 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 assets/js/exif-panel.js create mode 100644 assets/tests/exif-panel.test.js diff --git a/assets/controllers/gallery_lightbox_controller.js b/assets/controllers/gallery_lightbox_controller.js index c5ec3d80..44c356af 100644 --- a/assets/controllers/gallery_lightbox_controller.js +++ b/assets/controllers/gallery_lightbox_controller.js @@ -1,11 +1,12 @@ import { Controller } from '@hotwired/stimulus'; import Slideshow from './Slideshow.js'; +import { buildExifPanelHtml } from '../js/exif-panel.js'; /* Lightbox de la galerie médias : ouvre le média en plein écran au clic * sur une vignette, navigation précédent/suivant au clavier et via boutons. * Le diaporama (auto-avance, pause/lecture) est délégué à Slideshow. */ export default class extends Controller { - static targets = ['img', 'video', 'prev', 'next', 'play']; + static targets = ['img', 'video', 'prev', 'next', 'play', 'info']; connect() { this.links = Array.from(document.querySelectorAll('[data-lightbox]')); @@ -65,11 +66,33 @@ export default class extends Controller { this.videoTarget.style.display = 'none'; } + this.renderInfo(isVideo ? null : this.links[this.current]); + this.element.style.display = 'flex'; this.prevTarget.style.visibility = this.current > 0 ? 'visible' : 'hidden'; this.nextTarget.style.visibility = this.current < this.srcs.length - 1 ? 'visible' : 'hidden'; } + /* Remplit (ou masque) le panneau EXIF depuis les data-* de la vignette. */ + renderInfo(link) { + if (!this.hasInfoTarget) return; + + const html = link ? buildExifPanelHtml({ + takenAt: link.getAttribute('data-taken-at') || '', + camera: link.getAttribute('data-camera') || '', + aperture: link.getAttribute('data-aperture') || '', + shutter: link.getAttribute('data-shutter') || '', + iso: link.getAttribute('data-iso') || '', + focal: link.getAttribute('data-focal') || '', + lens: link.getAttribute('data-lens') || '', + gpsLat: link.getAttribute('data-gps-lat') || '', + gpsLon: link.getAttribute('data-gps-lon') || '', + }) : ''; + + this.infoTarget.innerHTML = html; + this.infoTarget.hidden = html === ''; + } + prev() { this.show(this.current - 1); } diff --git a/assets/js/exif-panel.js b/assets/js/exif-panel.js new file mode 100644 index 00000000..896fa45f --- /dev/null +++ b/assets/js/exif-panel.js @@ -0,0 +1,53 @@ +/** + * Panneau EXIF du lightbox (#268) — construction pure et testable du contenu à + * partir des attributs data-* d'une vignette photo. Aucune dépendance au DOM du + * contrôleur : le rendu se teste isolément. + */ + +function escapeHtml(text) { + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return String(text).replace(/[&<>"']/g, (m) => map[m]); +} + +/** + * Réduit un dataset de vignette aux réglages présents (champs vides ignorés). + * + * @param {Object} d - { takenAt, camera, aperture, shutter, iso, focal, lens } + * @returns {Array<{label: string, value: string}>} + */ +export function buildExifItems(d = {}) { + const items = []; + if (d.takenAt) items.push({ label: 'Prise le', value: d.takenAt }); + if (d.camera) items.push({ label: 'Appareil', value: d.camera }); + if (d.aperture) items.push({ label: 'Ouverture', value: `f/${d.aperture}` }); + if (d.shutter) items.push({ label: 'Vitesse', value: `${d.shutter}s` }); + if (d.iso) items.push({ label: 'ISO', value: String(d.iso) }); + if (d.focal) items.push({ label: 'Focale', value: `${d.focal} mm` }); + if (d.lens) items.push({ label: 'Objectif', value: d.lens }); + return items; +} + +/** + * Rend le panneau EXIF en HTML, ou une chaîne vide s'il n'y a rien à montrer. + * + * @param {Object} d - dataset de la vignette (data-* déjà lus) + * @returns {string} + */ +export function buildExifPanelHtml(d = {}) { + const items = buildExifItems(d); + let html = items + .map( + (i) => + `${escapeHtml(i.label)} ${escapeHtml(i.value)}`, + ) + .join(''); + + if (d.gpsLat && d.gpsLon) { + const lat = encodeURIComponent(d.gpsLat); + const lon = encodeURIComponent(d.gpsLon); + const url = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=15/${lat}/${lon}`; + html += `📍 Voir sur la carte`; + } + + return html; +} diff --git a/assets/styles/gallery.css b/assets/styles/gallery.css index 9d296fc3..19b8c05f 100644 --- a/assets/styles/gallery.css +++ b/assets/styles/gallery.css @@ -407,3 +407,33 @@ right: 0.5rem; } } + +/* Panneau EXIF du lightbox (réglages de prise de vue) — #268 */ +.hc-lightbox-exif { + position: absolute; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + max-width: min(92vw, 900px); + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem 0.9rem; + padding: 0.55rem 0.9rem; + border-radius: 0.75rem; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(8px); + color: #fff; + font-size: 0.82rem; + line-height: 1.3; + pointer-events: auto; +} +.hc-lightbox-exif[hidden] { display: none; } +.hc-exif-item { white-space: nowrap; } +.hc-exif-label { opacity: 0.6; margin-right: 0.15rem; } +.hc-exif-gps { color: #fff; text-decoration: none; } +.hc-exif-gps:hover { text-decoration: underline; } + +@media (max-width: 767px) { + .hc-lightbox-exif { font-size: 0.75rem; bottom: 0.6rem; gap: 0.3rem 0.6rem; } +} diff --git a/assets/tests/exif-panel.test.js b/assets/tests/exif-panel.test.js new file mode 100644 index 00000000..8bddb683 --- /dev/null +++ b/assets/tests/exif-panel.test.js @@ -0,0 +1,61 @@ +import { describe, test, expect } from '@jest/globals'; + +const { buildExifItems, buildExifPanelHtml } = await import('../js/exif-panel.js'); + +describe('buildExifItems', () => { + test('ne garde que les réglages présents', () => { + const items = buildExifItems({ + takenAt: '15/06/2024 12:30', + camera: 'NIKON Z 6', + aperture: '2.8', + shutter: '1/250', + iso: '400', + focal: '50', + lens: 'NIKKOR Z 50mm', + }); + expect(items).toEqual([ + { label: 'Prise le', value: '15/06/2024 12:30' }, + { label: 'Appareil', value: 'NIKON Z 6' }, + { label: 'Ouverture', value: 'f/2.8' }, + { label: 'Vitesse', value: '1/250s' }, + { label: 'ISO', value: '400' }, + { label: 'Focale', value: '50 mm' }, + { label: 'Objectif', value: 'NIKKOR Z 50mm' }, + ]); + }); + + test('champs vides ignorés', () => { + expect(buildExifItems({ aperture: '', iso: '400' })).toEqual([ + { label: 'ISO', value: '400' }, + ]); + }); + + test('dataset vide → aucun item', () => { + expect(buildExifItems({})).toEqual([]); + }); +}); + +describe('buildExifPanelHtml', () => { + test('rend les réglages en HTML', () => { + const html = buildExifPanelHtml({ aperture: '2.8', iso: '400' }); + expect(html).toContain('f/2.8'); + expect(html).toContain('ISO'); + expect(html).toContain('400'); + }); + + test('ajoute un lien carte quand le GPS est présent', () => { + const html = buildExifPanelHtml({ gpsLat: '48.8566', gpsLon: '2.3522' }); + expect(html).toContain('openstreetmap.org'); + expect(html).toContain('48.8566'); + }); + + test('chaîne vide quand rien à montrer', () => { + expect(buildExifPanelHtml({})).toBe(''); + }); + + test('échappe le HTML des valeurs (anti-XSS)', () => { + const html = buildExifPanelHtml({ lens: '' }); + expect(html).not.toContain('