1#[derive(Debug, Clone, PartialEq)]
10pub struct Asset {
11 pub id: String,
13 pub media_type: String,
14 pub bytes: Vec<u8>,
15 pub width_px: Option<u32>,
17 pub height_px: Option<u32>,
18 pub dpi: Option<f32>,
20}
21
22impl Asset {
23 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ImageFormat {
57 Supported {
59 media_type: &'static str,
60 ext: &'static str,
62 },
63 Unsupported(&'static str),
65}
66
67impl ImageFormat {
68 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
112fn 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 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; continue;
162 }
163 let len = be16(i + 2)? as usize;
164 let data = i + 4;
165 match marker {
166 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 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#[doc(hidden)]
203pub mod testdata {
204 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 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 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}