Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
80 changes: 55 additions & 25 deletions src/Parser/Tiff/TiffPreviewParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>
*/
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;

Expand All @@ -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)),
);
}

/**
Expand Down Expand Up @@ -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']);

Expand All @@ -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;
Expand All @@ -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;
}

/**
Expand Down
66 changes: 61 additions & 5 deletions tests/Unit/Parser/Tiff/TiffPreviewParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
Expand Down Expand Up @@ -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.
*/
Expand Down