Skip to main content

hyprforge_image/
measure.rs

1//! How big a picture is, and which way up — without decoding it.
2//!
3//! Always the first thing done to a file, because every other decision
4//! depends on it: whether it may be decoded at all, what it should be
5//! decoded *to*, and what shape it will present once its orientation is
6//! applied. Decoding to find out how big something is would be the whole
7//! problem this crate exists to avoid.
8//!
9//! # What "without decoding" costs
10//!
11//! Less than a decode, and not nothing. `image`'s `into_dimensions()`
12//! reads a header, but a JPEG decoder in 0.25 buffers the file before it
13//! will answer — so this is O(file bytes) of reading rather than O(1), and
14//! it is O(1) in *allocation*, which is the part that matters here.
15//!
16//! The same header-first check `hyprforge-authui`'s wallpaper path and
17//! `hyprforge-clipmenu`'s thumbnails already do, for the same reason.
18
19use crate::error::ImageError;
20use crate::orientation::Orientation;
21use std::path::{Path, PathBuf};
22
23/// The dimensions as stored in the file, before orientation.
24///
25/// "Source" rather than "size" because after a quarter turn these are not
26/// the dimensions the picture presents — see [`Measured::display_size`],
27/// and the orientation module's note on why that half is easy to miss.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct SourcePixels {
30    pub width: u32,
31    pub height: u32,
32}
33
34impl SourcePixels {
35    /// Total pixels, as `u64` — 36 megapixels times four bytes overflows
36    /// nothing, but a hostile header multiplied in `u32` would.
37    pub fn count(self) -> u64 {
38        u64::from(self.width) * u64::from(self.height)
39    }
40}
41
42/// What a header says about a picture.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Measured {
45    pub source: SourcePixels,
46    pub orientation: Orientation,
47    pub format: image::ImageFormat,
48}
49
50impl Measured {
51    /// The size the picture presents once its orientation is applied —
52    /// what a fit-to-window must be computed against.
53    pub fn display_size(&self) -> (u32, u32) {
54        self.orientation.applied_size(self.source.width, self.source.height)
55    }
56}
57
58/// Reads `path`'s header: how big, what format, which way up.
59pub fn measure(path: &Path) -> Result<Measured, ImageError> {
60    let unreadable = |source| ImageError::Unreadable { path: path.to_path_buf(), source };
61
62    let file = std::fs::File::open(path).map_err(unreadable)?;
63    let reader = image::ImageReader::new(std::io::BufReader::new(file))
64        .with_guessed_format()
65        .map_err(unreadable)?;
66
67    let format = reader.format().ok_or_else(|| ImageError::Undecodable {
68        path: path.to_path_buf(),
69        source: image::ImageError::Unsupported(
70            image::error::ImageFormatHint::Unknown.into(),
71        ),
72    })?;
73
74    let (width, height) = reader.into_dimensions().map_err(|source| ImageError::Undecodable {
75        path: path.to_path_buf(),
76        source,
77    })?;
78
79    Ok(Measured {
80        source: SourcePixels { width, height },
81        orientation: read_orientation(path),
82        format,
83    })
84}
85
86/// The EXIF orientation, or [`Orientation::Upright`] when there is none
87/// to read.
88///
89/// Never an error: a picture with no EXIF, a truncated EXIF block or a
90/// format that has no concept of it are all simply upright, and failing
91/// to *show* a photograph because its metadata was odd would be the wrong
92/// trade every time.
93fn read_orientation(path: &Path) -> Orientation {
94    let Ok(file) = std::fs::File::open(path) else {
95        return Orientation::Upright;
96    };
97    let mut reader = std::io::BufReader::new(file);
98    match image::ImageReader::new(&mut reader).with_guessed_format() {
99        Ok(r) => match r.into_decoder() {
100            Ok(mut decoder) => match image::ImageDecoder::orientation(&mut decoder) {
101                Ok(o) => from_image_orientation(o),
102                Err(_) => Orientation::Upright,
103            },
104            Err(_) => Orientation::Upright,
105        },
106        Err(_) => Orientation::Upright,
107    }
108}
109
110fn from_image_orientation(o: image::metadata::Orientation) -> Orientation {
111    match o {
112        image::metadata::Orientation::NoTransforms => Orientation::Upright,
113        image::metadata::Orientation::Rotate90 => Orientation::Rotate90,
114        image::metadata::Orientation::Rotate180 => Orientation::Rotate180,
115        image::metadata::Orientation::Rotate270 => Orientation::Rotate270,
116        image::metadata::Orientation::FlipHorizontal => Orientation::FlipHorizontal,
117        image::metadata::Orientation::FlipVertical => Orientation::FlipVertical,
118        image::metadata::Orientation::Rotate90FlipH => Orientation::Transpose,
119        image::metadata::Orientation::Rotate270FlipH => Orientation::Transverse,
120    }
121}
122
123/// The path, for callers that keep a `Measured` around without one.
124pub fn measured_path(path: &Path) -> PathBuf {
125    path.to_path_buf()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn write_png(dir: &Path, name: &str, width: u32, height: u32) -> PathBuf {
133        let path = dir.join(name);
134        image::RgbaImage::from_pixel(width, height, image::Rgba([1, 2, 3, 255]))
135            .save(&path)
136            .unwrap();
137        path
138    }
139
140    #[test]
141    fn a_header_gives_the_size_without_decoding_the_picture() {
142        let dir = tempfile::tempdir().unwrap();
143        let path = write_png(dir.path(), "a.png", 640, 480);
144        let measured = measure(&path).unwrap();
145        assert_eq!(measured.source, SourcePixels { width: 640, height: 480 });
146        assert_eq!(measured.format, image::ImageFormat::Png);
147        assert_eq!(measured.display_size(), (640, 480));
148    }
149
150    /// A file that is not a picture is `Undecodable`, never `Unreadable`:
151    /// it read perfectly well, it just is not an image.
152    #[test]
153    fn a_file_that_is_not_a_picture_is_undecodable_not_unreadable() {
154        let dir = tempfile::tempdir().unwrap();
155        let path = dir.path().join("notes.txt");
156        std::fs::write(&path, b"this is not a picture").unwrap();
157        assert!(matches!(measure(&path), Err(ImageError::Undecodable { .. })));
158    }
159
160    #[test]
161    fn a_file_that_is_not_there_is_unreadable() {
162        let dir = tempfile::tempdir().unwrap();
163        let path = dir.path().join("gone.png");
164        assert!(matches!(measure(&path), Err(ImageError::Unreadable { .. })));
165    }
166
167    /// A picture with no EXIF is upright rather than an error — the
168    /// common case for a screenshot or anything ever edited.
169    #[test]
170    fn a_picture_with_no_exif_measures_as_upright() {
171        let dir = tempfile::tempdir().unwrap();
172        let path = write_png(dir.path(), "b.png", 100, 50);
173        assert_eq!(measure(&path).unwrap().orientation, Orientation::Upright);
174    }
175
176    /// Truncated bytes must not panic or hang: a half-copied file in a
177    /// folder being written to is a case a viewer meets in practice.
178    #[test]
179    fn a_truncated_file_is_reported_rather_than_panicking() {
180        let dir = tempfile::tempdir().unwrap();
181        let full = write_png(dir.path(), "c.png", 200, 200);
182        let bytes = std::fs::read(&full).unwrap();
183        let cut = dir.path().join("cut.png");
184        std::fs::write(&cut, &bytes[..bytes.len() / 3]).unwrap();
185        // Either answer is acceptable — a PNG header may survive the cut
186        // and give dimensions. What must not happen is a panic.
187        let _ = measure(&cut);
188    }
189}