Skip to content

Commit bef4e96

Browse files
committed
✨ feat(FormatDetector): détecter CR2, CR3, NEF, ARW et DNG par signature
Détection binaire uniquement : l'extension n'est jamais consultée, un CR2 renommé en .jpg reste détecté. CR2 par la signature Canon « CR » après l'en-tête TIFF, CR3 par la boîte ftyp + brand « crx », DNG par le tag DNGVersion, NEF/ARW par le tag Make de l'IFD0. Un non-RAW donne null, jamais une exception : le détecteur détecte, il ne juge pas — c'est la façade qui lèvera UnsupportedFormatException. Sans quoi supports() devrait attraper sa propre exception pour renvoyer false. Sûreté : le fichier est de l'entrée non fiable. Chaque fread est vérifié en longueur avant unpack, les offsets sont bornés, l'endianness est transportée explicitement (v/V ou n/N, jamais S/L qui suivent le CPU), et le nombre d'entrées d'IFD0 est plafonné contre les fichiers hostiles. 14 tests : 5 formats, non-RAW, fichier vide, tronqué, magic faux, MP4 (ftyp sans crx), fichier absent, extension mensongère.
1 parent ae1cf12 commit bef4e96

4 files changed

Lines changed: 456 additions & 0 deletions

File tree

src/Format/Format.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RonanLenouvel\RawPreviewExtractor\Format;
6+
7+
/**
8+
* Formats RAW supportés par le package.
9+
*
10+
* La valeur de chaque cas est l'extension usuelle du format, en minuscules.
11+
* Elle sert d'identifiant stable — notamment comme clé de la map
12+
* Format → PreviewParserInterface — et non de critère de détection : celle-ci
13+
* repose exclusivement sur la signature binaire du fichier.
14+
*/
15+
enum Format: string
16+
{
17+
/** Canon RAW v2 — conteneur TIFF. */
18+
case CR2 = 'cr2';
19+
20+
/** Canon RAW v3 — conteneur ISO-BMFF. */
21+
case CR3 = 'cr3';
22+
23+
/** Nikon Electronic Format — conteneur TIFF. */
24+
case NEF = 'nef';
25+
26+
/** Sony Alpha RAW — conteneur TIFF. */
27+
case ARW = 'arw';
28+
29+
/** Adobe Digital Negative — conteneur TIFF. */
30+
case DNG = 'dng';
31+
}

