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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,30 @@ Four generations of the same Canon 5D line — 2005 to 2016, 12 to 30 megapixels
covered deliberately: RAW layout drifts between generations, and that drift is where
format parsers break.

### Wider audit

Beyond the table above, the library is audited against the whole
[raw.pixls.us](https://raw.pixls.us/) catalogue — 400+ camera models:

```bash
php bin/audit-cameras.php Canon 25 # 25 Canon models, spread across the range
php bin/audit-cameras.php all 500 # everything
```

It downloads each file, extracts, verifies with `imagecreatefromstring()`, then deletes it.
Latest Canon run — **every EOS body passed**:

```text
EOS-1D X Mark II EOS 5D Mark IV EOS 30D EOS 100D EOS 500D EOS 1000D
EOS R5 EOS R100 EOS RP EOS M6 EOS Kiss M EOS Rebel T5
PowerShot G12 PowerShot G1 X Mark II PowerShot SX1 IS PowerShot V1
```

The remaining failures are compacts whose files genuinely **contain no JPEG preview** —
CHDK-generated DNGs holding an uncompressed 128×96 thumbnail, or legacy `.CRW` files
(pre-CR2, out of scope). `PreviewNotFoundException` is the correct answer there, and the
audit counts it as an expected outcome rather than a defect.

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.
Expand Down
268 changes: 268 additions & 0 deletions bin/audit-cameras.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
<?php

declare(strict_types=1);

/**
* Audite le package contre le catalogue public de raw.pixls.us.
*
* Aucun code n'est spécifique à un modèle : cet outil sert à **découvrir** les
* structures que le parseur ne sait pas encore lire, pas à en énumérer les cas
* particuliers. Un échec est une invitation à généraliser une règle, jamais à
* ajouter un `if`.
*
* Rien n'est conservé : chaque fichier est téléchargé, testé, puis supprimé.
* Seul le rapport reste — les échecs, surtout, qui désignent une structure que
* le parseur ne sait pas encore lire.
*
* Usage :
* php bin/audit-cameras.php # 3 modèles par marque
* php bin/audit-cameras.php Canon 20 # 20 modèles Canon
* php bin/audit-cameras.php all 500 # tout le catalogue
*/

require __DIR__ . '/../vendor/autoload.php';

use RonanLenouvel\RawPreviewExtractor\Exception\RawPreviewExtractorException;
use RonanLenouvel\RawPreviewExtractor\RawPreviewExtractor;

const BASE = 'https://raw.pixls.us/data/';

/** Extensions des formats du périmètre. */
const EXTENSIONS = ['cr2', 'cr3', 'nef', 'arw', 'dng'];

/**
* Un fichier tronqué produit de **faux échecs** : le parseur refuse à juste
* titre un offset qui sort des bornes, et l'audit le compte comme un bug.
*
* On télécharge donc les fichiers entiers. C'est plus lent, mais un auditeur
* qui ment sur ses échecs ne sert à rien — chaque faux positif coûte plus cher
* en enquête que la bande passante économisée.
*/
const PARTIAL_BYTES = 0;

$brandArg = $argv[1] ?? 'default';
$limit = (int) ($argv[2] ?? 3);

$brands = match ($brandArg) {
'default' => ['Canon', 'Nikon', 'Sony', 'Apple', 'FUJIFILM', 'Panasonic'],
'all' => listDirectories(BASE),
default => [$brandArg],
};

$extractor = RawPreviewExtractor::createDefault();
$tmp = sys_get_temp_dir() . '/rpe-audit';
@mkdir($tmp, 0o755, true);

$results = [];
$stats = ['ok' => 0, 'failed' => 0, 'skipped' => 0];

foreach ($brands as $brand) {
$models = listDirectories(BASE . rawurlencode($brand) . '/');

if ([] === $models) {
continue;
}

// Échantillon réparti sur toute la gamme plutôt que les N premiers
// alphabétiquement : on veut couvrir des générations différentes.
$models = spread($models, $limit);

fprintf(STDERR, "\n── %s (%d modèles) ──\n", $brand, count($models));

foreach ($models as $model) {
$file = firstRawFile(BASE . rawurlencode($brand) . '/' . rawurlencode($model) . '/');

if (null === $file) {
++$stats['skipped'];

continue;
}

$url = BASE . rawurlencode($brand) . '/' . rawurlencode($model) . '/' . rawurlencode($file);
$path = $tmp . '/' . preg_replace('/[^\w.-]/', '_', $file);

if (!download($url, $path)) {
++$stats['skipped'];

continue;
}

$results[] = test($extractor, $brand, $model, $path, $stats);
@unlink($path);
}
}

report($results, $stats);

@rmdir($tmp);

exit($stats['failed'] > 0 ? 1 : 0);

/**
* Teste un fichier et rend une ligne de rapport.
*
* @param array{ok: int, failed: int, skipped: int} $stats
*
* @return array{brand: string, model: string, status: string, detail: string}
*/
function test(RawPreviewExtractor $extractor, string $brand, string $model, string $path, array &$stats): array
{
try {
$preview = $extractor->extract($path);

// Le contrôle qui compte : un en-tête valide ne prouve rien, seule
// l'ouverture réelle prouve que la preview est exploitable.
if (false === @imagecreatefromstring($preview->jpegData)) {
++$stats['failed'];
fwrite(STDERR, " ❌ {$model} — JPEG non décodable\n");

return ['brand' => $brand, 'model' => $model, 'status' => 'undecodable',
'detail' => sprintf('%dx%d', $preview->width, $preview->height)];
}

++$stats['ok'];
$detail = sprintf('%dx%d, %d Ko', $preview->width, $preview->height, strlen($preview->jpegData) / 1024);
fwrite(STDERR, " ✅ {$model} — {$detail}\n");

return ['brand' => $brand, 'model' => $model, 'status' => 'ok', 'detail' => $detail];
} catch (RawPreviewExtractorException $e) {
++$stats['failed'];
$type = (new ReflectionClass($e))->getShortName();
fwrite(STDERR, " ❌ {$model} — {$type}: {$e->getMessage()}\n");

return ['brand' => $brand, 'model' => $model, 'status' => $type, 'detail' => $e->getMessage()];
}
}

/**
* @param list<array{brand: string, model: string, status: string, detail: string}> $results
* @param array{ok: int, failed: int, skipped: int} $stats
*/
function report(array $results, array $stats): void
{
$total = $stats['ok'] + $stats['failed'];

echo "\n", str_repeat('═', 72), "\n";
printf("%d testés — %d réussis (%.1f %%), %d échecs, %d ignorés\n",
$total, $stats['ok'], $total > 0 ? $stats['ok'] / $total * 100 : 0,
$stats['failed'], $stats['skipped']);
echo str_repeat('═', 72), "\n";

$failures = array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);

if ([] === $failures) {
echo "\nAucun échec.\n";

return;
}

// Les échecs sont la seule information utile : ils désignent une structure
// que le parseur ne sait pas encore lire.
echo "\nÉCHECS — chacun est une règle à généraliser :\n\n";

foreach ($failures as $f) {
printf(" %-10s %-28s %s\n", $f['brand'], $f['model'], $f['status']);
printf(" %-10s %-28s %s\n", '', '', substr($f['detail'], 0, 60));
}
}

