Skip to main content

setzer_model/
asset.rs

1//! Images that belong to a book, referenced by `Block::Image::asset`.
2//!
3//! An asset is its bytes plus what the bytes say about themselves (format,
4//! pixel size, stored DPI). The metadata is always derived from the bytes,
5//! never stored next to them, so it cannot go stale: a `.kernsetzer` file holds
6//! only `assets/<id>`.
7
8/// An image file, e.g. `assets/image1.png` in a project.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Asset {
11    /// File name under `assets/` in the project, e.g. `image1.png`.
12    pub id: String,
13    pub media_type: String,
14    pub bytes: Vec<u8>,
15    /// Pixel size from the file header, if it could be read.
16    pub width_px: Option<u32>,
17    pub height_px: Option<u32>,
18    /// Resolution stored in the file (PNG `pHYs`, JPEG JFIF), if any.
19    pub dpi: Option<f32>,
20}
21
22impl Asset {
23    /// An asset with metadata read from `bytes`. Content that is not a
24    /// supported image gets `application/octet-stream` and no size.
25    pub fn new(id: impl Into<String>, bytes: Vec<u8>) -> Self {
26        let media_type = match ImageFormat::sniff(&bytes) {
27            ImageFormat::Supported { media_type, .. } => media_type,
28            ImageFormat::Unsupported(_) => "application/octet-stream",
29        };
30        let info = header_info(&bytes);
31        Self {
32            id: id.into(),
33            media_type: media_type.into(),
34            width_px: info.map(|i| i.0),
35            height_px: info.map(|i| i.1),
36            dpi: info.and_then(|i| i.2),
37            bytes,
38        }
39    }
40
41    /// Whether `id` can be used as a file name under `assets/`: a plain
42    /// name (no directories, no `..`, no leading dot) of portable
43    /// characters.
44    pub fn is_valid_id(id: &str) -> bool {
45        !id.is_empty()
46            && id.len() <= 255
47            && !id.starts_with('.')
48            && id
49                .chars()
50                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
51    }
52}
53
54/// What kind of image a file is, judged by content, not extension.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ImageFormat {
57    /// A format books can use: PNG, JPEG, GIF, WebP, SVG.
58    Supported {
59        media_type: &'static str,
60        /// File extension to use for the asset id.
61        ext: &'static str,
62    },
63    /// Anything else, with a name to show the author (`"TIFF"`).
64    Unsupported(&'static str),
65}
66
67impl ImageFormat {
68    /// Identify an image by its magic bytes.
69    pub fn sniff(b: &[u8]) -> Self {
70        let ok = |media_type, ext| ImageFormat::Supported { media_type, ext };
71        match b {
72            [0x89, b'P', b'N', b'G', ..] => ok("image/png", "png"),
73            [0xFF, 0xD8, 0xFF, ..] => ok("image/jpeg", "jpg"),
74            [b'G', b'I', b'F', b'8', ..] => ok("image/gif", "gif"),
75            [
76                b'R',
77                b'I',
78                b'F',
79                b'F',
80                _,
81                _,
82                _,
83                _,
84                b'W',
85                b'E',
86                b'B',
87                b'P',
88                ..,
89            ] => ok("image/webp", "webp"),
90            [0xD7, 0xCD, 0xC6, 0x9A, ..] | [0x01 | 0x02, 0x00, 0x09, 0x00, ..] => {
91                Self::Unsupported("WMF")
92            }
93            [0x01, 0x00, 0x00, 0x00, ..] if b.get(40..44) == Some(b" EMF") => {
94                Self::Unsupported("EMF")
95            }
96            [b'I', b'I', 0x2A, 0x00, ..] | [b'M', b'M', 0x00, 0x2A, ..] => {
97                Self::Unsupported("TIFF")
98            }
99            [b'B', b'M', ..] => Self::Unsupported("BMP"),
100            _ => {
101                let head = String::from_utf8_lossy(&b[..b.len().min(1024)]);
102                if head.contains("<svg") {
103                    ok("image/svg+xml", "svg")
104                } else {
105                    Self::Unsupported("an unknown")
106                }
107            }
108        }
109    }
110}
111
112/// (width, height, dpi) from the header of a PNG, JPEG, GIF or WebP.
113fn header_info(b: &[u8]) -> Option<(u32, u32, Option<f32>)> {
114    let be16 = |i: usize| {
115        b.get(i..i + 2)
116            .map(|s| u32::from(u16::from_be_bytes([s[0], s[1]])))
117    };
118    let be32 = |i: usize| {
119        b.get(i..i + 4)
120            .map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
121    };
122    let le16 = |i: usize| {
123        b.get(i..i + 2)
124            .map(|s| u32::from(u16::from_le_bytes([s[0], s[1]])))
125    };
126    let le24 = |i: usize| {
127        b.get(i..i + 3)
128            .map(|s| u32::from(s[0]) | u32::from(s[1]) << 8 | u32::from(s[2]) << 16)
129    };
130    let ImageFormat::Supported { media_type, .. } = ImageFormat::sniff(b) else {
131        return None;
132    };
133    match media_type {
134        "image/png" => {
135            let (w, h) = (be32(16)?, be32(20)?);
136            // Walk chunks for pHYs (pixels per metre when unit == 1).
137            let mut i = 8;
138            let mut dpi = None;
139            while let (Some(len), Some(kind)) = (be32(i), b.get(i + 4..i + 8)) {
140                match kind {
141                    b"pHYs" if b.get(i + 16) == Some(&1) => {
142                        dpi = be32(i + 8).map(|ppm| ppm as f32 * 0.0254);
143                        break;
144                    }
145                    b"IDAT" | b"IEND" => break,
146                    _ => i = i.checked_add(12 + len as usize)?,
147                }
148            }
149            Some((w, h, dpi))
150        }
151        "image/jpeg" => {
152            let mut i = 2;
153            let mut dpi = None;
154            loop {
155                if b.get(i) != Some(&0xFF) {
156                    return None;
157                }
158                let marker = *b.get(i + 1)?;
159                if marker == 0xFF {
160                    i += 1; // fill byte
161                    continue;
162                }
163                let len = be16(i + 2)? as usize;
164                let data = i + 4;
165                match marker {
166                    // APP0 JFIF: units (1 = dpi, 2 = dots per cm), X density.
167                    0xE0 if b.get(data..data + 5) == Some(b"JFIF\0") => {
168                        let x = be16(data + 8)? as f32;
169                        dpi = match b.get(data + 7) {
170                            Some(1) => Some(x),
171                            Some(2) => Some(x * 2.54),
172                            _ => None,
173                        };
174                    }
175                    // Start of frame (not DHT, JPG, DAC).
176                    0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => {
177                        let h = be16(data + 1)?;
178                        let w = be16(data + 3)?;
179                        return Some((w, h, dpi.filter(|d| *d > 1.0)));
180                    }
181                    _ => {}
182                }
183                i = data.checked_add(len.checked_sub(2)?)?;
184            }
185        }
186        "image/gif" => Some((le16(6)?, le16(8)?, None)),
187        "image/webp" => match b.get(12..16)? {
188            b"VP8X" => Some((le24(24)? + 1, le24(27)? + 1, None)),
189            b"VP8L" => {
190                let bits = b.get(21..25)?;
191                let v = u32::from_le_bytes([bits[0], bits[1], bits[2], bits[3]]);
192                Some(((v & 0x3FFF) + 1, ((v >> 14) & 0x3FFF) + 1, None))
193            }
194            b"VP8 " => Some((le16(26)? & 0x3FFF, le16(28)? & 0x3FFF, None)),
195            _ => None,
196        },
197        _ => None,
198    }
199}
200
201/// Test images for this crate and its dependents' tests.
202#[doc(hidden)]
203pub mod testdata {
204    /// A valid 3×2 PNG with a 300 dpi pHYs chunk (CRCs are not checked).
205    pub fn png_3x2_300dpi() -> Vec<u8> {
206        let mut b = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
207        b.extend_from_slice(&13u32.to_be_bytes());
208        b.extend_from_slice(b"IHDR");
209        b.extend_from_slice(&3u32.to_be_bytes());
210        b.extend_from_slice(&2u32.to_be_bytes());
211        b.extend_from_slice(&[8, 2, 0, 0, 0]);
212        b.extend_from_slice(&[0; 4]);
213        b.extend_from_slice(&9u32.to_be_bytes());
214        b.extend_from_slice(b"pHYs");
215        b.extend_from_slice(&11811u32.to_be_bytes());
216        b.extend_from_slice(&11811u32.to_be_bytes());
217        b.push(1);
218        b.extend_from_slice(&[0; 4]);
219        b.extend_from_slice(&0u32.to_be_bytes());
220        b.extend_from_slice(b"IEND");
221        b.extend_from_slice(&[0; 4]);
222        b
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::testdata::png_3x2_300dpi;
229    use super::*;
230
231    #[test]
232    fn png_header() {
233        let (w, h, dpi) = header_info(&png_3x2_300dpi()).unwrap();
234        assert_eq!((w, h), (3, 2));
235        assert!((dpi.unwrap() - 300.0).abs() < 0.1);
236    }
237
238    #[test]
239    fn jpeg_header() {
240        let mut b = vec![0xFF, 0xD8];
241        // APP0 JFIF, 72 dpi
242        b.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]);
243        b.extend_from_slice(b"JFIF\0");
244        b.extend_from_slice(&[1, 1, 1, 0, 72, 0, 72, 0, 0]);
245        // SOF0: precision, height 480, width 640
246        b.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 8, 0x01, 0xE0, 0x02, 0x80]);
247        b.extend_from_slice(&[0; 12]);
248        assert_eq!(header_info(&b), Some((640, 480, Some(72.0))));
249    }
250
251    #[test]
252    fn truncated_headers_do_not_panic() {
253        let png = png_3x2_300dpi();
254        for n in 0..png.len() {
255            let _ = header_info(&png[..n]);
256        }
257        for b in [
258            &[0xFF, 0xD8, 0xFF][..],
259            &[0xFF, 0xD8, 0xFF, 0xC0, 0, 1][..],
260            b"GIF8",
261            b"RIFF0000WEBPVP8X",
262        ] {
263            let _ = header_info(b);
264        }
265    }
266
267    #[test]
268    fn formats_by_content() {
269        assert_eq!(
270            ImageFormat::sniff(b"GIF89a.."),
271            ImageFormat::Supported {
272                media_type: "image/gif",
273                ext: "gif"
274            }
275        );
276        assert_eq!(
277            ImageFormat::sniff(&[0xD7, 0xCD, 0xC6, 0x9A, 0]),
278            ImageFormat::Unsupported("WMF")
279        );
280        assert_eq!(
281            ImageFormat::sniff(
282                br#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>"#
283            ),
284            ImageFormat::Supported {
285                media_type: "image/svg+xml",
286                ext: "svg"
287            }
288        );
289    }
290
291    #[test]
292    fn new_reads_metadata() {
293        let a = Asset::new("p.png", png_3x2_300dpi());
294        assert_eq!(a.media_type, "image/png");
295        assert_eq!((a.width_px, a.height_px), (Some(3), Some(2)));
296        let junk = Asset::new("x.bin", b"hello".to_vec());
297        assert_eq!(junk.media_type, "application/octet-stream");
298        assert_eq!(junk.width_px, None);
299    }
300
301    #[test]
302    fn asset_ids() {
303        for ok in ["image1.png", "Cover_2-final.jpg", "a"] {
304            assert!(Asset::is_valid_id(ok), "{ok}");
305        }
306        for bad in [
307            "", ".hidden", "../x.png", "a/b.png", "a\\b.png", "ü.png", "a b.png",
308        ] {
309            assert!(!Asset::is_valid_id(bad), "{bad}");
310        }
311    }
312}