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; } }