/**
* Sous-répertoires d'un index Apache.
*
* @return list<string>
*/
function listDirectories(string $url): array
{
$html = fetch($url);

if (null === $html) {
return [];
}

preg_match_all('/href="([^"?\/][^"]*)\/"/', $html, $m);

return array_values(array_map(rawurldecode(...), $m[1]));
}

/**
* Premier fichier RAW d'un dossier modèle.
*/
function firstRawFile(string $url): ?string
{
$html = fetch($url);

if (null === $html) {
return null;
}

preg_match_all('/href="([^"?\/][^"]*)"/', $html, $m);

foreach ($m[1] as $name) {
$ext = strtolower(pathinfo(rawurldecode($name), PATHINFO_EXTENSION));

if (in_array($ext, EXTENSIONS, true)) {
return rawurldecode($name);
}
}

return null;
}

function fetch(string $url): ?string
{
$ctx = stream_context_create(['http' => ['timeout' => 20, 'ignore_errors' => true]]);
$body = @file_get_contents($url, false, $ctx);

return false === $body ? null : $body;
}

/**
* Télécharge les premiers octets seulement : une preview n'est jamais loin du
* début, et le reste du fichier est le capteur.
*/
function download(string $url, string $path): bool
{
$headers = PARTIAL_BYTES > 0 ? 'Range: bytes=0-' . (PARTIAL_BYTES - 1) : '';

$ctx = stream_context_create(['http' => [
'timeout' => 180,
'header' => $headers,
'ignore_errors' => true,
]]);

$body = @file_get_contents($url, false, $ctx);

if (false === $body || strlen($body) < 1024) {
return false;
}

file_put_contents($path, $body);

return true;
}