src/Format/FormatDetector.php

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RonanLenouvel\RawPreviewExtractor\Format;
6+
7+
/**
8+
* Détecte le format RAW par lecture de la signature binaire du fichier.
9+
*
10+
* L'extension du fichier n'est jamais consultée : un CR2 renommé en `.jpg`
11+
* reste détecté comme un CR2, et un `.cr2` qui n'en est pas un est rejeté.
12+
*
13+
* Deux familles de conteneurs sont reconnues :
14+
* - TIFF 6.0 (CR2, NEF, ARW, DNG), discriminé par la signature Canon `CR`
15+
* ou par les tags DNGVersion / Make de l'IFD0 ;
16+
* - ISO-BMFF (CR3), discriminé par la boîte `ftyp` et le brand `crx `.
17+
*/
18+
final class FormatDetector implements FormatDetectorInterface
19+
{
20+
/** Octets suffisants pour couvrir l'en-tête TIFF, la signature CR2 et le ftyp d'un CR3. */
21+
private const HEADER_BYTES = 16;
22+
23+
/** Nombre magique du format TIFF, lu selon l'endianness du fichier. */
24+
private const TIFF_MAGIC = 42;
25+
26+
/** Au-delà, un IFD0 aussi long trahit un fichier corrompu ou hostile. */
27+
private const MAX_IFD0_ENTRIES = 512;
28+
29+
private const TAG_MAKE = 0x010F;
30+
private const TAG_DNG_VERSION = 0xC612;
31+
32+
public function detect(string $path): ?Format
33+
{
34+
$handle = @fopen($path, 'rb');
35+
36+
if (false === $handle) {
37+
return null;
38+
}
39+
40+
try {
41+
$header = fread($handle, self::HEADER_BYTES);
42+
43+
// fread renvoie moins que demandé en fin de fichier : sans ce contrôle,
44+
// unpack() lirait des octets qui n'existent pas.
45+
if (!is_string($header) || strlen($header) < 8) {
46+
return null;
47+
}
48+
49+
return $this->detectIsoBmff($header)
50+
?? $this->detectTiff($handle, $header);
51+
} finally {
52+
fclose($handle);
53+
}
54+
}
55+
56+
/**
57+
* CR3 : boîte `ftyp` en tête, brand majeur `crx ` aux octets 8-11.
58+
*/
59+
private function detectIsoBmff(string $header): ?Format
60+
{
61+
if (strlen($header) < 12 || 'ftyp' !== substr($header, 4, 4)) {
62+
return null;
63+
}
64+
65+
return 'crx ' === substr($header, 8, 4) ? Format::CR3 : null;
66+
}
67+
68+
/**
69+
* @param resource $handle
70+
*/
71+
private function detectTiff($handle, string $header): ?Format
72+
{
73+
$endianness = $this->readEndianness($header);
74+
75+
if (null === $endianness) {
76+
return null;
77+
}
78+
79+
[$shortFormat, $longFormat] = $endianness;
80+
81+
if (self::TIFF_MAGIC !== $this->unpackInt($shortFormat, substr($header, 2, 2))) {
82+
return null;
83+
}
84+
85+
// CR2 : signature « CR » suivie de la version, juste après l'en-tête TIFF.
86+
if (strlen($header) >= 10 && 'CR' === substr($header, 8, 2)) {
87+
return Format::CR2;
88+
}
89+
90+
$ifdOffset = $this->unpackInt($longFormat, substr($header, 4, 4));
91+
92+
if (null === $ifdOffset || $ifdOffset < 8) {
93+
return null;
94+
}
95+
96+
return $this->detectFromIfd0($handle, $shortFormat, $longFormat, $ifdOffset);
97+
}
98+
99+
/**
100+
* @return array{string, string}|null couple de formats unpack() {court, long}
101+
*/
102+
private function readEndianness(string $header): ?array
103+
{
104+
return match (substr($header, 0, 2)) {
105+
'II' => ['v', 'V'], // little-endian
106+
'MM' => ['n', 'N'], // big-endian
107+
default => null, // ni l'un ni l'autre : pas un TIFF
108+
};
109+
}
110+
111+
/**
112+
* Parcourt les entrées de l'IFD0 à la recherche d'un tag discriminant.
113+
*
114+
* @param resource $handle
115+
*/
116+
private function detectFromIfd0($handle, string $shortFormat, string $longFormat, int $ifdOffset): ?Format
117+
{
118+
if (-1 === fseek($handle, $ifdOffset)) {
119+
return null;
120+
}
121+
122+
$countBytes = fread($handle, 2);
123+
124+
if (!is_string($countBytes) || 2 !== strlen($countBytes)) {
125+
return null;
126+
}
127+
128+
$entryCount = $this->unpackInt($shortFormat, $countBytes);
129+
130+
if (null === $entryCount || $entryCount < 1 || $entryCount > self::MAX_IFD0_ENTRIES) {
131+
return null;
132+
}
133+
134+
$makeValue = null;
135+
136+
for ($i = 0; $i < $entryCount; ++$i) {
137+
$entry = fread($handle, 12);
138+
139+
if (!is_string($entry) || 12 !== strlen($entry)) {
140+
return null;
141+
}
142+
143+
$tag = $this->unpackInt($shortFormat, substr($entry, 0, 2));
144+
145+
// DNGVersion suffit : le format est normalisé par Adobe.
146+
if (self::TAG_DNG_VERSION === $tag) {
147+
return Format::DNG;
148+
}
149+
150+
if (self::TAG_MAKE === $tag && null === $makeValue) {
151+
$makeValue = $this->readMake($handle, $longFormat, $entry);
152+
}
153+
}
154+
155+
return $this->formatFromMake($makeValue);
156+
}
157+
158+
/**
159+
* Lit la valeur du tag Make, stockée hors de l'entrée dès qu'elle dépasse
160+
* 4 octets — ce qui est toujours le cas d'un nom de fabricant.
161+
*
162+
* @param resource $handle
163+
*/
164+
private function readMake($handle, string $longFormat, string $entry): ?string
165+
{
166+
$count = $this->unpackInt($longFormat, substr($entry, 4, 4));
167+
$offset = $this->unpackInt($longFormat, substr($entry, 8, 4));
168+
169+
if (null === $count || null === $offset || $count < 1 || $count > 256) {
170+
return null;
171+
}
172+
173+
// La position courante doit être restaurée : la boucle appelante
174+
// continue de lire les entrées séquentiellement.
175+
$position = ftell($handle);
176+
177+
if (false === $position || -1 === fseek($handle, $offset)) {
178+
return null;
179+
}
180+
181+
$value = fread($handle, $count);
182+
fseek($handle, $position);
183+
184+
if (!is_string($value)) {
185+
return null;
186+
}
187+
188+
return rtrim($value, "\x00");
189+
}
190+
191+
private function formatFromMake(?string $make): ?Format
192+
{
193+
if (null === $make) {
194+
return null;
195+
}
196+
197+
$make = strtoupper($make);
198+
199+
return match (true) {
200+
str_contains($make, 'NIKON') => Format::NEF,
201+
str_contains($make, 'SONY') => Format::ARW,
202+
str_contains($make, 'CANON') => Format::CR2,
203+
default => null,
204+
};
205+
}
206+
207+
/**
208+
* unpack() renvoie false sur données trop courtes ; on normalise en null
209+
* plutôt que de laisser un false se propager silencieusement.
210+
*/
211+
private function unpackInt(string $format, string $bytes): ?int
212+
{
213+
$expected = 'v' === $format || 'n' === $format ? 2 : 4;
214+
215+
if (strlen($bytes) !== $expected) {
216+
return null;
217+
}
218+
219+
$result = @unpack($format, $bytes);
220+
221+
return false === $result ? null : $result[1];
222+
}
223+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RonanLenouvel\RawPreviewExtractor\Format;
6+
7+
/**
8+
* Identifie le format RAW d'un fichier à partir de sa signature binaire.
9+
*
10+
* Un détecteur détecte : il ne juge pas. Un fichier illisible, absent ou
11+
* non-RAW donne `null`, jamais une exception — c'est à l'appelant de décider
12+
* si l'absence de format est une erreur.
13+
*/
14+
interface FormatDetectorInterface
15+
{
16+
/**
17+
* @param string $path chemin absolu du fichier à identifier
18+
*
19+
* @return Format|null le format détecté, ou null si le fichier n'est pas
20+
* un RAW supporté, est illisible ou n'existe pas
21+
*/
22+
public function detect(string $path): ?Format;
23+
}

0 commit comments

Comments
 (0)