diff --git a/README.md b/README.md
index d50abac..3bc0b89 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,39 @@ try {
}
```
+### Orientation
+
+A preview is stored exactly as the sensor captured it: a shot taken in portrait comes out
+**lying on its side**. The camera records the rotation rather than applying it.
+
+This library does not rotate the image — that would need GD, the very dependency it exists
+to avoid. It tells you what the file says:
+
+```php
+$preview->orientation; // Orientation::Rotate90
+$preview->orientation->degrees(); // 90
+$preview->orientation->isUpright(); // false
+$preview->orientation->isMirrored(); // false — 4 of the 8 EXIF values are mirrors
+$preview->orientation->swapsDimensions(); // true — width and height swap on a quarter turn
+```
+
+In a browser, fixing it costs nothing:
+
+```php
+printf('
',
+ $preview->orientation->degrees());
+```
+
+Server-side, if you do have GD — note it rotates counter-clockwise:
+
+```php
+$image = imagecreatefromstring($preview->jpegData);
+
+if (!$preview->orientation->isUpright()) {
+ $image = imagerotate($image, -$preview->orientation->degrees(), 0);
+}
+```
+
Check support before extracting:
```php
diff --git a/docs/index.md b/docs/index.md
index 6b21241..088b337 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -90,6 +90,7 @@ final readonly class ExtractedPreview
public int $width,
public int $height,
public Format $sourceFormat,
+ public Orientation $orientation = Orientation::Normal,
) {}
}
```
@@ -101,6 +102,31 @@ claims 4000×3000 over a 48×36 preview will report 48×36.
`$jpegData` is validated: its `FFD8` magic is checked, and its SOF marker is confirmed
decodable before it is returned.
+### `Orientation`
+
+The camera records how it was held; it does not rotate the preview. A portrait shot
+therefore comes out lying on its side — verified on an iPhone 12 Pro, which writes
+`Orientation = 6`.
+
+Rotating it here would need GD. Instead the enum carries what a caller actually needs:
+
+| Method | Answers |
+|--------|---------|
+| `degrees()` | 0, 90, 180 or 270 — what `imagerotate()` and CSS `rotate()` take |
+| `isUpright()` | the common case, in one check |
+| `isMirrored()` | four of the eight EXIF values are mirrors, not rotations |
+| `swapsDimensions()` | a quarter turn swaps width and height |
+
+Where it is read from:
+
+- **TIFF formats** — tag `0x0112` of the IFD0. It applies to the whole shot, previews
+ included: the camera records its position once, not per image.
+- **CR3** — no TIFF of its own, so it comes from the `CMT1` box, which *is* a complete
+ TIFF. `TiffReader::fromRange()` reads it in place, without a temporary file.
+
+An absent tag, or a value outside the 1-8 range, yields `Normal` rather than failing an
+otherwise successful extraction.
+
### `Format`
```php
diff --git a/src/ExtractedPreview.php b/src/ExtractedPreview.php
index 9d3ab6b..6245a75 100644
--- a/src/ExtractedPreview.php
+++ b/src/ExtractedPreview.php
@@ -16,21 +16,30 @@
*
* Dimensions come from the **JPEG itself**, not from container tags: those often
* describe the full-resolution RAW image rather than the preview.
+ *
+ * A preview is stored exactly as the sensor captured it, so one shot in portrait
+ * comes out lying on its side. {@see $orientation} says how to put it upright —
+ * rotating it here would require GD, which this package exists to avoid.
*/
final readonly class ExtractedPreview
{
/**
- * @param string $jpegData raw JPEG bytes, ready to write to disk; the
- * `FFD8` magic is validated during extraction
- * @param int $width preview width in pixels
- * @param int $height preview height in pixels
- * @param Format $sourceFormat format of the RAW file it came from
+ * @param string $jpegData raw JPEG bytes, ready to write to disk;
+ * the `FFD8` magic is validated during
+ * extraction
+ * @param int $width preview width in pixels, as encoded
+ * @param int $height preview height in pixels, as encoded
+ * @param Format $sourceFormat format of the RAW file it came from
+ * @param Orientation $orientation how the camera was held; defaults to
+ * {@see Orientation::Normal} when the file
+ * says nothing
*/
public function __construct(
public string $jpegData,
public int $width,
public int $height,
public Format $sourceFormat,
+ public Orientation $orientation = Orientation::Normal,
) {
}
}
diff --git a/src/Orientation.php b/src/Orientation.php
new file mode 100644
index 0000000..45cc0d4
--- /dev/null
+++ b/src/Orientation.php
@@ -0,0 +1,124 @@
+extract('/photos/IMG_0042.DNG');
+ *
+ * if (!$preview->orientation->isUpright()) {
+ * // in a browser, free and dependency-less
+ * echo '
';
+ * }
+ * ```
+ *
+ * The eight EXIF values mix rotations and mirrors. Treating a mirror as a
+ * rotation produces a flipped image, so {@see isMirrored()} is worth checking.
+ */
+enum Orientation: int
+{
+ /** Upright — nothing to do. The common case. */
+ case Normal = 1;
+
+ /** Mirrored along the vertical axis. */
+ case FlipHorizontal = 2;
+
+ /** Upside down. */
+ case Rotate180 = 3;
+
+ /** Mirrored along the horizontal axis. */
+ case FlipVertical = 4;
+
+ /** Mirrored, then rotated 90° clockwise. */
+ case Transpose = 5;
+
+ /** Rotated 90° clockwise — a phone held upright. */
+ case Rotate90 = 6;
+
+ /** Mirrored, then rotated 270° clockwise. */
+ case Transverse = 7;
+
+ /** Rotated 270° clockwise. */
+ case Rotate270 = 8;
+
+ /**
+ * Builds from a raw EXIF value, tolerating anything out of spec.
+ *
+ * A tag holding 0, 9 or garbage must not fail an otherwise successful
+ * extraction: an unknown orientation is assumed upright.
+ *
+ * @param int|null $exif the raw tag value, or null when the tag is absent
+ */
+ public static function fromExif(?int $exif): self
+ {
+ return null === $exif ? self::Normal : (self::tryFrom($exif) ?? self::Normal);
+ }
+
+ /**
+ * Clockwise rotation to apply, in degrees: 0, 90, 180 or 270.
+ *
+ * This is what `imagerotate()` and CSS `transform: rotate()` take — note
+ * that `imagerotate()` turns counter-clockwise, so negate it.
+ *
+ * Mirrored values report the rotation part only; see {@see isMirrored()}.
+ */
+ public function degrees(): int
+ {
+ return match ($this) {
+ self::Normal, self::FlipHorizontal => 0,
+ self::Rotate90, self::Transpose => 90,
+ self::Rotate180, self::FlipVertical => 180,
+ self::Rotate270, self::Transverse => 270,
+ };
+ }
+
+ /**
+ * Does the image also need mirroring?
+ *
+ * Four of the eight EXIF values are mirrors. They are rare — most cameras
+ * only ever write 1, 3, 6 or 8 — but a caller that ignores them will show
+ * some images reversed.
+ */
+ public function isMirrored(): bool
+ {
+ return match ($this) {
+ self::FlipHorizontal, self::FlipVertical,
+ self::Transpose, self::Transverse => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Is the image already the right way up?
+ *
+ * A single check to skip the whole question, which is the common case.
+ */
+ public function isUpright(): bool
+ {
+ return self::Normal === $this;
+ }
+
+ /**
+ * Do width and height swap once the rotation is applied?
+ *
+ * True for the quarter turns. A caller sizing a container from
+ * `$preview->width` before rotating would otherwise get it backwards.
+ */
+ public function swapsDimensions(): bool
+ {
+ return 90 === $this->degrees() || 270 === $this->degrees();
+ }
+}
diff --git a/src/Parser/Cr3/Cr3PreviewParser.php b/src/Parser/Cr3/Cr3PreviewParser.php
index 2d22358..d0ee2bc 100644
--- a/src/Parser/Cr3/Cr3PreviewParser.php
+++ b/src/Parser/Cr3/Cr3PreviewParser.php
@@ -8,7 +8,10 @@
use RonanLenouvel\RawPreviewExtractor\Exception\PreviewNotFoundException;
use RonanLenouvel\RawPreviewExtractor\ExtractedPreview;
use RonanLenouvel\RawPreviewExtractor\Format\Format;
+use RonanLenouvel\RawPreviewExtractor\Orientation;
use RonanLenouvel\RawPreviewExtractor\Parser\PreviewParserInterface;
+use RonanLenouvel\RawPreviewExtractor\Parser\Tiff\TiffReader;
+use RonanLenouvel\RawPreviewExtractor\Parser\Tiff\TiffTag;
/**
* Extracts the JPEG preview of a CR3 (Canon RAW v3, ISO-BMFF container).
@@ -58,6 +61,13 @@ final class Cr3PreviewParser implements PreviewParserInterface
/** Length of the UUID that follows the type of a `uuid` box. */
private const UUID_LENGTH = 16;
+ /**
+ * Box holding the EXIF of the IFD0 — the orientation among them.
+ *
+ * A CR3 has no TIFF of its own, but `CMT1` **is** one, header included.
+ */
+ private const EXIF_BOX = 'CMT1';
+
public function extract(string $path, Format $format): ExtractedPreview
{
$reader = new IsoBmffBoxReader($path);
@@ -74,7 +84,13 @@ public function extract(string $path, Format $format): ExtractedPreview
if (null !== $jpeg) {
[$width, $height] = $this->readJpegDimensions($jpeg);
- return new ExtractedPreview($jpeg, $width, $height, $format);
+ return new ExtractedPreview(
+ $jpeg,
+ $width,
+ $height,
+ $format,
+ $this->readOrientation($reader, $path),
+ );
}
}
@@ -84,6 +100,50 @@ public function extract(string $path, Format $format): ExtractedPreview
));
}
+ /**
+ * Reads the orientation from the `CMT1` box.
+ *
+ * A CR3 carries no TIFF of its own, but `CMT1` is one — header and all. The
+ * reader works on that window of the host file: no temporary file, no copy
+ * in memory.
+ *
+ * The box is absent on some models, and nothing guarantees its content.
+ * Neither is a reason to fail an extraction that otherwise succeeded, so an
+ * unreadable orientation is assumed upright.
+ *
+ * @throws CorruptedFileException if the box structure is invalid
+ */
+ private function readOrientation(IsoBmffBoxReader $reader, string $path): Orientation
+ {
+ $canon = $reader->findUuid((string) hex2bin(self::PREVIEW_LOCATIONS[1]['uuid']));
+
+ if (null === $canon) {
+ return Orientation::Normal;
+ }
+
+ $box = $this->findWithin($reader, $canon, self::EXIF_BOX);
+
+ if (null === $box) {
+ return Orientation::Normal;
+ }
+
+ try {
+ $tiff = TiffReader::fromRange($path, $box->payloadOffset, $box->payloadLength);
+
+ foreach ($tiff->readIfd($tiff->readIfdOffsets()[0] ?? 8) as $entry) {
+ if (TiffTag::Orientation->value === $entry->tag) {
+ return Orientation::fromExif($entry->value());
+ }
+ }
+ } catch (CorruptedFileException) {
+ // CMT1 exists but holds no readable TIFF. That is metadata trouble,
+ // not preview trouble: the JPEG we found is still fine.
+ return Orientation::Normal;
+ }
+
+ return Orientation::Normal;
+ }
+
/**
* Extracts the JPEG from a preview box, if it is there.
*
diff --git a/src/Parser/Tiff/TiffPreviewParser.php b/src/Parser/Tiff/TiffPreviewParser.php
index ccffedd..41169be 100644
--- a/src/Parser/Tiff/TiffPreviewParser.php
+++ b/src/Parser/Tiff/TiffPreviewParser.php
@@ -8,6 +8,7 @@
use RonanLenouvel\RawPreviewExtractor\Exception\PreviewNotFoundException;
use RonanLenouvel\RawPreviewExtractor\ExtractedPreview;
use RonanLenouvel\RawPreviewExtractor\Format\Format;
+use RonanLenouvel\RawPreviewExtractor\Orientation;
use RonanLenouvel\RawPreviewExtractor\Parser\PreviewParserInterface;
/**
@@ -55,11 +56,16 @@ public function extract(string $path, Format $format): ExtractedPreview
{
$reader = new TiffReader($path);
$candidates = [];
+ $offsets = $reader->readIfdOffsets();
- foreach ($reader->readIfdOffsets() as $offset) {
+ foreach ($offsets as $offset) {
$this->collectFromIfd($reader, $offset, $candidates, 0);
}
+ // The orientation of the IFD0 applies to the whole shot, previews
+ // included: the camera records how it was held once, not per image.
+ $orientation = $this->readOrientation($reader, $offsets[0] ?? null);
+
// The largest preview is the most useful: a RAW often carries several,
// from the 160x120 thumbnail to full resolution.
usort($candidates, static fn (array $a, array $b): int => $b['length'] <=> $a['length']);
@@ -67,7 +73,7 @@ public function extract(string $path, Format $format): ExtractedPreview
// A candidate is only kept if it is genuinely decodable: the biggest
// block of a CR2 is the sensor in lossless JPEG, not a preview.
foreach ($candidates as $candidate) {
- $preview = $this->tryBuildPreview($reader, $candidate, $format);
+ $preview = $this->tryBuildPreview($reader, $candidate, $format, $orientation);
if (null !== $preview) {
return $preview;
@@ -79,6 +85,28 @@ public function extract(string $path, Format $format): ExtractedPreview
);
}
+ /**
+ * Reads the EXIF orientation of the IFD0.
+ *
+ * The tag is often absent, and out-of-spec values do occur. Neither is a
+ * reason to fail an otherwise successful extraction: an unknown orientation
+ * is assumed upright.
+ *
+ * @throws CorruptedFileException if the IFD cannot be read
+ */
+ private function readOrientation(TiffReader $reader, ?int $ifd0Offset): Orientation
+ {
+ if (null === $ifd0Offset) {
+ return Orientation::Normal;
+ }
+
+ $entries = $this->indexByTag($reader->readIfd($ifd0Offset));
+
+ return Orientation::fromExif(
+ ($entries[TiffTag::Orientation->value] ?? null)?->value(),
+ );
+ }
+
/**
* Collects the candidates of an IFD, then descends into its SubIFDs.
*
@@ -179,7 +207,12 @@ private function subIfdOffsets(array $entries): array
*
* @throws CorruptedFileException
*/
- private function tryBuildPreview(TiffReader $reader, array $candidate, Format $format): ?ExtractedPreview
+ private function tryBuildPreview(
+ TiffReader $reader,
+ array $candidate,
+ Format $format,
+ Orientation $orientation,
+ ): ?ExtractedPreview
{
$jpeg = $reader->readBytes($candidate['offset'], $candidate['length']);
@@ -200,7 +233,7 @@ private function tryBuildPreview(TiffReader $reader, array $candidate, Format $f
return null;
}
- return new ExtractedPreview($jpeg, $sof['width'], $sof['height'], $format);
+ return new ExtractedPreview($jpeg, $sof['width'], $sof['height'], $format, $orientation);
}
/**
diff --git a/src/Parser/Tiff/TiffReader.php b/src/Parser/Tiff/TiffReader.php
index 486c783..94f634b 100644
--- a/src/Parser/Tiff/TiffReader.php
+++ b/src/Parser/Tiff/TiffReader.php
@@ -62,7 +62,20 @@ final class TiffReader
private $handle;
private readonly bool $bigEndian;
+
+ /**
+ * Where the TIFF starts inside the host file.
+ *
+ * Zero for a plain TIFF. Non-zero when the TIFF is embedded in a larger
+ * container — a CR3 carries complete TIFFs in its `CMT1`/`CMT2` boxes. Every
+ * offset a TIFF declares counts from *its own* start, so this base is added
+ * on every read and never leaks outside this class.
+ */
+ private readonly int $base;
+
+ /** Size of the readable window: the whole file, or the embedded TIFF. */
private readonly int $fileSize;
+
private readonly int $firstIfdOffset;
/**
@@ -70,18 +83,31 @@ final class TiffReader
*
* @throws CorruptedFileException if the file is unreadable or is not a TIFF
*/
- public function __construct(private readonly string $path)
+ public function __construct(string $path, int $base = 0, ?int $length = null)
{
if (!is_file($path)) {
throw new CorruptedFileException(sprintf('Unreadable file: %s', $path));
}
+ $realSize = (int) filesize($path);
+
+ if ($base < 0 || $base + ($length ?? 0) > $realSize) {
+ throw new CorruptedFileException(sprintf(
+ 'TIFF range out of bounds: %d bytes at offset %d (file size: %d).',
+ $length ?? 0,
+ $base,
+ $realSize,
+ ));
+ }
+
// is_file() has already ruled out the missing file and the directory:
// fopen can now only fail on a permissions problem, which @ would
// absorb silently — so we let it bubble up.
$this->handle = fopen($path, 'rb');
- $this->fileSize = (int) filesize($path);
+ $this->base = $base;
+ $this->fileSize = $length ?? $realSize;
+ fseek($this->handle, $this->base);
$header = (string) fread($this->handle, self::HEADER_LENGTH);
if (self::HEADER_LENGTH !== strlen($header)) {
@@ -107,6 +133,26 @@ public function __construct(private readonly string $path)
$this->firstIfdOffset = $this->unpackLong(substr($header, 4, 4));
}
+ /**
+ * Reads a TIFF embedded inside a larger file.
+ *
+ * A CR3 stores complete TIFFs in its `CMT1`/`CMT2` boxes: this reads them in
+ * place, with no temporary file and no copy in memory. Reads are bounded to
+ * the window — an offset pointing outside it is out of bounds, even though
+ * it lands inside the host file.
+ *
+ * @param string $path path of the host file
+ * @param int $offset where the embedded TIFF starts
+ * @param int $length its length in bytes
+ *
+ * @throws CorruptedFileException if the range falls outside the file, or
+ * does not hold a valid TIFF
+ */
+ public static function fromRange(string $path, int $offset, int $length): self
+ {
+ return new self($path, $offset, $length);
+ }
+
public function __destruct()
{
fclose($this->handle);
@@ -245,7 +291,9 @@ public function readBytes(int $offset, int $length): string
));
}
- fseek($this->handle, $offset);
+ // Offsets are relative to the TIFF's own start; the base translates them
+ // to the host file. It is zero for a plain TIFF, so this costs nothing.
+ fseek($this->handle, $this->base + $offset);
// The range is already bounded against fileSize: fread returns exactly
// $length bytes.
diff --git a/src/Parser/Tiff/TiffTag.php b/src/Parser/Tiff/TiffTag.php
index 18505fb..d74d9ab 100644
--- a/src/Parser/Tiff/TiffTag.php
+++ b/src/Parser/Tiff/TiffTag.php
@@ -22,6 +22,14 @@ enum TiffTag: int
/** Compression scheme: 6 or 7 = JPEG, 1 = uncompressed. */
case Compression = 0x0103;
+ /**
+ * How the camera was held — 1 to 8.
+ *
+ * A preview is stored as the sensor captured it: when the tag is not 1, the
+ * JPEG comes out rotated or mirrored. See {@see \RonanLenouvel\RawPreviewExtractor\Orientation}.
+ */
+ case Orientation = 0x0112;
+
/** Camera manufacturer. */
case Make = 0x010F;
diff --git a/tests/Unit/OrientationTest.php b/tests/Unit/OrientationTest.php
new file mode 100644
index 0000000..433e4d0
--- /dev/null
+++ b/tests/Unit/OrientationTest.php
@@ -0,0 +1,115 @@
+
+ */
+ public static function provideExifValues(): iterable
+ {
+ yield '1 normal' => [1, Orientation::Normal];
+ yield '2 miroir horizontal' => [2, Orientation::FlipHorizontal];
+ yield '3 rotation 180' => [3, Orientation::Rotate180];
+ yield '4 miroir vertical' => [4, Orientation::FlipVertical];
+ yield '5 transpose' => [5, Orientation::Transpose];
+ yield '6 rotation 90' => [6, Orientation::Rotate90];
+ yield '7 transverse' => [7, Orientation::Transverse];
+ yield '8 rotation 270' => [8, Orientation::Rotate270];
+ }
+
+ #[DataProvider('provideDegrees')]
+ public function testReportsRotationInDegrees(Orientation $o, int $degrees): void
+ {
+ // Le degré est ce dont l'appelant a besoin : imagerotate() ou une
+ // transform CSS le prennent directement.
+ self::assertSame($degrees, $o->degrees());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideDegrees(): iterable
+ {
+ yield 'normal' => [Orientation::Normal, 0];
+ yield 'miroir horizontal' => [Orientation::FlipHorizontal, 0];
+ yield '180' => [Orientation::Rotate180, 180];
+ yield 'miroir vertical' => [Orientation::FlipVertical, 180];
+ yield 'transpose' => [Orientation::Transpose, 90];
+ yield '90' => [Orientation::Rotate90, 90];
+ yield 'transverse' => [Orientation::Transverse, 270];
+ yield '270' => [Orientation::Rotate270, 270];
+ }
+
+ #[DataProvider('provideMirrored')]
+ public function testReportsMirroring(Orientation $o, bool $mirrored): void
+ {
+ // Quatre des huit valeurs EXIF sont des miroirs, pas des rotations :
+ // les traiter comme des rotations produirait une image inversée.
+ self::assertSame($mirrored, $o->isMirrored());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideMirrored(): iterable
+ {
+ yield 'normal' => [Orientation::Normal, false];
+ yield 'miroir horizontal' => [Orientation::FlipHorizontal, true];
+ yield '180' => [Orientation::Rotate180, false];
+ yield 'miroir vertical' => [Orientation::FlipVertical, true];
+ yield 'transpose' => [Orientation::Transpose, true];
+ yield '90' => [Orientation::Rotate90, false];
+ yield 'transverse' => [Orientation::Transverse, true];
+ yield '270' => [Orientation::Rotate270, false];
+ }
+
+ public function testDetectsWhenNothingNeedsToBeDone(): void
+ {
+ // Le cas courant : la plupart des photos sont droites. L'appelant doit
+ // pouvoir l'écarter d'un seul test.
+ self::assertTrue(Orientation::Normal->isUpright());
+
+ self::assertFalse(Orientation::Rotate90->isUpright());
+ self::assertFalse(Orientation::FlipHorizontal->isUpright());
+ }
+
+ public function testSwapsDimensionsOnQuarterTurns(): void
+ {
+ // Une rotation de 90° ou 270° échange largeur et hauteur : sans cela,
+ // un appelant qui dimensionne son conteneur avant de pivoter se trompe.
+ self::assertTrue(Orientation::Rotate90->swapsDimensions());
+ self::assertTrue(Orientation::Rotate270->swapsDimensions());
+ self::assertTrue(Orientation::Transpose->swapsDimensions());
+ self::assertTrue(Orientation::Transverse->swapsDimensions());
+
+ self::assertFalse(Orientation::Normal->swapsDimensions());
+ self::assertFalse(Orientation::Rotate180->swapsDimensions());
+ self::assertFalse(Orientation::FlipHorizontal->swapsDimensions());
+ }
+
+ public function testFallsBackToNormalForUnknownValue(): void
+ {
+ // Un tag hors spec (0, 9, 42…) ne doit pas faire échouer une extraction
+ // par ailleurs réussie : on suppose l'image droite.
+ self::assertSame(Orientation::Normal, Orientation::fromExif(0));
+ self::assertSame(Orientation::Normal, Orientation::fromExif(9));
+ self::assertSame(Orientation::Normal, Orientation::fromExif(null));
+ }
+}
diff --git a/tests/Unit/Parser/Cr3/Cr3PreviewParserTest.php b/tests/Unit/Parser/Cr3/Cr3PreviewParserTest.php
index 52bb9ea..4640873 100644
--- a/tests/Unit/Parser/Cr3/Cr3PreviewParserTest.php
+++ b/tests/Unit/Parser/Cr3/Cr3PreviewParserTest.php
@@ -9,6 +9,7 @@
use RonanLenouvel\RawPreviewExtractor\Exception\CorruptedFileException;
use RonanLenouvel\RawPreviewExtractor\Exception\PreviewNotFoundException;
use RonanLenouvel\RawPreviewExtractor\Format\Format;
+use RonanLenouvel\RawPreviewExtractor\Orientation;
use RonanLenouvel\RawPreviewExtractor\Parser\Cr3\Cr3PreviewParser;
#[CoversClass(Cr3PreviewParser::class)]
@@ -54,6 +55,30 @@ public function testExtractsPreviewFromPrvwBox(): void
self::assertSame(Format::CR3, $preview->sourceFormat);
}
+ public function testReportsOrientationFromCmt1(): void
+ {
+ // Le CR3 n'a pas d'IFD TIFF direct : son orientation vit dans la boîte
+ // CMT1, qui EST un TIFF complet. TiffReader::fromRange() la lit en
+ // place, sans fichier temporaire ni copie mémoire.
+ $jpeg = $this->jpeg(64, 48);
+ $path = $this->cr3(['PRVW' => $jpeg], orientation: 6);
+
+ $preview = $this->parser->extract($path, Format::CR3);
+
+ self::assertSame(Orientation::Rotate90, $preview->orientation);
+ self::assertSame(90, $preview->orientation->degrees());
+ }
+
+ public function testDefaultsToNormalWhenCmt1IsAbsent(): void
+ {
+ // Tous les CR3 n'ont pas de CMT1 exploitable : son absence n'est pas une
+ // erreur, on suppose l'image droite.
+ $preview = $this->parser->extract($this->cr3(['PRVW' => $this->jpeg(32, 24)]), Format::CR3);
+
+ self::assertSame(Orientation::Normal, $preview->orientation);
+ self::assertTrue($preview->orientation->isUpright());
+ }
+
public function testExtractedJpegHasValidDimensions(): void
{
$preview = $this->parser->extract($this->cr3(['PRVW' => $this->jpeg(200, 150)]), Format::CR3);
@@ -189,7 +214,7 @@ private function jpeg(int $width, int $height): string
*
* @param array $boxes type de boîte => contenu brut
*/
- private function cr3(array $boxes): string
+ private function cr3(array $boxes, ?int $orientation = null): string
{
$canonInner = '';
$previewInner = '';
@@ -202,6 +227,12 @@ private function cr3(array $boxes): string
}
}
+ if (null !== $orientation) {
+ // CMT1 est un TIFF complet, en-tête compris : c'est ce que contient
+ // un vrai CR3.
+ $canonInner = $this->box('CMT1', $this->tiffWithOrientation($orientation)) . $canonInner;
+ }
+
$file = $this->box('ftyp', 'crx isom')
. $this->box('moov', $this->box('uuid', (string) hex2bin(self::CANON_UUID) . $canonInner));
@@ -212,6 +243,17 @@ private function cr3(array $boxes): string
return $this->file($file . $this->box('mdat', str_repeat("\x00", 32)));
}
+ /**
+ * Un TIFF minimal ne portant que le tag Orientation — la forme de CMT1.
+ */
+ private function tiffWithOrientation(int $orientation): string
+ {
+ return 'II' . pack('v', 42) . pack('V', 8)
+ . pack('v', 1)
+ . pack('v', 0x0112) . pack('v', 3) . pack('V', 1) . pack('v', $orientation) . "\x00\x00"
+ . pack('V', 0);
+ }
+
private function box(string $type, string $payload): string
{
return pack('N', 8 + strlen($payload)) . $type . $payload;
diff --git a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php
index cf5294c..24f2b12 100644
--- a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php
+++ b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php
@@ -10,6 +10,7 @@
use RonanLenouvel\RawPreviewExtractor\Exception\CorruptedFileException;
use RonanLenouvel\RawPreviewExtractor\Exception\PreviewNotFoundException;
use RonanLenouvel\RawPreviewExtractor\Format\Format;
+use RonanLenouvel\RawPreviewExtractor\Orientation;
use RonanLenouvel\RawPreviewExtractor\Parser\Tiff\TiffPreviewParser;
use RonanLenouvel\RawPreviewExtractor\Parser\Tiff\TiffTag;
@@ -47,6 +48,53 @@ public function testExtractsPreviewViaJpegInterchangeFormat(): void
self::assertSame(Format::CR2, $preview->sourceFormat);
}
+ public function testReportsOrientationFromIfd0(): void
+ {
+ // Cas de l'iPhone 12 Pro : Orientation = 6 dans l'IFD0, la preview sort
+ // couchée à 90°. Sans cette information, l'appelant ne peut pas la
+ // redresser — et nous ne pouvons pas le faire pour lui sans GD.
+ $jpeg = $this->jpeg(64, 48);
+
+ $entries = [
+ [TiffTag::Orientation->value, 3, 1, pack('v', 6) . "\x00\x00"],
+ [TiffTag::JpegInterchangeFormat->value, 4, 1, 'OFFSET'],
+ [TiffTag::JpegInterchangeFormatLength->value, 4, 1, pack('V', strlen($jpeg))],
+ ];
+
+ $preview = $this->parser->extract($this->tiffWith($entries, $jpeg), Format::DNG);
+
+ self::assertSame(Orientation::Rotate90, $preview->orientation);
+ self::assertSame(90, $preview->orientation->degrees());
+ self::assertFalse($preview->orientation->isUpright());
+ }
+
+ public function testDefaultsToNormalWhenTagIsAbsent(): void
+ {
+ // La plupart des RAW n'ont pas ce tag, ou le mettent à 1. Une absence
+ // n'est pas une erreur : on suppose l'image droite.
+ $preview = $this->parser->extract($this->tiffWithJpeg($this->jpeg(32, 24)), Format::CR2);
+
+ self::assertSame(Orientation::Normal, $preview->orientation);
+ self::assertTrue($preview->orientation->isUpright());
+ }
+
+ public function testToleratesOrientationOutOfSpec(): void
+ {
+ // Un tag à 42 ne doit pas faire échouer une extraction par ailleurs
+ // réussie.
+ $jpeg = $this->jpeg(16, 16);
+
+ $entries = [
+ [TiffTag::Orientation->value, 3, 1, pack('v', 42) . "\x00\x00"],
+ [TiffTag::JpegInterchangeFormat->value, 4, 1, 'OFFSET'],
+ [TiffTag::JpegInterchangeFormatLength->value, 4, 1, pack('V', strlen($jpeg))],
+ ];
+
+ $preview = $this->parser->extract($this->tiffWith($entries, $jpeg), Format::NEF);
+
+ self::assertSame(Orientation::Normal, $preview->orientation);
+ }
+
public function testExtractedJpegHasValidDimensions(): void
{
// Validation croisée : si l'extraction est correcte, GD sait relire le
diff --git a/tests/Unit/Parser/Tiff/TiffReaderTest.php b/tests/Unit/Parser/Tiff/TiffReaderTest.php
index 1b6b2b7..4f5ee6f 100644
--- a/tests/Unit/Parser/Tiff/TiffReaderTest.php
+++ b/tests/Unit/Parser/Tiff/TiffReaderTest.php
@@ -27,6 +27,56 @@ protected function tearDown(): void
$this->tempFiles = [];
}
+ public function testReadsATiffEmbeddedInALargerFile(): void
+ {
+ // Le CR3 range un TIFF complet dans sa boîte CMT1, au milieu du fichier.
+ // Le reader doit pouvoir travailler sur une fenêtre : ni copie mémoire,
+ // ni fichier temporaire.
+ $tiff = $this->tiff('II', [[TiffTag::Orientation->value, 3, 1, pack('v', 6) . "\x00\x00"]]);
+ $bytes = str_repeat("\xAA", 100) . $tiff . str_repeat("\xBB", 100);
+
+ $reader = TiffReader::fromRange($this->file($bytes), 100, strlen($tiff));
+
+ self::assertSame(6, $reader->readIfd(8)[0]->value());
+ }
+
+ public function testRangeOffsetsAreRelativeToTheEmbeddedTiff(): void
+ {
+ // Les offsets d'un TIFF embarqué comptent depuis SON début, pas depuis
+ // celui du fichier hôte. Confondre les deux lit n'importe où.
+ $value = "NIKON\x00";
+ $entry = pack('v', TiffTag::Make->value) . pack('v', 2)
+ . pack('V', strlen($value)) . pack('V', 26); // ← offset interne
+
+ $tiff = 'II' . pack('v', 42) . pack('V', 8)
+ . pack('v', 1) . $entry . pack('V', 0) . $value;
+
+ $reader = TiffReader::fromRange($this->file(str_repeat("\x00", 512) . $tiff), 512, strlen($tiff));
+
+ self::assertSame('NIKON', $reader->readIfd(8)[0]->ascii);
+ }
+
+ public function testRangeRefusesToReadBeyondItsWindow(): void
+ {
+ // Une plage borne la lecture : un offset qui sort de la fenêtre est
+ // hors bornes, même s'il pointe dans le fichier hôte.
+ $this->expectException(CorruptedFileException::class);
+ $this->expectExceptionMessage('out of bounds');
+
+ $tiff = $this->tiff('II', []);
+ $bytes = $tiff . str_repeat("\xFF", 1000); // 1000 octets APRÈS la plage
+
+ TiffReader::fromRange($this->file($bytes), 0, strlen($tiff))
+ ->readBytes(strlen($tiff) + 10, 4);
+ }
+
+ public function testThrowsWhenRangeIsOutsideTheFile(): void
+ {
+ $this->expectException(CorruptedFileException::class);
+
+ TiffReader::fromRange($this->file($this->tiff('II', [])), 9999, 100);
+ }
+
public function testReadsLittleEndianHeader(): void
{
$reader = $this->reader($this->tiff('II', [[TiffTag::ImageWidth->value, 3, 1, "\x40\x06\x00\x00"]]));
@@ -394,11 +444,22 @@ private function entry(int $tag, int $type, int $count, string $value, string $b
}
private function reader(string $bytes): TiffReader
+ {
+ return new TiffReader($this->file($bytes));
+ }
+
+ /**
+ * Écrit les octets dans un fichier temporaire et rend son chemin.
+ *
+ * Utile aux tests de fromRange(), qui ont besoin du chemin plutôt que d'un
+ * reader déjà construit.
+ */
+ private function file(string $bytes): string
{
$path = tempnam(sys_get_temp_dir(), 'tiff');
file_put_contents($path, $bytes);
$this->tempFiles[] = $path;
- return new TiffReader($path);
+ return $path;
}
}