/**
* Répartit un échantillon sur toute la liste plutôt que d'en prendre la tête :
* les N premiers modèles alphabétiques sont souvent la même génération.
*
* @param list<string> $items
*
* @return list<string>
*/
function spread(array $items, int $limit): array
{
$count = count($items);

if ($count <= $limit) {
return $items;
}

$step = $count / $limit;
$picked = [];

for ($i = 0; $i < $limit; ++$i) {
$picked[] = $items[(int) floor($i * $step)];
}

return $picked;
}
11 changes: 5 additions & 6 deletions src/Parser/Tiff/TiffPreviewParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,12 @@ private function tryBuildPreview(TiffReader $reader, array $candidate, Format $f
{
$jpeg = $reader->readBytes($candidate['offset'], $candidate['length']);

// La structure a désigné ce bloc comme un JPEG : s'il n'en est pas un,
// c'est le fichier qui ment, pas la preview qui manque.
// Un tag qui ment sur son contenu est courant dans les RAW : le
// PowerShot G12 annonce Compression = 6 sur des données brutes, dans
// l'IFD dont le bloc est justement le plus gros. Ce n'est pas une
// corruption du fichier — c'est un candidat de plus à écarter.
if (!str_starts_with($jpeg, self::JPEG_MAGIC)) {
throw new CorruptedFileException(sprintf(
'Le bloc désigné à l\'offset %d n\'est pas un JPEG (magic FFD8 absent).',
$candidate['offset'],
));
return null;
}

$sof = $this->findSofSegment($jpeg);
Expand Down
23 changes: 18 additions & 5 deletions tests/Unit/Parser/Tiff/TiffPreviewParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,11 @@ public function testThrowsCorruptedWhenJpegOffsetIsOutOfBounds(): void
$this->parser->extract($this->tiffWith($entries, ''), Format::CR2);
}

public function testThrowsCorruptedWhenBlockIsNotJpeg(): void
public function testThrowsPreviewNotFoundWhenBlockIsNotJpeg(): void
{
// Le tag pointe vers des données qui ne commencent pas par FFD8 :
// la structure ment, le fichier est corrompu.
$this->expectException(CorruptedFileException::class);
$this->expectExceptionMessage('FFD8');
// Un tag qui ment sur son contenu est courant dans les RAW : ce n'est
// pas une corruption du fichier, c'est un candidat inexploitable.
$this->expectException(PreviewNotFoundException::class);

$notJpeg = str_repeat("\x00", 40);

Expand All @@ -222,6 +221,20 @@ public function testThrowsCorruptedWhenBlockIsNotJpeg(): void
$this->parser->extract($this->tiffWith($entries, $notJpeg), Format::CR2);
}

public function testFallsBackToSmallerCandidateWhenLargestIsNotJpeg(): void
{
// Cas réel du PowerShot G12 : l'IFD2 déclare Compression = 6 sur des
// données brutes, et c'est le plus gros bloc. Le candidat suivant —
// plus petit mais valide — doit prendre le relais plutôt que faire
// échouer l'extraction.
$liar = str_repeat("\x3A\x02", 400); // gros, annoncé JPEG, n'en est pas un
$real = $this->jpeg(64, 48); // plus petit, mais vrai

$path = $this->tiffWithChain([$real, $liar]);

self::assertSame($real, $this->parser->extract($path, Format::CR2)->jpegData);
}

public function testThrowsPreviewNotFoundWhenLengthIsZero(): void
{
$this->expectException(PreviewNotFoundException::class);
Expand Down