From 9313607158650bbecf94aa1d9e99048496214172 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 16:50:54 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A7=20fix(TiffPreviewParser):=20re?= =?UTF-8?q?jeter=20le=20JPEG=20lossless=20des=20donn=C3=A9es=20du=20capteu?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur un Canon 5D Mark IV, extract() rendait 28,8 Mo au lieu de la preview de 2 Mo — et ces octets n'étaient pas affichables. Le pire cas : pas une exception, une réponse fausse qui passait tous les contrôles. Canon stocke les données du capteur d'un CR2 en JPEG LOSSLESS, dans des IFD qui déclarent honnêtement Compression = 6. Le filtre les acceptait donc, et comme ces blocs sont les plus gros du fichier, la stratégie « garder la plus grande preview » les choisissait : IFD0 6720x4480 Compression=6 2 096 284 o ← la vraie preview IFD3 6880x4544 Compression=6 30 199 466 o ← capteur, JPEG lossless IFD4 6880x4544 Compression=6 28 841 907 o ← capteur, JPEG lossless Compression = 6 ne distingue donc pas une preview des données brutes. Le marqueur SOF, si : vérifié sur six appareils, toutes les previews utilisent SOF0 baseline ; seul le capteur d'un CR2 utilise SOF3 lossless. Les candidats sont désormais validés avant d'être retenus : le plus gros bloc gagne seulement s'il est réellement décodable (SOF0, SOF1 ou SOF2). Un piège dans le diagnostic : getimagesizefromstring() acceptait ce JPEG lossless — elle ne lit que l'en-tête et annonçait 3440x2272. Seul imagecreatefromstring() révélait l'échec. La validation croisée par GD n'était pas assez stricte. Un bloc sans SOF exploitable donne désormais PreviewNotFoundException et non CorruptedFileException : le fichier n'est pas corrompu, c'est le candidat qui n'est pas une preview. Vérifié : les 6 previews des fichiers réels s'ouvrent par imagecreatefromstring(). CR2 5D Mark IV → 6720x4480, 2047 Ko. Closes #23 --- src/Parser/Tiff/TiffPreviewParser.php | 80 +++++++++++++------ .../Parser/Tiff/TiffPreviewParserTest.php | 66 +++++++++++++-- 2 files changed, 116 insertions(+), 30 deletions(-) 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. */ From 336a33bfc9bc4e01b19ec8fea18721d76ff4f2d3 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 16:52:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=96=20docs(readme):=20les=20cinq?= =?UTF-8?q?=20formats=20sont=20valid=C3=A9s=20sur=20fichiers=20r=C3=A9els?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six appareils vérifiés (raw.pixls.us, CC0) : Canon 5D Mark IV, EOS R, EOS RP, Nikon D750, Sony α7 et iPhone 12 Pro en ProRAW. Les cinq formats du périmètre sont couverts. Chaque preview est ouverte par imagecreatefromstring(), pas seulement décodée en en-tête : c'est cette distinction qui a révélé le bug du JPEG lossless. Le statut passe de « waiting on validation » à « validated ». 1.0.0 reste non taggé : l'API est stable mais un release est un engagement semver, et d'autres appareils méritent d'être testés d'abord. --- README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) 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