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}