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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<img src="thumb.jpg" style="transform: rotate(%ddeg)">',
$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
Expand Down
26 changes: 26 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ final readonly class ExtractedPreview
public int $width,
public int $height,
public Format $sourceFormat,
public Orientation $orientation = Orientation::Normal,
) {}
}
```
Expand All @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/ExtractedPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
124 changes: 124 additions & 0 deletions src/Orientation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace RonanLenouvel\RawPreviewExtractor;

/**
* How the camera was held, as recorded in the EXIF `Orientation` tag (0x0112).
*
* A preview is stored exactly as the sensor captured it. When the photographer
* held the camera vertically, the JPEG comes out **lying on its side** — the
* camera records the rotation rather than applying it.
*
* This library does not rotate the image: doing so would require GD or Imagick,
* the very dependencies it exists to avoid. It reports what the file says and
* lets the caller decide:
*
* ```php
* $preview = $extractor->extract('/photos/IMG_0042.DNG');
*
* if (!$preview->orientation->isUpright()) {
* // in a browser, free and dependency-less
* echo '<img src="thumb.jpg" style="transform: rotate('
* . $preview->orientation->degrees() . 'deg)">';
* }
* ```
*
* 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();
}
}
62 changes: 61 additions & 1 deletion src/Parser/Cr3/Cr3PreviewParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand All @@ -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),
);
}
}

Expand All @@ -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.
*
Expand Down
Loading