diff --git a/README.md b/README.md index 3bc0b89..a7c7bcb 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,44 @@ Extraction is O(1) in file size: a 30 MB RAW takes the same ~2 ms as a 7 MB one, the file is never loaded — only its container structure is read, then the JPEG block is seeked to directly. +### Metadata + +Alongside the preview, the extractor reads the **shooting settings** from the RAW's EXIF — +what a photographer wants next to the image: when it was taken, and how. + +```php +$preview = $extractor->extract('/path/to/photo.cr2'); + +$meta = $preview->metadata; // RawMetadata|null + +$meta?->dateTimeOriginal; // "2024:06:15 12:30:45" (raw EXIF form) +$meta?->fNumber; // 2.8 (aperture, float) +$meta?->exposureTime; // "1/250" (shutter, fraction kept intact) +$meta?->iso; // 400 +$meta?->focalLength; // 50.0 (millimetres) +$meta?->lensModel; // "RF50mm F1.8 STM" +$meta?->cameraMake; // "Canon" +$meta?->cameraModel; // "Canon EOS R5" +``` + +Every field is nullable, and so is `$preview->metadata` itself: a tag may be absent, an old +body says less than a recent one, and a partial read is never a failure. `RawMetadata::isEmpty()` +tells a caller nothing usable was found, in one call: + +```php +if ($meta !== null && !$meta->isEmpty()) { + // display the shooting settings +} +``` + +Values are exposed exactly as the file encodes them — no rounding, no locale: the shutter +speed keeps its fraction (`"1/250"` reads better than `0.004`), the aperture stays a plain +number. Metadata is read from the same container walk as the preview, so it costs nothing +extra. + +> Coverage follows the TIFF-based formats (CR2, NEF, ARW, DNG), which carry EXIF in a +> standard IFD. For CR3 (ISO-BMFF) the preview is returned but `metadata` is currently `null`. + ### Exceptions All of them implement `RawPreviewExtractorException`: diff --git a/docs/index.md b/docs/index.md index 088b337..6622f99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -91,6 +91,7 @@ final readonly class ExtractedPreview public int $height, public Format $sourceFormat, public Orientation $orientation = Orientation::Normal, + public ?RawMetadata $metadata = null, // shooting settings, or null ) {} } ``` @@ -102,6 +103,35 @@ 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. +`$metadata` carries the shooting settings read from EXIF (see below); it is `null` when the +format carries none this package can reach. + +### `RawMetadata` + +```php +final readonly class RawMetadata +{ + public function __construct( + public ?string $dateTimeOriginal = null, // "YYYY:MM:DD HH:MM:SS" + public ?float $fNumber = null, // aperture, e.g. 2.8 + public ?string $exposureTime = null, // shutter, e.g. "1/250" + public ?int $iso = null, + public ?float $focalLength = null, // millimetres + public ?string $lensModel = null, + public ?string $cameraMake = null, + public ?string $cameraModel = null, + ) {} + + public function isEmpty(): bool; // true when every field is null +} +``` + +Every field is nullable: a tag may be absent, and a partial read is not a failure. Values +are exposed as the file encodes them — the shutter speed keeps its fraction, the aperture +stays a plain number. Read from the same container walk as the preview, so it costs nothing +extra. TIFF-based formats (CR2, NEF, ARW, DNG) are covered; CR3 returns the preview with +`metadata` currently `null`. + ### `Orientation` The camera records how it was held; it does not rotate the preview. A portrait shot diff --git a/src/ExtractedPreview.php b/src/ExtractedPreview.php index 6245a75..dbe71b9 100644 --- a/src/ExtractedPreview.php +++ b/src/ExtractedPreview.php @@ -30,9 +30,13 @@ * @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 + * @param Orientation $orientation how the camera was held; defaults to + * {@see Orientation::Normal} when the file + * says nothing + * @param RawMetadata|null $metadata shooting settings read from EXIF (date, + * aperture, shutter, ISO, focal length, + * lens, camera); null when the format + * carries none this package can reach */ public function __construct( public string $jpegData, @@ -40,6 +44,7 @@ public function __construct( public int $height, public Format $sourceFormat, public Orientation $orientation = Orientation::Normal, + public ?RawMetadata $metadata = null, ) { } } diff --git a/src/Parser/Tiff/IfdEntry.php b/src/Parser/Tiff/IfdEntry.php index 739d043..c2dcb1f 100644 --- a/src/Parser/Tiff/IfdEntry.php +++ b/src/Parser/Tiff/IfdEntry.php @@ -44,4 +44,41 @@ public function value(): ?int { return $this->values[0] ?? null; } + + /** + * First RATIONAL value as a float, or null. + * + * A RATIONAL is a numerator/denominator pair; {@see TiffReader} stores both + * halves side by side in {@see $values}. Returns null when the entry is not + * a resolved rational or the denominator is zero (a lie, not a value). + */ + public function rational(): ?float + { + [$numerator, $denominator] = [$this->values[0] ?? null, $this->values[1] ?? null]; + + if (null === $numerator || null === $denominator || 0 === $denominator) { + return null; + } + + return $numerator / $denominator; + } + + /** + * First RATIONAL value as its raw numerator/denominator pair, or null. + * + * Lets a caller keep the fraction intact — a 1/250 s shutter speed reads + * better as "1/250" than as "0.004". + * + * @return array{int, int}|null + */ + public function rationalPair(): ?array + { + [$numerator, $denominator] = [$this->values[0] ?? null, $this->values[1] ?? null]; + + if (null === $numerator || null === $denominator) { + return null; + } + + return [$numerator, $denominator]; + } } diff --git a/src/Parser/Tiff/TiffPreviewParser.php b/src/Parser/Tiff/TiffPreviewParser.php index 41169be..a6a1695 100644 --- a/src/Parser/Tiff/TiffPreviewParser.php +++ b/src/Parser/Tiff/TiffPreviewParser.php @@ -10,6 +10,7 @@ use RonanLenouvel\RawPreviewExtractor\Format\Format; use RonanLenouvel\RawPreviewExtractor\Orientation; use RonanLenouvel\RawPreviewExtractor\Parser\PreviewParserInterface; +use RonanLenouvel\RawPreviewExtractor\RawMetadata; /** * Extracts the JPEG preview of RAW files built on TIFF: CR2, NEF, ARW and DNG. @@ -66,6 +67,10 @@ public function extract(string $path, Format $format): ExtractedPreview // included: the camera records how it was held once, not per image. $orientation = $this->readOrientation($reader, $offsets[0] ?? null); + // The shooting settings likewise belong to the shot, not to a given + // preview: read once from the IFD0 and its EXIF sub-IFD. + $metadata = $this->readMetadata($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']); @@ -73,7 +78,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, $orientation); + $preview = $this->tryBuildPreview($reader, $candidate, $format, $orientation, $metadata); if (null !== $preview) { return $preview; @@ -212,6 +217,7 @@ private function tryBuildPreview( array $candidate, Format $format, Orientation $orientation, + ?RawMetadata $metadata, ): ?ExtractedPreview { $jpeg = $reader->readBytes($candidate['offset'], $candidate['length']); @@ -233,7 +239,73 @@ private function tryBuildPreview( return null; } - return new ExtractedPreview($jpeg, $sof['width'], $sof['height'], $format, $orientation); + return new ExtractedPreview($jpeg, $sof['width'], $sof['height'], $format, $orientation, $metadata); + } + + /** + * Reads the shooting settings from the IFD0 (camera) and its EXIF sub-IFD + * (aperture, shutter, ISO, focal length, lens, date). + * + * A missing tag, or an absent EXIF IFD, is never a reason to fail: each + * field falls back to null. Returns null only when there is no IFD0 at all. + * + * @throws CorruptedFileException if an announced IFD cannot be read + */ + private function readMetadata(TiffReader $reader, ?int $ifd0Offset): ?RawMetadata + { + if (null === $ifd0Offset) { + return null; + } + + $ifd0 = $this->indexByTag($reader->readIfd($ifd0Offset)); + + $exif = []; + $exifPointer = ($ifd0[TiffTag::ExifIfdPointer->value] ?? null)?->value(); + if (null !== $exifPointer) { + $exif = $this->indexByTag($reader->readIfd($exifPointer)); + } + + $asciiOrNull = static fn (?IfdEntry $e): ?string => (null !== $e && '' !== $e->ascii) ? $e->ascii : null; + + return new RawMetadata( + dateTimeOriginal: $asciiOrNull($exif[TiffTag::DateTimeOriginal->value] ?? null), + fNumber: ($exif[TiffTag::FNumber->value] ?? null)?->rational(), + exposureTime: $this->formatExposure($exif[TiffTag::ExposureTime->value] ?? null), + iso: ($exif[TiffTag::IsoSpeedRatings->value] ?? null)?->value(), + focalLength: ($exif[TiffTag::FocalLength->value] ?? null)?->rational(), + lensModel: $asciiOrNull($exif[TiffTag::LensModel->value] ?? null), + cameraMake: $asciiOrNull($ifd0[TiffTag::Make->value] ?? null), + cameraModel: $asciiOrNull($ifd0[TiffTag::Model->value] ?? null), + ); + } + + /** + * Formats a shutter-speed RATIONAL, keeping the fraction photographers read. + * + * Below one second the fraction is normalised to "1/N" (1/250); at one second + * or more the value is shown as trimmed decimal seconds ("2", "1.3"). + */ + private function formatExposure(?IfdEntry $entry): ?string + { + $pair = $entry?->rationalPair(); + + if (null === $pair) { + return null; + } + + [$numerator, $denominator] = $pair; + + if (0 === $numerator || 0 === $denominator) { + return null; + } + + $seconds = $numerator / $denominator; + + if ($seconds < 1.0) { + return '1/'.(int) round($denominator / $numerator); + } + + return rtrim(rtrim(number_format($seconds, 1, '.', ''), '0'), '.'); } /** diff --git a/src/Parser/Tiff/TiffReader.php b/src/Parser/Tiff/TiffReader.php index 94f634b..4475337 100644 --- a/src/Parser/Tiff/TiffReader.php +++ b/src/Parser/Tiff/TiffReader.php @@ -42,6 +42,10 @@ final class TiffReader */ private const MAX_RESOLVED_VALUE_LENGTH = 65536; + /** TIFF type codes for the two rational (numerator/denominator) types. */ + private const TYPE_RATIONAL = 5; + private const TYPE_SRATIONAL = 10; + /** Size in bytes of each TIFF 6.0 type, indexed by type code. */ private const TYPE_SIZES = [ 1 => 1, // BYTE @@ -268,7 +272,7 @@ private function readEntry(string $bytes): IfdEntry return new IfdEntry($tag, $type, $count, [], rtrim($raw, "\x00")); } - return new IfdEntry($tag, $type, $count, $this->unpackIntegers($raw, $size, $count)); + return new IfdEntry($tag, $type, $count, $this->unpackIntegers($raw, $type, $size, $count)); } /** @@ -307,20 +311,31 @@ public function readBytes(int $offset, int $length): string * length: no need to check it again here. TYPE_SIZES only contains sizes of * 1, 2, 4 or 8 bytes. * + * A RATIONAL (type 5) or SRATIONAL (type 10) is a numerator/denominator + * pair: both halves are kept, side by side, so a caller can rebuild the + * fraction (see {@see IfdEntry::rational()}). A plain 8-byte value (DOUBLE) + * keeps its historical behaviour — its first 4 bytes — as no tag needs more. + * * @return list */ - private function unpackIntegers(string $bytes, int $size, int $count): array + private function unpackIntegers(string $bytes, int $type, int $size, int $count): array { + $isRational = self::TYPE_RATIONAL === $type || self::TYPE_SRATIONAL === $type; $values = []; for ($i = 0; $i < $count; ++$i) { $chunk = substr($bytes, $i * $size, $size); + if ($isRational) { + $values[] = $this->unpackLong(substr($chunk, 0, 4)); // numerator + $values[] = $this->unpackLong(substr($chunk, 4, 4)); // denominator + + continue; + } + $values[] = match ($size) { 1 => ord($chunk), 2 => $this->unpackShort($chunk), - // RATIONAL and DOUBLE (8 bytes): only the first 4 are of - // interest to us — no tag used here needs the rest. default => $this->unpackLong(substr($chunk, 0, 4)), }; } diff --git a/src/Parser/Tiff/TiffTag.php b/src/Parser/Tiff/TiffTag.php index d74d9ab..3169c44 100644 --- a/src/Parser/Tiff/TiffTag.php +++ b/src/Parser/Tiff/TiffTag.php @@ -56,4 +56,24 @@ enum TiffTag: int /** Present only in DNG files. */ case DngVersion = 0xC612; + + // --- EXIF IFD tags (reachable through ExifIfdPointer): shooting settings --- + + /** Shutter speed, RATIONAL seconds (e.g. 1/250). */ + case ExposureTime = 0x829A; + + /** Aperture, RATIONAL f-number (e.g. 28/10 → f/2.8). */ + case FNumber = 0x829D; + + /** ISO sensitivity, SHORT. */ + case IsoSpeedRatings = 0x8827; + + /** Capture date/time, ASCII "YYYY:MM:DD HH:MM:SS". */ + case DateTimeOriginal = 0x9003; + + /** Focal length, RATIONAL millimetres. */ + case FocalLength = 0x920A; + + /** Lens model, ASCII. */ + case LensModel = 0xA434; } diff --git a/src/RawMetadata.php b/src/RawMetadata.php new file mode 100644 index 0000000..8f4b1f8 --- /dev/null +++ b/src/RawMetadata.php @@ -0,0 +1,64 @@ +extract('/photos/IMG_0042.CR2'); + * $meta = $preview->metadata; + * echo $meta?->fNumber; // 2.8 + * echo $meta?->exposureTime; // "1/250" + * ``` + * + * Every field is nullable: a tag may be absent, and a partial read is not a + * failure — an old body simply says less than a recent one. Values are exposed + * as the file encodes them (no rounding, no locale): the shutter speed keeps its + * fraction, the aperture stays a plain number. + */ +final readonly class RawMetadata +{ + /** + * @param string|null $dateTimeOriginal capture date, raw EXIF form + * "YYYY:MM:DD HH:MM:SS", or null + * @param float|null $fNumber aperture as an f-number (2.8), or null + * @param string|null $exposureTime shutter speed kept as a fraction + * ("1/250") or decimal seconds ("0.5"), or null + * @param int|null $iso ISO sensitivity, or null + * @param float|null $focalLength focal length in millimetres, or null + * @param string|null $lensModel lens model, or null + * @param string|null $cameraMake camera manufacturer, or null + * @param string|null $cameraModel camera model, or null + */ + public function __construct( + public ?string $dateTimeOriginal = null, + public ?float $fNumber = null, + public ?string $exposureTime = null, + public ?int $iso = null, + public ?float $focalLength = null, + public ?string $lensModel = null, + public ?string $cameraMake = null, + public ?string $cameraModel = null, + ) { + } + + /** + * True when every field is null — nothing usable was found. + * + * Lets a caller skip an empty metadata object rather than test each field. + */ + public function isEmpty(): bool + { + return null === $this->dateTimeOriginal + && null === $this->fNumber + && null === $this->exposureTime + && null === $this->iso + && null === $this->focalLength + && null === $this->lensModel + && null === $this->cameraMake + && null === $this->cameraModel; + } +} diff --git a/tests/Unit/Parser/Tiff/IfdEntryTest.php b/tests/Unit/Parser/Tiff/IfdEntryTest.php new file mode 100644 index 0000000..538dde5 --- /dev/null +++ b/tests/Unit/Parser/Tiff/IfdEntryTest.php @@ -0,0 +1,43 @@ +value()); + self::assertNull((new IfdEntry(0x0112, 3, 1, []))->value()); + } + + public function testRationalDividesNumeratorByDenominator(): void + { + // 28/10 = f/2.8 + self::assertEqualsWithDelta(2.8, (new IfdEntry(0x829D, 5, 1, [28, 10]))->rational(), 0.0001); + } + + public function testRationalIsNullWhenDenominatorIsZero(): void + { + // Un dénominateur nul est un mensonge, pas une valeur : jamais de division. + self::assertNull((new IfdEntry(0x829D, 5, 1, [28, 0]))->rational()); + } + + public function testRationalIsNullWhenPairIncomplete(): void + { + self::assertNull((new IfdEntry(0x829D, 5, 1, [28]))->rational()); + self::assertNull((new IfdEntry(0x829D, 5, 1, []))->rational()); + } + + public function testRationalPairKeepsBothHalves(): void + { + self::assertSame([1, 250], (new IfdEntry(0x829A, 5, 1, [1, 250]))->rationalPair()); + self::assertNull((new IfdEntry(0x829A, 5, 1, [1]))->rationalPair()); + } +} diff --git a/tests/Unit/Parser/Tiff/TiffPreviewParserMetadataTest.php b/tests/Unit/Parser/Tiff/TiffPreviewParserMetadataTest.php new file mode 100644 index 0000000..65a0ee3 --- /dev/null +++ b/tests/Unit/Parser/Tiff/TiffPreviewParserMetadataTest.php @@ -0,0 +1,204 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + $this->parser = new TiffPreviewParser(); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $path) { + @unlink($path); + } + $this->tempFiles = []; + } + + public function testReadsFullShootingMetadata(): void + { + $meta = $this->parser->extract($this->tiffWithMetadata(), Format::CR2)->metadata; + + self::assertInstanceOf(RawMetadata::class, $meta); + self::assertSame('Canon', $meta->cameraMake); + self::assertSame('Canon EOS R5', $meta->cameraModel); + self::assertSame('2024:06:15 12:30:45', $meta->dateTimeOriginal); + self::assertEqualsWithDelta(2.8, $meta->fNumber, 0.0001); + self::assertSame('1/250', $meta->exposureTime); + self::assertSame(400, $meta->iso); + self::assertEqualsWithDelta(50.0, $meta->focalLength, 0.0001); + self::assertSame('RF50mm F1.8 STM', $meta->lensModel); + self::assertFalse($meta->isEmpty()); + } + + public function testMetadataIsEmptyWhenTagsAreAbsent(): void + { + // IFD0 présent (donc metadata non-null) mais sans aucun tag EXIF. + $jpeg = $this->jpeg(32, 24); + $preview = $this->parser->extract($this->tiffJpegOnly($jpeg), Format::NEF); + + self::assertInstanceOf(RawMetadata::class, $preview->metadata); + self::assertTrue($preview->metadata->isEmpty()); + } + + public function testExposureAtOrAboveOneSecondIsDecimal(): void + { + // 2 s → "2" (pas "1/0" ni "2/1"). + $meta = $this->parser->extract($this->tiffWithExposure(2, 1), Format::ARW)->metadata; + + self::assertSame('2', $meta?->exposureTime); + } + + // --- builders --- + + private function tiffWithMetadata(): string + { + $jpeg = $this->jpeg(64, 48); + + $ifd0Count = 5; + $exifCount = 6; + $ifd0Size = 2 + $ifd0Count * 12 + 4; + $exifSize = 2 + $exifCount * 12 + 4; + $exifOffset = 8 + $ifd0Size; + $heapBase = $exifOffset + $exifSize; + + $heap = ''; + $put = function (string $bytes) use (&$heap, $heapBase): int { + $offset = $heapBase + strlen($heap); + $heap .= $bytes; + + return $offset; + }; + + $makeOff = $put("Canon\x00"); + $modelOff = $put("Canon EOS R5\x00"); + $fNumberOff = $put(pack('V', 28).pack('V', 10)); // 2.8 + $exposureOff = $put(pack('V', 1).pack('V', 250)); // 1/250 + $dateOff = $put("2024:06:15 12:30:45\x00"); + $focalOff = $put(pack('V', 50).pack('V', 1)); // 50 mm + $lensOff = $put("RF50mm F1.8 STM\x00"); + + $jpegOffset = $heapBase + strlen($heap); + + $ifd0 = $this->ifd([ + [TiffTag::Make->value, 2, 6, pack('V', $makeOff)], + [TiffTag::Model->value, 2, 13, pack('V', $modelOff)], + [TiffTag::ExifIfdPointer->value, 4, 1, pack('V', $exifOffset)], + [TiffTag::JpegInterchangeFormat->value, 4, 1, pack('V', $jpegOffset)], + [TiffTag::JpegInterchangeFormatLength->value, 4, 1, pack('V', strlen($jpeg))], + ]); + + $exif = $this->ifd([ + [TiffTag::FNumber->value, 5, 1, pack('V', $fNumberOff)], + [TiffTag::ExposureTime->value, 5, 1, pack('V', $exposureOff)], + [TiffTag::IsoSpeedRatings->value, 3, 1, pack('v', 400)."\x00\x00"], + [TiffTag::DateTimeOriginal->value, 2, 20, pack('V', $dateOff)], + [TiffTag::FocalLength->value, 5, 1, pack('V', $focalOff)], + [TiffTag::LensModel->value, 2, 16, pack('V', $lensOff)], + ]); + + return $this->file('II'.pack('v', 42).pack('V', 8).$ifd0.$exif.$heap.$jpeg); + } + + private function tiffWithExposure(int $numerator, int $denominator): string + { + $jpeg = $this->jpeg(16, 16); + + $ifd0Size = 2 + 3 * 12 + 4; + $exifSize = 2 + 1 * 12 + 4; + $exifOffset = 8 + $ifd0Size; + $heapBase = $exifOffset + $exifSize; + + $exposure = pack('V', $numerator).pack('V', $denominator); + $jpegOffset = $heapBase + strlen($exposure); + + $ifd0 = $this->ifd([ + [TiffTag::ExifIfdPointer->value, 4, 1, pack('V', $exifOffset)], + [TiffTag::JpegInterchangeFormat->value, 4, 1, pack('V', $jpegOffset)], + [TiffTag::JpegInterchangeFormatLength->value, 4, 1, pack('V', strlen($jpeg))], + ]); + + $exif = $this->ifd([ + [TiffTag::ExposureTime->value, 5, 1, pack('V', $heapBase)], + ]); + + return $this->file('II'.pack('v', 42).pack('V', 8).$ifd0.$exif.$exposure.$jpeg); + } + + private function tiffJpegOnly(string $jpeg): string + { + $ifd0Size = 2 + 2 * 12 + 4; + $jpegOffset = 8 + $ifd0Size; + + $ifd0 = $this->ifd([ + [TiffTag::JpegInterchangeFormat->value, 4, 1, pack('V', $jpegOffset)], + [TiffTag::JpegInterchangeFormatLength->value, 4, 1, pack('V', strlen($jpeg))], + ]); + + return $this->file('II'.pack('v', 42).pack('V', 8).$ifd0.$jpeg); + } + + /** + * Assemble un IFD (little-endian) à partir d'entrées déjà encodées. + * + * @param list $entries [tag, type, count, valeur 4 octets] + */ + private function ifd(array $entries): string + { + $body = pack('v', count($entries)); + + foreach ($entries as [$tag, $type, $count, $value]) { + $body .= pack('v', $tag).pack('v', $type).pack('V', $count).$value; + } + + return $body.pack('V', 0); + } + + private function jpeg(int $width, int $height): string + { + $image = imagecreatetruecolor($width, $height); + imagefilledrectangle($image, 0, 0, $width, $height, imagecolorallocate($image, 100, 100, 100)); + + ob_start(); + imagejpeg($image, null, 90); + $data = (string) ob_get_clean(); + imagedestroy($image); + + return $data; + } + + private function file(string $bytes): string + { + $path = tempnam(sys_get_temp_dir(), 'tppm'); + file_put_contents($path, $bytes); + $this->tempFiles[] = $path; + + return $path; + } +} diff --git a/tests/Unit/RawMetadataTest.php b/tests/Unit/RawMetadataTest.php new file mode 100644 index 0000000..a2ea5e2 --- /dev/null +++ b/tests/Unit/RawMetadataTest.php @@ -0,0 +1,42 @@ +isEmpty()); + } + + public function testIsNotEmptyWhenAnyFieldIsSet(): void + { + self::assertFalse((new RawMetadata(iso: 400))->isEmpty()); + self::assertFalse((new RawMetadata(cameraModel: 'Canon EOS R5'))->isEmpty()); + } + + public function testExposesValuesAsConstructed(): void + { + $meta = new RawMetadata( + dateTimeOriginal: '2024:06:15 12:30:45', + fNumber: 2.8, + exposureTime: '1/250', + iso: 400, + focalLength: 50.0, + lensModel: 'RF50mm F1.8 STM', + cameraMake: 'Canon', + cameraModel: 'Canon EOS R5', + ); + + self::assertSame('1/250', $meta->exposureTime); + self::assertSame(400, $meta->iso); + self::assertSame('Canon', $meta->cameraMake); + } +}