Skip to main content

hyprforge_image/
orientation.rs

1//! Which way up a picture is, according to its EXIF.
2//!
3//! # Why this crate has to own it
4//!
5//! iced already applies EXIF orientation — but only on two of its three
6//! handle kinds. In `iced_graphics-0.14.0/src/image.rs`, `Handle::Path`
7//! and `Handle::Bytes` are decoded with `image::open` / `load_from_memory`
8//! and then rotated according to `exif::Tag::Orientation`; `Handle::Rgba`
9//! is a straight passthrough of whatever pixels it is handed.
10//!
11//! Those same two paths decode at **full resolution with no cap**, which
12//! is exactly the allocation a viewer cannot afford (see [`crate::budget`]).
13//! So the viewer must hand over `Handle::Rgba` — and the moment it does,
14//! it inherits the orientation job iced had been doing for it.
15//!
16//! Half of that is a bug people see immediately: every portrait phone
17//! photograph sideways. The other half is worse, because it looks like
18//! the first: apply orientation here *and* hand iced a path handle
19//! somewhere else, and that picture is rotated twice.
20
21/// How the stored pixels must be transformed to be shown the right way up.
22///
23/// The eight EXIF orientation values, named for what they do rather than
24/// numbered — `Rotate90` is what value 6 means, and nobody remembers that.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum Orientation {
27    /// Value 1: already upright. Also what a file with no EXIF gets.
28    #[default]
29    Upright,
30    /// Value 2.
31    FlipHorizontal,
32    /// Value 3.
33    Rotate180,
34    /// Value 4.
35    FlipVertical,
36    /// Value 5.
37    Transpose,
38    /// Value 6 — the common one: a phone held upright.
39    Rotate90,
40    /// Value 7.
41    Transverse,
42    /// Value 8.
43    Rotate270,
44}
45
46impl Orientation {
47    /// From the raw EXIF value. Anything outside 1..=8 is
48    /// [`Orientation::Upright`] — a corrupt tag must not turn a picture
49    /// sideways, and "no opinion" is the safe reading of a value nobody
50    /// defined.
51    pub fn from_exif(value: u16) -> Orientation {
52        match value {
53            2 => Orientation::FlipHorizontal,
54            3 => Orientation::Rotate180,
55            4 => Orientation::FlipVertical,
56            5 => Orientation::Transpose,
57            6 => Orientation::Rotate90,
58            7 => Orientation::Transverse,
59            8 => Orientation::Rotate270,
60            _ => Orientation::Upright,
61        }
62    }
63
64    /// Whether applying this swaps width and height.
65    ///
66    /// The half of orientation that is easy to forget, because it is not
67    /// about pixels: a fit-to-window computed on the stored dimensions of
68    /// a phone photograph fits the wrong rectangle, and the picture is
69    /// letterboxed on the wrong axis before anyone notices it is also
70    /// sideways.
71    pub fn swaps_axes(self) -> bool {
72        matches!(
73            self,
74            Orientation::Transpose
75                | Orientation::Rotate90
76                | Orientation::Transverse
77                | Orientation::Rotate270
78        )
79    }
80
81    /// The size a picture presents after this orientation is applied.
82    pub fn applied_size(self, width: u32, height: u32) -> (u32, u32) {
83        if self.swaps_axes() {
84            (height, width)
85        } else {
86            (width, height)
87        }
88    }
89
90    /// The equivalent `image` operation, which is what actually moves the
91    /// pixels. Delegated rather than hand-rolled: `image::metadata::Orientation` is
92    /// the same eight cases, and `DynamicImage::apply_orientation` is
93    /// tested by people who do only this.
94    pub fn as_image(self) -> image::metadata::Orientation {
95        match self {
96            Orientation::Upright => image::metadata::Orientation::NoTransforms,
97            Orientation::FlipHorizontal => image::metadata::Orientation::FlipHorizontal,
98            Orientation::Rotate180 => image::metadata::Orientation::Rotate180,
99            Orientation::FlipVertical => image::metadata::Orientation::FlipVertical,
100            Orientation::Transpose => image::metadata::Orientation::Rotate90FlipH,
101            Orientation::Rotate90 => image::metadata::Orientation::Rotate90,
102            Orientation::Transverse => image::metadata::Orientation::Rotate270FlipH,
103            Orientation::Rotate270 => image::metadata::Orientation::Rotate270,
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    /// The common case, and the one anybody notices: a phone held
113    /// upright stores its pixels landscape and tags them 6.
114    #[test]
115    fn a_portrait_phone_photo_is_tagged_as_a_quarter_turn() {
116        let o = Orientation::from_exif(6);
117        assert_eq!(o, Orientation::Rotate90);
118        assert!(o.swaps_axes());
119        assert_eq!(o.applied_size(4032, 3024), (3024, 4032));
120    }
121
122    #[test]
123    fn a_file_with_no_exif_at_all_is_upright() {
124        assert_eq!(Orientation::default(), Orientation::Upright);
125        assert_eq!(Orientation::Upright.applied_size(800, 600), (800, 600));
126    }
127
128    /// A corrupt tag must not turn a picture sideways. Every value
129    /// outside the defined range reads as "no opinion".
130    #[test]
131    fn a_value_nobody_defined_is_upright_rather_than_a_guess() {
132        for value in [0, 9, 42, u16::MAX] {
133            assert_eq!(Orientation::from_exif(value), Orientation::Upright, "{value}");
134        }
135    }
136
137    #[test]
138    fn every_defined_value_round_trips_to_a_distinct_transform() {
139        let all: Vec<Orientation> = (1..=8).map(Orientation::from_exif).collect();
140        for (i, a) in all.iter().enumerate() {
141            for b in &all[i + 1..] {
142                assert_ne!(a, b, "two EXIF values map to the same transform");
143            }
144        }
145    }
146
147    /// Only the four quarter-turn cases change the shape of the frame.
148    #[test]
149    fn only_the_quarter_turns_swap_width_and_height() {
150        assert!(!Orientation::from_exif(1).swaps_axes());
151        assert!(!Orientation::from_exif(2).swaps_axes());
152        assert!(!Orientation::from_exif(3).swaps_axes());
153        assert!(!Orientation::from_exif(4).swaps_axes());
154        assert!(Orientation::from_exif(5).swaps_axes());
155        assert!(Orientation::from_exif(6).swaps_axes());
156        assert!(Orientation::from_exif(7).swaps_axes());
157        assert!(Orientation::from_exif(8).swaps_axes());
158    }
159}