Skip to main content

hyprforge_image/
decode.rs

1//! Turning a file into pixels a renderer can take, within a budget.
2//!
3//! The order is the design, and each step exists because the one before
4//! it made it safe:
5//!
6//! 1. [`crate::measure`](crate::measure()) reads the header. Nothing is allocated.
7//! 2. [`crate::budget`] decides whether this may be decoded at all, and
8//!    at what size.
9//! 3. The decoder runs under `image`'s own `Limits`, so a file whose
10//!    header lied is refused while allocating rather than after.
11//! 4. The EXIF orientation is applied — **ours to apply**, because the
12//!    renderer only does it for handle kinds a viewer cannot use. See
13//!    [`crate::orientation`].
14//! 5. The result is resized down to the budget and handed over as RGBA8.
15
16use crate::budget::{Budget, DecodePixels};
17use crate::error::ImageError;
18use crate::measure::{measure, Measured};
19use std::path::Path;
20
21/// A decoded picture, ready to become a renderer's handle.
22#[derive(Clone, PartialEq, Eq)]
23pub struct Decoded {
24    /// RGBA8, row-major, `size.width * size.height * 4` bytes.
25    pub pixels: Vec<u8>,
26    /// The size of `pixels` — what was *kept*, which is at or under both
27    /// the budget and the source.
28    pub size: DecodePixels,
29    /// What the header said, before any of this. Kept so a viewer can
30    /// show the picture's real dimensions rather than the decoded ones —
31    /// an info panel that reports what it happened to decode would be
32    /// lying about the file.
33    pub measured: Measured,
34}
35
36/// Deliberately hand-written: the derived one would render several
37/// megabytes of pixel data into a log line.
38///
39/// Not the keystroke rule, but the same habit — a `Debug` that dumps a
40/// buffer is a `Debug` nobody can use, and someone debugging a viewer is
41/// debugging sizes, not pixel values.
42impl std::fmt::Debug for Decoded {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Decoded")
45            .field("size", &self.size)
46            .field("bytes", &self.pixels.len())
47            .field("measured", &self.measured)
48            .finish()
49    }
50}
51
52/// Decodes `path` to fit within `budget`.
53pub fn decode_to_fit(path: &Path, budget: &Budget) -> Result<Decoded, ImageError> {
54    let measured = measure(path)?;
55
56    if !budget.allows_source(measured.source) {
57        return Err(ImageError::TooLarge {
58            path: path.to_path_buf(),
59            width: measured.source.width,
60            height: measured.source.height,
61        });
62    }
63
64    let undecodable = |source| ImageError::Undecodable { path: path.to_path_buf(), source };
65
66    let file = std::fs::File::open(path)
67        .map_err(|source| ImageError::Unreadable { path: path.to_path_buf(), source })?;
68    let mut reader = image::ImageReader::new(std::io::BufReader::new(file));
69    reader.set_format(measured.format);
70    reader.limits(budget.limits());
71    let mut decoded = reader.decode().map_err(undecodable)?;
72
73    // Orientation before fitting, not after: a quarter turn swaps the
74    // axes, so fitting first would fit the wrong rectangle and then turn
75    // it — landing at a size that is inside the budget on paper and the
76    // wrong shape on screen.
77    //
78    // `apply_orientation` mutates in place and returns `()`, unlike the
79    // `rotate90`-style methods next to it that return a new image.
80    decoded.apply_orientation(measured.orientation.as_image());
81
82    let target = budget.fit(crate::measure::SourcePixels {
83        width: decoded.width(),
84        height: decoded.height(),
85    });
86    let decoded = if target.width == decoded.width() && target.height == decoded.height() {
87        decoded
88    } else {
89        // `thumbnail_exact`, not `resize_exact`, and this is the single
90        // most consequential line in the crate — measured, because it
91        // looks like a quality choice and is really a memory one.
92        //
93        // On the 36-megapixel photograph in `budget`'s table,
94        // `resize_exact` peaks at 509MB and this peaks at 214MB. It is
95        // cheaper even than not scaling at all (252MB), because it goes
96        // straight to the small buffer rather than building a full-size
97        // one on the way. `resize_exact` works through floating-point
98        // intermediates which, at this source size, are larger than the
99        // picture.
100        //
101        // Not a quality compromise at this ratio: `thumbnail_exact` is a
102        // box average that samples every source pixel, where a triangle
103        // filter's support is narrow enough to skip most of them and
104        // alias. It would be the wrong tool for an *upscale*, which is
105        // why `fit` never asks for one.
106        decoded.thumbnail_exact(target.width, target.height)
107    };
108
109    let rgba = decoded.into_rgba8();
110    Ok(Decoded {
111        size: DecodePixels { width: rgba.width(), height: rgba.height() },
112        pixels: rgba.into_raw(),
113        measured,
114    })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::budget::ViewportPixels;
121    use crate::orientation::Orientation;
122    use std::path::PathBuf;
123
124    fn write_png(dir: &Path, name: &str, width: u32, height: u32) -> PathBuf {
125        let path = dir.join(name);
126        image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255]))
127            .save(&path)
128            .unwrap();
129        path
130    }
131
132    /// The claim the whole crate is built around, measured rather than
133    /// asserted in prose: a photograph far larger than the screen is
134    /// *retained* at a fraction of its full size.
135    #[test]
136    fn a_picture_larger_than_the_window_is_never_retained_at_full_size() {
137        let dir = tempfile::tempdir().unwrap();
138        // 4000x3000 rather than a real 36MP file: the arithmetic is the
139        // same and the test does not spend a second encoding one.
140        let path = write_png(dir.path(), "big.png", 4000, 3000);
141        let budget = Budget::for_viewport(ViewportPixels { width: 800, height: 600 });
142        let decoded = decode_to_fit(&path, &budget).unwrap();
143
144        assert!(decoded.size.width < 4000, "{:?}", decoded.size);
145        assert_eq!(decoded.pixels.len() as u64, decoded.size.rgba_bytes());
146        assert!(decoded.pixels.len() as u64 <= budget.max_retained_bytes());
147        // And the file's real size is still reported, not the decoded one.
148        assert_eq!(decoded.measured.source.width, 4000);
149    }
150
151    #[test]
152    fn a_picture_that_already_fits_is_decoded_as_it_is() {
153        let dir = tempfile::tempdir().unwrap();
154        let path = write_png(dir.path(), "small.png", 320, 240);
155        let budget = Budget::for_viewport(ViewportPixels { width: 1920, height: 1080 });
156        let decoded = decode_to_fit(&path, &budget).unwrap();
157        assert_eq!(decoded.size, DecodePixels { width: 320, height: 240 });
158    }
159
160    #[test]
161    fn the_decoded_buffer_is_exactly_four_bytes_a_pixel() {
162        let dir = tempfile::tempdir().unwrap();
163        let path = write_png(dir.path(), "rgba.png", 7, 5);
164        let budget = Budget::for_edge(1000);
165        let decoded = decode_to_fit(&path, &budget).unwrap();
166        assert_eq!(decoded.pixels.len(), 7 * 5 * 4);
167    }
168
169    /// A `Debug` that printed the buffer would make every log line
170    /// useless and every panic message enormous.
171    #[test]
172    fn debugging_a_decoded_picture_shows_sizes_and_not_pixels() {
173        let dir = tempfile::tempdir().unwrap();
174        let path = write_png(dir.path(), "d.png", 4, 4);
175        let decoded = decode_to_fit(&path, &Budget::for_edge(100)).unwrap();
176        let shown = format!("{decoded:?}");
177        assert!(shown.contains("bytes"));
178        assert!(shown.len() < 400, "{shown}");
179    }
180
181    #[test]
182    fn a_file_that_is_not_a_picture_is_reported_and_not_decoded() {
183        let dir = tempfile::tempdir().unwrap();
184        let path = dir.path().join("x.txt");
185        std::fs::write(&path, b"not a picture at all").unwrap();
186        assert!(decode_to_fit(&path, &Budget::for_edge(100)).is_err());
187    }
188
189    /// A JPEG with a real EXIF orientation tag, built by hand because
190    /// `image` can write pixels but not metadata.
191    ///
192    /// The picture is 32x16 with its top half red and its bottom half
193    /// blue, so a quarter turn is unmistakable: rotating clockwise sends
194    /// the top row to the right-hand column.
195    fn jpeg_with_orientation(dir: &Path, name: &str, exif: Option<u16>) -> PathBuf {
196        let mut rgb = image::RgbImage::new(32, 16);
197        for (_, y, pixel) in rgb.enumerate_pixels_mut() {
198            *pixel = if y < 8 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) };
199        }
200        let mut bytes = Vec::new();
201        image::DynamicImage::ImageRgb8(rgb)
202            .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg)
203            .unwrap();
204
205        if let Some(value) = exif {
206            // An APP1 segment spliced in directly after the SOI marker.
207            //   FF E1 <len> "Exif\0\0"
208            //   TIFF header, big-endian: "MM" 002A <offset to IFD0 = 8>
209            //   IFD0: one entry — tag 0x0112 (Orientation), type 3
210            //   (SHORT), count 1, value in the high half of the 4-byte
211            //   value field, then a null next-IFD offset.
212            let mut app1: Vec<u8> = Vec::new();
213            app1.extend_from_slice(b"Exif\0\0");
214            app1.extend_from_slice(&[0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08]);
215            app1.extend_from_slice(&[0x00, 0x01]);
216            app1.extend_from_slice(&[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01]);
217            app1.extend_from_slice(&value.to_be_bytes());
218            app1.extend_from_slice(&[0x00, 0x00]);
219            app1.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
220
221            let len = (app1.len() + 2) as u16;
222            let mut spliced = vec![0xFF, 0xD8, 0xFF, 0xE1];
223            spliced.extend_from_slice(&len.to_be_bytes());
224            spliced.extend_from_slice(&app1);
225            spliced.extend_from_slice(&bytes[2..]);
226            bytes = spliced;
227        }
228
229        let path = dir.join(name);
230        std::fs::write(&path, &bytes).unwrap();
231        path
232    }
233
234    fn pixel_at(decoded: &Decoded, x: u32, y: u32) -> (u8, u8, u8) {
235        let i = ((y * decoded.size.width + x) * 4) as usize;
236        (decoded.pixels[i], decoded.pixels[i + 1], decoded.pixels[i + 2])
237    }
238
239    fn is_reddish((r, _g, b): (u8, u8, u8)) -> bool {
240        r > 150 && b < 100
241    }
242
243    fn is_bluish((r, _g, b): (u8, u8, u8)) -> bool {
244        b > 150 && r < 100
245    }
246
247    /// The control, and the reason the test below is worth anything: the
248    /// same pixels with no EXIF tag come back unturned. A rig that
249    /// cannot fail on purpose is not evidence — CLAUDE.md's rule about
250    /// checking the instrument before trusting what it says.
251    #[test]
252    fn without_an_exif_tag_the_picture_is_not_turned() {
253        let dir = tempfile::tempdir().unwrap();
254        let path = jpeg_with_orientation(dir.path(), "plain.jpg", None);
255        let decoded = decode_to_fit(&path, &Budget::for_edge(1000)).unwrap();
256
257        assert_eq!((decoded.size.width, decoded.size.height), (32, 16));
258        assert!(is_reddish(pixel_at(&decoded, 16, 2)), "top should be red");
259        assert!(is_bluish(pixel_at(&decoded, 16, 13)), "bottom should be blue");
260    }
261
262    /// The headline: a photograph a phone tagged as needing a quarter
263    /// turn is shown upright, by us, because the renderer will not do it
264    /// for the handle kind a viewer has to use.
265    #[test]
266    fn a_portrait_photo_is_not_shown_sideways() {
267        let dir = tempfile::tempdir().unwrap();
268        let path = jpeg_with_orientation(dir.path(), "turned.jpg", Some(6));
269        let decoded = decode_to_fit(&path, &Budget::for_edge(1000)).unwrap();
270
271        // Rotated a quarter turn clockwise: the frame stands up...
272        assert_eq!((decoded.size.width, decoded.size.height), (16, 32));
273        // ...and the row that was along the top is now the right column.
274        assert!(is_reddish(pixel_at(&decoded, 13, 16)), "right column should be red");
275        assert!(is_bluish(pixel_at(&decoded, 2, 16)), "left column should be blue");
276    }
277
278    /// And the size reported for the file is the size it *presents*, so
279    /// an info panel and a fit-to-window agree with each other.
280    #[test]
281    fn a_turned_photo_reports_the_size_it_presents() {
282        let dir = tempfile::tempdir().unwrap();
283        let path = jpeg_with_orientation(dir.path(), "turned2.jpg", Some(6));
284        let measured = crate::measure::measure(&path).unwrap();
285        assert_eq!(measured.orientation, Orientation::Rotate90);
286        assert_eq!(measured.source, crate::measure::SourcePixels { width: 32, height: 16 });
287        assert_eq!(measured.display_size(), (16, 32));
288    }
289
290    /// Orientation is applied before fitting, so a quarter-turned
291    /// picture comes back in the shape it will be shown in.
292    #[test]
293    fn a_decoded_picture_reports_the_shape_it_will_be_shown_in() {
294        let dir = tempfile::tempdir().unwrap();
295        let path = write_png(dir.path(), "wide.png", 400, 200);
296        let decoded = decode_to_fit(&path, &Budget::for_edge(1000)).unwrap();
297        assert_eq!(decoded.measured.orientation, Orientation::Upright);
298        assert_eq!((decoded.size.width, decoded.size.height), decoded.measured.display_size());
299    }
300}