Skip to main content

slimg_core/
format.rs

1use std::path::Path;
2
3/// Supported image formats.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum Format {
6    Jpeg,
7    Png,
8    WebP,
9    Avif,
10    Jxl,
11    Qoi,
12}
13
14impl Format {
15    /// Detect format from a file extension (case-insensitive).
16    pub fn from_extension(path: &Path) -> Option<Self> {
17        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
18        match ext.as_str() {
19            "jpg" | "jpeg" => Some(Self::Jpeg),
20            "png" => Some(Self::Png),
21            "webp" => Some(Self::WebP),
22            "avif" => Some(Self::Avif),
23            "jxl" => Some(Self::Jxl),
24            "qoi" => Some(Self::Qoi),
25            _ => None,
26        }
27    }
28
29    /// Detect format from magic bytes at the start of the file data.
30    pub fn from_magic_bytes(data: &[u8]) -> Option<Self> {
31        if data.len() >= 3 && data[..3] == [0xFF, 0xD8, 0xFF] {
32            return Some(Self::Jpeg);
33        }
34        if data.len() >= 4 && data[..4] == [0x89, 0x50, 0x4E, 0x47] {
35            return Some(Self::Png);
36        }
37        if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
38            return Some(Self::WebP);
39        }
40        if data.len() >= 12 && &data[4..8] == b"ftyp" {
41            let brand = &data[8..12];
42            if brand.starts_with(b"avif") || brand.starts_with(b"avis") {
43                return Some(Self::Avif);
44            }
45        }
46        // JXL: bare codestream starts with [0xFF, 0x0A]
47        if data.len() >= 2 && data[..2] == [0xFF, 0x0A] {
48            return Some(Self::Jxl);
49        }
50        // JXL: container format starts with [0x00, 0x00, 0x00, 0x0C] + "JXL " at bytes 4-7
51        if data.len() >= 8 && data[..4] == [0x00, 0x00, 0x00, 0x0C] && &data[4..8] == b"JXL " {
52            return Some(Self::Jxl);
53        }
54        if data.len() >= 4 && &data[..4] == b"qoif" {
55            return Some(Self::Qoi);
56        }
57        None
58    }
59
60    /// Return the canonical file extension for this format.
61    pub fn extension(&self) -> &'static str {
62        match self {
63            Self::Jpeg => "jpg",
64            Self::Png => "png",
65            Self::WebP => "webp",
66            Self::Avif => "avif",
67            Self::Jxl => "jxl",
68            Self::Qoi => "qoi",
69        }
70    }
71
72    /// Whether encoding is supported for this format.
73    ///
74    /// Returns `false` only for JXL due to GPL license restrictions
75    /// in the reference encoder.
76    pub fn can_encode(&self) -> bool {
77        !matches!(self, Self::Jxl)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::path::PathBuf;
85
86    // ── Extension detection ─────────────────────────────────────
87
88    #[test]
89    fn extension_jpg() {
90        assert_eq!(
91            Format::from_extension(Path::new("photo.jpg")),
92            Some(Format::Jpeg)
93        );
94    }
95
96    #[test]
97    fn extension_jpeg() {
98        assert_eq!(
99            Format::from_extension(Path::new("photo.jpeg")),
100            Some(Format::Jpeg)
101        );
102    }
103
104    #[test]
105    fn extension_jpg_uppercase() {
106        assert_eq!(
107            Format::from_extension(Path::new("photo.JPG")),
108            Some(Format::Jpeg)
109        );
110    }
111
112    #[test]
113    fn extension_jpeg_mixed_case() {
114        assert_eq!(
115            Format::from_extension(Path::new("photo.JpEg")),
116            Some(Format::Jpeg)
117        );
118    }
119
120    #[test]
121    fn extension_png() {
122        assert_eq!(
123            Format::from_extension(Path::new("image.png")),
124            Some(Format::Png)
125        );
126    }
127
128    #[test]
129    fn extension_png_uppercase() {
130        assert_eq!(
131            Format::from_extension(Path::new("image.PNG")),
132            Some(Format::Png)
133        );
134    }
135
136    #[test]
137    fn extension_webp() {
138        assert_eq!(
139            Format::from_extension(Path::new("image.webp")),
140            Some(Format::WebP)
141        );
142    }
143
144    #[test]
145    fn extension_avif() {
146        assert_eq!(
147            Format::from_extension(Path::new("image.avif")),
148            Some(Format::Avif)
149        );
150    }
151
152    #[test]
153    fn extension_jxl() {
154        assert_eq!(
155            Format::from_extension(Path::new("image.jxl")),
156            Some(Format::Jxl)
157        );
158    }
159
160    #[test]
161    fn extension_qoi() {
162        assert_eq!(
163            Format::from_extension(Path::new("image.qoi")),
164            Some(Format::Qoi)
165        );
166    }
167
168    #[test]
169    fn extension_unknown() {
170        assert_eq!(Format::from_extension(Path::new("file.bmp")), None);
171    }
172
173    #[test]
174    fn extension_none() {
175        assert_eq!(Format::from_extension(Path::new("noext")), None);
176    }
177
178    #[test]
179    fn extension_empty_path() {
180        assert_eq!(Format::from_extension(&PathBuf::new()), None);
181    }
182
183    // ── Magic byte detection ────────────────────────────────────
184
185    #[test]
186    fn magic_jpeg() {
187        let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00];
188        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Jpeg));
189    }
190
191    #[test]
192    fn magic_png() {
193        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
194        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Png));
195    }
196
197    #[test]
198    fn magic_webp() {
199        let mut data = Vec::new();
200        data.extend_from_slice(b"RIFF");
201        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // file size placeholder
202        data.extend_from_slice(b"WEBP");
203        assert_eq!(Format::from_magic_bytes(&data), Some(Format::WebP));
204    }
205
206    #[test]
207    fn magic_avif_avif_brand() {
208        let mut data = vec![0x00, 0x00, 0x00, 0x20]; // box size
209        data.extend_from_slice(b"ftyp");
210        data.extend_from_slice(b"avif");
211        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Avif));
212    }
213
214    #[test]
215    fn magic_avif_avis_brand() {
216        let mut data = vec![0x00, 0x00, 0x00, 0x20]; // box size
217        data.extend_from_slice(b"ftyp");
218        data.extend_from_slice(b"avis");
219        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Avif));
220    }
221
222    #[test]
223    fn magic_jxl_bare_codestream() {
224        let data = [0xFF, 0x0A, 0x00, 0x00];
225        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Jxl));
226    }
227
228    #[test]
229    fn magic_jxl_container() {
230        let mut data = vec![0x00, 0x00, 0x00, 0x0C];
231        data.extend_from_slice(b"JXL ");
232        data.extend_from_slice(&[0x0D, 0x0A, 0x87, 0x0A]);
233        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Jxl));
234    }
235
236    #[test]
237    fn magic_qoi() {
238        let mut data = Vec::new();
239        data.extend_from_slice(b"qoif");
240        data.extend_from_slice(&[0x00; 10]);
241        assert_eq!(Format::from_magic_bytes(&data), Some(Format::Qoi));
242    }
243
244    #[test]
245    fn magic_unknown() {
246        let data = [0x00, 0x00, 0x00, 0x00];
247        assert_eq!(Format::from_magic_bytes(&data), None);
248    }
249
250    #[test]
251    fn magic_empty() {
252        assert_eq!(Format::from_magic_bytes(&[]), None);
253    }
254
255    #[test]
256    fn magic_too_short_for_jpeg() {
257        assert_eq!(Format::from_magic_bytes(&[0xFF, 0xD8]), None);
258    }
259
260    // ── extension() ─────────────────────────────────────────────
261
262    #[test]
263    fn extension_string() {
264        assert_eq!(Format::Jpeg.extension(), "jpg");
265        assert_eq!(Format::Png.extension(), "png");
266        assert_eq!(Format::WebP.extension(), "webp");
267        assert_eq!(Format::Avif.extension(), "avif");
268        assert_eq!(Format::Jxl.extension(), "jxl");
269        assert_eq!(Format::Qoi.extension(), "qoi");
270    }
271
272    // ── can_encode ──────────────────────────────────────────────
273
274    #[test]
275    fn can_encode_jxl_is_false() {
276        assert!(!Format::Jxl.can_encode());
277    }
278
279    #[test]
280    fn can_encode_all_others_true() {
281        assert!(Format::Jpeg.can_encode());
282        assert!(Format::Png.can_encode());
283        assert!(Format::WebP.can_encode());
284        assert!(Format::Avif.can_encode());
285        assert!(Format::Qoi.can_encode());
286    }
287}