Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
30 changes: 30 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {}
}
```
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/ExtractedPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@
* @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,
public int $width,
public int $height,
public Format $sourceFormat,
public Orientation $orientation = Orientation::Normal,
public ?RawMetadata $metadata = null,
) {
}
}
37 changes: 37 additions & 0 deletions src/Parser/Tiff/IfdEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
76 changes: 74 additions & 2 deletions src/Parser/Tiff/TiffPreviewParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,14 +67,18 @@ 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']);

// 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;
Expand Down Expand Up @@ -212,6 +217,7 @@ private function tryBuildPreview(
array $candidate,
Format $format,
Orientation $orientation,
?RawMetadata $metadata,
): ?ExtractedPreview
{
$jpeg = $reader->readBytes($candidate['offset'], $candidate['length']);
Expand All @@ -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'), '.');
}

/**
Expand Down
23 changes: 19 additions & 4 deletions src/Parser/Tiff/TiffReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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<int>
*/
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)),
};
}
Expand Down
20 changes: 20 additions & 0 deletions src/Parser/Tiff/TiffTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading