Skip to main content

bnto_encode/
format.rs

1// Image format detection via magic bytes (primary) and file extension (fallback).
2// Magic bytes are more reliable than browser-provided MIME types.
3
4/// Supported image formats for compression, resize, and conversion.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ImageFormat {
7    Jpeg,
8    Png,
9    /// Lossless-only in Rust `image` crate. Lossy planned via jSquash JS fallback.
10    WebP,
11}
12
13// --- Format Detection ---
14
15/// JPEG: FF D8 FF, PNG: 89 50 4E 47 0D 0A 1A 0A, WebP: RIFF....WEBP
16const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
17
18impl ImageFormat {
19    /// Detect format from file header bytes (magic bytes).
20    pub fn from_magic_bytes(data: &[u8]) -> Option<Self> {
21        if data.len() < 4 {
22            return None;
23        }
24        if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
25            return Some(Self::Jpeg);
26        }
27        if data.len() >= 8 && data[..8] == PNG_SIGNATURE {
28            return Some(Self::Png);
29        }
30        if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
31            return Some(Self::WebP);
32        }
33        None
34    }
35
36    /// Fallback detection from filename extension (case-insensitive).
37    pub fn from_extension(filename: &str) -> Option<Self> {
38        let lower = filename.to_lowercase();
39        if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
40            Some(Self::Jpeg)
41        } else if lower.ends_with(".png") {
42            Some(Self::Png)
43        } else if lower.ends_with(".webp") {
44            Some(Self::WebP)
45        } else {
46            None
47        }
48    }
49
50    /// Detect format: magic bytes first, extension fallback.
51    pub fn detect(data: &[u8], filename: &str) -> Option<Self> {
52        Self::from_magic_bytes(data).or_else(|| Self::from_extension(filename))
53    }
54
55    /// MIME type for this format.
56    pub fn mime_type(&self) -> &'static str {
57        match self {
58            Self::Jpeg => "image/jpeg",
59            Self::Png => "image/png",
60            Self::WebP => "image/webp",
61        }
62    }
63
64    /// File extension without the dot.
65    pub fn extension(&self) -> &'static str {
66        match self {
67            Self::Jpeg => "jpg",
68            Self::Png => "png",
69            Self::WebP => "webp",
70        }
71    }
72}
73
74// --- Tests ---
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    // --- Magic Bytes Detection Tests ---
81
82    #[test]
83    fn test_detect_jpeg_from_magic_bytes() {
84        let jpeg_data = include_bytes!("../../../../test-fixtures/images/small.jpg");
85        let format = ImageFormat::from_magic_bytes(jpeg_data);
86        assert_eq!(format, Some(ImageFormat::Jpeg));
87    }
88
89    #[test]
90    fn test_detect_png_from_magic_bytes() {
91        let png_data = include_bytes!("../../../../test-fixtures/images/small.png");
92        let format = ImageFormat::from_magic_bytes(png_data);
93        assert_eq!(format, Some(ImageFormat::Png));
94    }
95
96    #[test]
97    fn test_detect_webp_from_magic_bytes() {
98        let webp_data = include_bytes!("../../../../test-fixtures/images/small.webp");
99        let format = ImageFormat::from_magic_bytes(webp_data);
100        assert_eq!(format, Some(ImageFormat::WebP));
101    }
102
103    #[test]
104    fn test_magic_bytes_returns_none_for_unknown_data() {
105        let unknown_data = b"Hello, I am not an image!";
106        let format = ImageFormat::from_magic_bytes(unknown_data);
107        assert_eq!(format, None);
108    }
109
110    #[test]
111    fn test_magic_bytes_returns_none_for_too_short_data() {
112        let short_data = b"Hi";
113        let format = ImageFormat::from_magic_bytes(short_data);
114        assert_eq!(format, None);
115    }
116
117    #[test]
118    fn test_magic_bytes_returns_none_for_empty_data() {
119        let empty: &[u8] = b"";
120        let format = ImageFormat::from_magic_bytes(empty);
121        assert_eq!(format, None);
122    }
123
124    // --- Extension Detection Tests ---
125
126    #[test]
127    fn test_detect_jpeg_from_extension_jpg() {
128        assert_eq!(
129            ImageFormat::from_extension("photo.jpg"),
130            Some(ImageFormat::Jpeg)
131        );
132    }
133
134    #[test]
135    fn test_detect_jpeg_from_extension_jpeg() {
136        assert_eq!(
137            ImageFormat::from_extension("photo.jpeg"),
138            Some(ImageFormat::Jpeg)
139        );
140    }
141
142    #[test]
143    fn test_detect_jpeg_case_insensitive() {
144        assert_eq!(
145            ImageFormat::from_extension("PHOTO.JPG"),
146            Some(ImageFormat::Jpeg)
147        );
148        assert_eq!(
149            ImageFormat::from_extension("Photo.Jpeg"),
150            Some(ImageFormat::Jpeg)
151        );
152    }
153
154    #[test]
155    fn test_detect_png_from_extension() {
156        assert_eq!(
157            ImageFormat::from_extension("screenshot.png"),
158            Some(ImageFormat::Png)
159        );
160    }
161
162    #[test]
163    fn test_detect_webp_from_extension() {
164        assert_eq!(
165            ImageFormat::from_extension("image.webp"),
166            Some(ImageFormat::WebP)
167        );
168    }
169
170    #[test]
171    fn test_extension_returns_none_for_unsupported() {
172        assert_eq!(ImageFormat::from_extension("image.bmp"), None);
173        assert_eq!(ImageFormat::from_extension("image.gif"), None);
174        assert_eq!(ImageFormat::from_extension("image.tiff"), None);
175        assert_eq!(ImageFormat::from_extension("document.pdf"), None);
176    }
177
178    #[test]
179    fn test_extension_returns_none_for_no_extension() {
180        assert_eq!(ImageFormat::from_extension("noextension"), None);
181    }
182
183    // --- Combined Detection Tests ---
184
185    #[test]
186    fn test_detect_uses_magic_bytes_first() {
187        let jpeg_data = include_bytes!("../../../../test-fixtures/images/small.jpg");
188        let format = ImageFormat::detect(jpeg_data, "misleading.png");
189        assert_eq!(format, Some(ImageFormat::Jpeg));
190    }
191
192    #[test]
193    fn test_detect_falls_back_to_extension() {
194        let unknown_data = b"not a real image but trust the name";
195        let format = ImageFormat::detect(unknown_data, "photo.jpg");
196        assert_eq!(format, Some(ImageFormat::Jpeg));
197    }
198
199    #[test]
200    fn test_detect_returns_none_when_both_fail() {
201        let unknown_data = b"not a real image";
202        let format = ImageFormat::detect(unknown_data, "mystery_file");
203        assert_eq!(format, None);
204    }
205
206    // --- Utility Method Tests ---
207
208    #[test]
209    fn test_mime_types() {
210        assert_eq!(ImageFormat::Jpeg.mime_type(), "image/jpeg");
211        assert_eq!(ImageFormat::Png.mime_type(), "image/png");
212        assert_eq!(ImageFormat::WebP.mime_type(), "image/webp");
213    }
214
215    #[test]
216    fn test_extensions() {
217        assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
218        assert_eq!(ImageFormat::Png.extension(), "png");
219        assert_eq!(ImageFormat::WebP.extension(), "webp");
220    }
221
222    // --- Edge Case Tests ---
223
224    #[test]
225    fn test_magic_bytes_single_byte_returns_none() {
226        assert_eq!(ImageFormat::from_magic_bytes(&[0xFF]), None);
227    }
228
229    #[test]
230    fn test_magic_bytes_two_bytes_returns_none() {
231        assert_eq!(ImageFormat::from_magic_bytes(&[0xFF, 0xD8]), None);
232    }
233
234    #[test]
235    fn test_magic_bytes_three_bytes_returns_none() {
236        assert_eq!(ImageFormat::from_magic_bytes(&[0xFF, 0xD8, 0xFF]), None);
237    }
238
239    #[test]
240    fn test_magic_bytes_exactly_4_bytes_jpeg_detected() {
241        let data = [0xFF, 0xD8, 0xFF, 0xE0];
242        assert_eq!(
243            ImageFormat::from_magic_bytes(&data),
244            Some(ImageFormat::Jpeg)
245        );
246    }
247
248    #[test]
249    fn test_magic_bytes_jpeg_like_but_third_byte_not_ff() {
250        let data = [0xFF, 0xD8, 0x00, 0x00];
251        assert_eq!(ImageFormat::from_magic_bytes(&data), None);
252    }
253
254    #[test]
255    fn test_magic_bytes_jpeg_header_only_no_image_data() {
256        let data = [0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x00, 0x00, 0x00];
257        assert_eq!(
258            ImageFormat::from_magic_bytes(&data),
259            Some(ImageFormat::Jpeg)
260        );
261    }
262
263    #[test]
264    fn test_magic_bytes_7_bytes_partial_png_returns_none() {
265        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A];
266        assert_eq!(ImageFormat::from_magic_bytes(&data), None);
267    }
268
269    #[test]
270    fn test_magic_bytes_exactly_8_bytes_png_detected() {
271        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
272        assert_eq!(ImageFormat::from_magic_bytes(&data), Some(ImageFormat::Png));
273    }
274
275    #[test]
276    fn test_magic_bytes_png_with_wrong_final_byte() {
277        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x00];
278        assert_eq!(ImageFormat::from_magic_bytes(&data), None);
279    }
280
281    #[test]
282    fn test_magic_bytes_11_bytes_partial_webp_returns_none() {
283        let data = [
284            b'R', b'I', b'F', b'F', 0x00, 0x00, 0x00, 0x00, b'W', b'E', b'B',
285        ];
286        assert_eq!(ImageFormat::from_magic_bytes(&data), None);
287    }
288
289    #[test]
290    fn test_magic_bytes_exactly_12_bytes_webp_detected() {
291        let data = [
292            b'R', b'I', b'F', b'F', 0x00, 0x00, 0x00, 0x00, b'W', b'E', b'B', b'P',
293        ];
294        assert_eq!(
295            ImageFormat::from_magic_bytes(&data),
296            Some(ImageFormat::WebP)
297        );
298    }
299
300    #[test]
301    fn test_magic_bytes_riff_but_not_webp() {
302        let data = [
303            b'R', b'I', b'F', b'F', 0x00, 0x00, 0x00, 0x00, b'A', b'V', b'I', b' ',
304        ];
305        assert_eq!(ImageFormat::from_magic_bytes(&data), None);
306    }
307
308    #[test]
309    fn test_detect_zero_bytes_with_jpg_extension_uses_extension() {
310        let format = ImageFormat::detect(b"", "empty.jpg");
311        assert_eq!(format, Some(ImageFormat::Jpeg));
312    }
313
314    #[test]
315    fn test_detect_zero_bytes_no_extension_returns_none() {
316        let format = ImageFormat::detect(b"", "unknown_file");
317        assert_eq!(format, None);
318    }
319
320    #[test]
321    fn test_detect_single_byte_with_png_extension_uses_extension() {
322        let format = ImageFormat::detect(&[0x42], "tiny.png");
323        assert_eq!(format, Some(ImageFormat::Png));
324    }
325
326    #[test]
327    fn test_detect_4_bytes_jpeg_ignores_wrong_extension() {
328        let data = [0xFF, 0xD8, 0xFF, 0xE0];
329        let format = ImageFormat::detect(&data, "lies.png");
330        assert_eq!(format, Some(ImageFormat::Jpeg));
331    }
332}