diff --git a/README.md b/README.md index 5db8df5..1a2c641 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Extract the JPEG preview embedded in camera RAW files — **pure PHP, no externa [![CI](https://github.com/ronan-develop/raw-preview-extractor/actions/workflows/ci.yml/badge.svg)](https://github.com/ronan-develop/raw-preview-extractor/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -> **Status: feature-complete, not yet released.** All five formats extract; the API below -> is implemented and covered by tests. Waiting on validation against real camera files -> before tagging `1.0.0`. +> **Status: feature-complete and validated against real camera files** (see +> [Tested cameras](#tested-cameras)). Not yet tagged `1.0.0` — the public API is settled, +> but a released version is a semver commitment, and more cameras are worth testing first. ## Why @@ -123,18 +123,24 @@ return [ Verified against real files from [raw.pixls.us](https://raw.pixls.us/) (CC0): -| Camera | Format | File | Preview extracted | -|---------------|--------|--------|----------------------| -| Nikon D750 | NEF | 25 MB | 6016×4016 — 952 KB | -| Canon EOS R | CR3 | 30 MB | 1620×1080 — 228 KB | -| Canon EOS RP | CR3 | 7 MB | 1620×1080 — 328 KB | +All five formats are verified against real camera files: -Extraction takes **1–3 ms** regardless of file size: the file is never loaded into memory, -only the container structure is read and the JPEG block is seeked to directly. +| Camera | Format | File | Preview extracted | +|--------------------|--------|-------|-----------------------| +| Canon EOS 5D Mk IV | CR2 | 62 MB | 6720×4480 — 2047 KB | +| Canon EOS R | CR3 | 30 MB | 1620×1080 — 228 KB | +| Canon EOS RP | CR3 | 7 MB | 1620×1080 — 328 KB | +| Nikon D750 | NEF | 25 MB | 6016×4016 — 952 KB | +| Sony α7 (ILCE-7) | ARW | 24 MB | 1616×1080 — 460 KB | +| Apple iPhone 12 Pro| DNG | 29 MB | 4032×3024 — 5239 KB | -CR2, ARW and DNG are covered by synthetic tests but **not yet verified against real files**. -RAW structure varies between camera generations, and CR3 has no public specification — if -your camera is not listed, the library may well work, it just has not been verified. +Every preview above is checked with `imagecreatefromstring()` — not just decoded headers, +but actually opened. Extraction takes **1–9 ms** on files up to 62 MB: the file is never +loaded into memory, only the container structure is read before seeking to the JPEG block. + +RAW layout varies between camera generations, and CR3 has no public specification. If your +camera is not listed, the library may well work — it just has not been verified. You can +check in one command: see [Trying it on your own RAW files](#trying-it-on-your-own-raw-files). ## Contributing diff --git a/src/Parser/Tiff/TiffPreviewParser.php b/src/Parser/Tiff/TiffPreviewParser.php index 8f06a4b..8a7a9ff 100644 --- a/src/Parser/Tiff/TiffPreviewParser.php +++ b/src/Parser/Tiff/TiffPreviewParser.php @@ -29,6 +29,25 @@ final class TiffPreviewParser implements PreviewParserInterface /** Tout JPEG commence par ce marqueur (SOI). */ private const JPEG_MAGIC = "\xFF\xD8"; + /** + * Marqueurs SOF d'un JPEG réellement décodable. + * + * `Compression = 6` ne suffit pas à distinguer une preview des données du + * capteur : Canon stocke celles-ci en **JPEG lossless** dans un CR2, avec le + * même tag. Ces blocs sont les plus gros du fichier et gagneraient donc la + * comparaison par taille — pour rendre 28 Mo qu'aucun décodeur ne lit. + * + * Vérifié sur six appareils : les previews utilisent toutes SOF0 ; seul le + * capteur d'un CR2 utilise SOF3. + * + * @var list + */ + private const DECODABLE_SOF_MARKERS = [ + 0xC0, // SOF0 — baseline, le cas de toutes les previews observées + 0xC1, // SOF1 — extended sequential + 0xC2, // SOF2 — progressif + ]; + /** Profondeur maximale de récursion dans les sous-IFD. */ private const MAX_SUB_IFD_DEPTH = 4; @@ -41,17 +60,23 @@ public function extract(string $path, Format $format): ExtractedPreview $this->collectFromIfd($reader, $offset, $candidates, 0); } - if ([] === $candidates) { - throw new PreviewNotFoundException( - sprintf('Aucune preview JPEG dans %s.', basename($path)), - ); - } - // La plus grande preview est la plus utile : un RAW en porte souvent // plusieurs, de la vignette 160×120 à la pleine résolution. usort($candidates, static fn (array $a, array $b): int => $b['length'] <=> $a['length']); - return $this->buildPreview($reader, $candidates[0], $format, $path); + // Un candidat n'est retenu que s'il est réellement décodable : le plus + // gros bloc d'un CR2 est le capteur en JPEG lossless, pas une preview. + foreach ($candidates as $candidate) { + $preview = $this->tryBuildPreview($reader, $candidate, $format); + + if (null !== $preview) { + return $preview; + } + } + + throw new PreviewNotFoundException( + sprintf('Aucune preview JPEG exploitable dans %s.', basename($path)), + ); } /** @@ -154,7 +179,7 @@ private function subIfdOffsets(array $entries): array * * @throws CorruptedFileException */ - private function buildPreview(TiffReader $reader, array $candidate, Format $format, string $path): ExtractedPreview + private function tryBuildPreview(TiffReader $reader, array $candidate, Format $format): ?ExtractedPreview { $jpeg = $reader->readBytes($candidate['offset'], $candidate['length']); @@ -167,22 +192,30 @@ private function buildPreview(TiffReader $reader, array $candidate, Format $form )); } - [$width, $height] = $this->readJpegDimensions($jpeg, $candidate['offset']); + $sof = $this->findSofSegment($jpeg); - return new ExtractedPreview($jpeg, $width, $height, $format); + // Pas de SOF, ou un SOF que personne ne décode (lossless, arithmétique, + // différentiel) : ce bloc n'est pas une preview affichable. On passe au + // candidat suivant plutôt que de rendre des octets inutilisables. + if (null === $sof || !in_array($sof['marker'], self::DECODABLE_SOF_MARKERS, true)) { + return null; + } + + return new ExtractedPreview($jpeg, $sof['width'], $sof['height'], $format); } /** - * Lit les dimensions dans le segment SOF du JPEG. + * Localise le segment SOF et en extrait le marqueur et les dimensions. * - * Les tags ImageWidth/ImageLength de l'IFD ne conviennent pas : ils décrivent - * souvent l'image RAW pleine résolution, pas la preview. + * Le SOF fait autorité sur les dimensions : les tags ImageWidth/ImageLength + * de l'IFD décrivent souvent l'image RAW pleine résolution, pas la preview. + * Son marqueur dit aussi **comment** l'image est encodée, donc si un + * décodeur courant saura la lire. * - * @return array{int, int} - * - * @throws CorruptedFileException + * @return array{marker: int, width: int, height: int}|null null si le JPEG + * ne porte aucun SOF */ - private function readJpegDimensions(string $jpeg, int $offset): array + private function findSofSegment(string $jpeg): ?array { $length = strlen($jpeg); $position = 2; @@ -199,19 +232,16 @@ private function readJpegDimensions(string $jpeg, int $offset): array // SOF0 à SOF15, hors DHT (C4), DNL (C8) et DAC (CC) qui partagent la plage. if ($marker >= 0xC0 && $marker <= 0xCF && !in_array($marker, [0xC4, 0xC8, 0xCC], true)) { return [ - unpack('n', substr($jpeg, $position + 7, 2))[1], - unpack('n', substr($jpeg, $position + 5, 2))[1], + 'marker' => $marker, + 'width' => unpack('n', substr($jpeg, $position + 7, 2))[1], + 'height' => unpack('n', substr($jpeg, $position + 5, 2))[1], ]; } - $segmentLength = unpack('n', substr($jpeg, $position + 2, 2))[1]; - $position += 2 + $segmentLength; + $position += 2 + unpack('n', substr($jpeg, $position + 2, 2))[1]; } - throw new CorruptedFileException(sprintf( - 'JPEG sans segment SOF à l\'offset %d : dimensions introuvables.', - $offset, - )); + return null; } /** diff --git a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php index 853af9c..1eb01b8 100644 --- a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php +++ b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php @@ -117,12 +117,12 @@ public function testFindsPreviewInSubIfd(): void self::assertSame($jpeg, $this->parser->extract($path, Format::NEF)->jpegData); } - public function testThrowsCorruptedWhenJpegHasNoSofSegment(): void + public function testThrowsPreviewNotFoundWhenJpegHasNoSofSegment(): void { - // Un bloc qui commence par FFD8 mais ne porte aucun segment SOF : le - // magic ne suffit pas, les dimensions doivent être trouvables. - $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('SOF'); + // Un bloc qui commence par FFD8 mais ne porte aucun SOF n'est pas une + // preview exploitable — sans que le fichier soit corrompu pour autant. + // C'est un candidat qu'on écarte, pas une structure qui ment. + $this->expectException(PreviewNotFoundException::class); $fake = "\xFF\xD8" . "\xFF\xE0" . pack('n', 16) . str_repeat("\x00", 14) . "\xFF\xD9"; @@ -143,6 +143,43 @@ public function testStopsDescendingBeyondMaxSubIfdDepth(): void $this->parser->extract($this->tiffWithDeepSubIfds($this->jpeg(16, 12), 8), Format::NEF); } + public function testIgnoresLosslessJpegSensorData(): void + { + // Canon stocke les données du capteur d'un CR2 en JPEG *lossless*, dans + // un IFD qui déclare honnêtement Compression = 6. Ce bloc est le plus + // gros du fichier : sans contrôle du marqueur SOF, la stratégie « la + // plus grande preview » le choisit — et rend 28 Mo indécodables. + $sensor = $this->jpegWithSof("\xC3", 6880, 4544); // SOF3 = lossless + $preview = $this->jpeg(64, 48); // SOF0 = baseline + + $path = $this->tiffWithChain([$preview, $sensor]); + + // La preview baseline gagne, bien qu'elle soit la plus petite. + self::assertSame($preview, $this->parser->extract($path, Format::CR2)->jpegData); + } + + public function testThrowsPreviewNotFoundWhenOnlyLosslessJpegExists(): void + { + // Un fichier dont le seul bloc « JPEG » est du lossless n'a pas de + // preview exploitable — mieux vaut le dire que rendre l'indécodable. + $this->expectException(PreviewNotFoundException::class); + + $sensor = $this->jpegWithSof("\xC3", 100, 100); + + $this->parser->extract($this->tiffWithJpeg($sensor), Format::CR2); + } + + public function testAcceptsProgressiveJpeg(): void + { + // SOF2 (progressif) est parfaitement décodable : ne pas le rejeter avec + // le lossless. + $progressive = $this->jpegWithSof("\xC2", 320, 240); + + $preview = $this->parser->extract($this->tiffWithJpeg($progressive), Format::DNG); + + self::assertSame([320, 240], [$preview->width, $preview->height]); + } + public function testThrowsPreviewNotFoundWhenNoJpegTag(): void { $this->expectException(PreviewNotFoundException::class); @@ -241,6 +278,25 @@ private function jpeg(int $width, int $height): string return $data; } + /** + * Un JPEG minimal portant le marqueur SOF demandé. + * + * GD ne sait produire que du baseline (SOF0) : pour tester le rejet du + * lossless (SOF3), il faut forger la structure à la main. + */ + private function jpegWithSof(string $marker, int $width, int $height): string + { + // SOI, APP0/JFIF, puis le segment SOF : longueur, précision, hauteur, + // largeur, nombre de composantes. + $sof = "\xFF" . $marker . pack('n', 11) . "\x08" + . pack('n', $height) . pack('n', $width) . "\x01\x01\x11\x00"; + + return "\xFF\xD8" + . "\xFF\xE0" . pack('n', 16) . "JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + . $sof + . "\xFF\xD9"; + } + /** * TIFF minimal dont l'IFD0 désigne le JPEG via JPEGInterchangeFormat. */