Show the photograph the way it was taken

Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:37:05 +02:00
co-authored by Claude Opus 5
parent b044a8c067
commit 489465faf0
12 changed files with 875 additions and 48 deletions
+87
View File
@@ -470,6 +470,11 @@ const EXIF_IFD_POINTER: u16 = 0x8769;
mod exif_tag {
pub const MAKE: u16 = 0x010F;
pub const MODEL: u16 = 0x0110;
/// How the stored pixels sit relative to how the image should be seen.
///
/// Lives in the main IFD rather than the Exif sub-IFD, which is why it is
/// found at all: the sub-IFD is where the *capture* tags are.
pub const ORIENTATION: u16 = 0x0112;
/// When the shutter fired. Absent on scanner output.
pub const DATE_TIME_ORIGINAL: u16 = 0x9003;
/// When the file was written. A camera sets both; a **scanner sets only
@@ -511,6 +516,17 @@ fn read_exif_entries(
exif_tag::ISO => md.iso = r.scalar(e),
exif_tag::PIXEL_X => md.width = r.scalar(e),
exif_tag::PIXEL_Y => md.height = r.scalar(e),
// First IFD wins, unlike the fields above, which take the last
// reading. This loop visits every IFD in the file, and a TIFF's
// second one describes the *embedded thumbnail* — which some
// bodies write already upright, tagged `1`. Letting that overwrite
// the main image's tag would lay every portrait frame on its side.
exif_tag::ORIENTATION => {
if let Some(v) = r.scalar(e) {
md.orientation
.get_or_insert_with(|| dr_types::Orientation::from_exif(v as u16));
}
}
exif_tag::DATE_TIME_ORIGINAL => {
if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) {
md.captured_at = Some(t);
@@ -575,6 +591,77 @@ mod tests {
v
}
/// Build a little-endian TIFF with two chained IFDs.
///
/// The second one stands in for a TIFF's thumbnail IFD, which is where the
/// orientation test's whole point lives.
fn tiff_two_ifds(first: &[(u16, u16, u32, u32)], second: &[(u16, u16, u32, u32)]) -> Vec<u8> {
// IFD0 occupies 2 + 12n + 4 bytes from offset 8.
let second_at = 8 + 2 + 12 * first.len() as u32 + 4;
let mut v = tiff(first, second_at);
v.truncate(second_at as usize);
v.extend_from_slice(&(second.len() as u16).to_le_bytes());
for (tag, kind, count, value) in second {
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&kind.to_le_bytes());
v.extend_from_slice(&count.to_le_bytes());
v.extend_from_slice(&value.to_le_bytes());
}
v.extend_from_slice(&0u32.to_le_bytes());
v.resize(v.len().max(1024), 0);
v
}
#[test]
fn the_grid_reads_a_jpegs_orientation_without_decoding_it() {
// The exact call the thumbnail worker makes, on the exact bytes it
// has: a header, no pixels. Going through `metadata` instead would
// build a rawler decoder per grid cell.
let jpeg = jpeg_with_exif(&[(exif_tag::ORIENTATION, 3, 1, 6)], &[]);
assert_eq!(
crate::orientation(&jpeg),
Some(dr_types::Orientation::from_exif(6))
);
// And a file that says nothing declines rather than guessing.
let plain = jpeg_with_exif(&[(exif_tag::ISO, 3, 1, 400)], &[]);
assert_eq!(crate::orientation(&plain), None);
}
#[test]
fn orientation_is_read_from_the_main_ifd() {
// 6 is "rotate 90° clockwise to display" — a phone or a body held on
// its side, which is the case this whole path exists for.
let h = tiff(&[(exif_tag::ORIENTATION, 3, 1, 6)], 0);
let md = tiff_metadata(&h).expect("metadata");
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(6)));
}
#[test]
fn a_file_with_no_orientation_tag_reports_none_rather_than_upright() {
// "Nothing was said" and "the camera was level" are different claims.
// They are displayed alike, but only one of them can later be
// distinguished from a deliberate `1`.
let h = tiff(&[(tag::IMAGE_WIDTH, 4, 1, 1620)], 0);
let md = tiff_metadata(&h).expect("metadata");
assert_eq!(md.orientation, None);
}
#[test]
fn the_thumbnail_ifd_does_not_overwrite_the_main_images_orientation() {
// The regression this guards: some bodies write their embedded
// thumbnail already upright and tag that IFD `1`. Reading every IFD
// last-wins — which is right for make, model and the dates — would
// take the thumbnail's `1` and lay every portrait frame on its side.
let h = tiff_two_ifds(
&[(exif_tag::ORIENTATION, 3, 1, 8)],
&[(exif_tag::ORIENTATION, 3, 1, 1)],
);
let md = tiff_metadata(&h).expect("metadata");
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(8)));
}
#[test]
fn finds_a_jpeg_interchange_preview() {
let h = tiff(