Skip to main content

panimg_core/
format.rs

1use serde::Serialize;
2use std::path::Path;
3
4/// Supported image formats.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
6#[serde(rename_all = "lowercase")]
7pub enum ImageFormat {
8    Jpeg,
9    Png,
10    WebP,
11    Avif,
12    Tiff,
13    Gif,
14    Bmp,
15    Qoi,
16    Jxl,
17    Svg,
18    Pdf,
19    Heic,
20}
21
22impl ImageFormat {
23    /// Detect format from magic bytes at the start of file data.
24    pub fn from_bytes(data: &[u8]) -> Option<Self> {
25        if data.len() < 4 {
26            return None;
27        }
28
29        // JPEG: FF D8 FF
30        if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
31            return Some(Self::Jpeg);
32        }
33        // PNG: 89 50 4E 47
34        if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
35            return Some(Self::Png);
36        }
37        // GIF: GIF87a or GIF89a
38        if data.starts_with(b"GIF8") {
39            return Some(Self::Gif);
40        }
41        // BMP: BM
42        if data.starts_with(b"BM") {
43            return Some(Self::Bmp);
44        }
45        // TIFF: II (little-endian) or MM (big-endian)
46        if data.starts_with(&[0x49, 0x49, 0x2A, 0x00])
47            || data.starts_with(&[0x4D, 0x4D, 0x00, 0x2A])
48        {
49            return Some(Self::Tiff);
50        }
51        // WebP: RIFF....WEBP
52        if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
53            return Some(Self::WebP);
54        }
55        // QOI: qoif
56        if data.starts_with(b"qoif") {
57            return Some(Self::Qoi);
58        }
59        // AVIF/HEIF/HEIC: ....ftyp (ISOBMFF container)
60        if data.len() >= 12 && &data[4..8] == b"ftyp" {
61            let brand = &data[8..12];
62            if brand == b"avif" || brand == b"avis" {
63                return Some(Self::Avif);
64            }
65            if brand == b"heic" || brand == b"heix" || brand == b"hevc" || brand == b"hevx" {
66                return Some(Self::Heic);
67            }
68            // mif1 is generic HEIF — scan compatible brands to disambiguate
69            if brand == b"mif1" {
70                // Read ftyp box size from first 4 bytes (big-endian u32)
71                let box_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
72                let box_end = box_size.min(data.len());
73                // Compatible brands start at offset 16, each 4 bytes
74                let mut offset = 16;
75                while offset + 4 <= box_end {
76                    let compat = &data[offset..offset + 4];
77                    if compat == b"avif" || compat == b"avis" {
78                        return Some(Self::Avif);
79                    }
80                    if compat == b"heic"
81                        || compat == b"heix"
82                        || compat == b"hevc"
83                        || compat == b"hevx"
84                    {
85                        return Some(Self::Heic);
86                    }
87                    offset += 4;
88                }
89                // Default: treat mif1 as AVIF (most common case)
90                return Some(Self::Avif);
91            }
92        }
93        // JPEG XL: FF 0A (codestream) or 00 00 00 0C 4A 58 4C 20 (container)
94        if data.starts_with(&[0xFF, 0x0A]) {
95            return Some(Self::Jxl);
96        }
97        if data.len() >= 8 && data.starts_with(&[0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20]) {
98            return Some(Self::Jxl);
99        }
100        // SVG: starts with < and contains <svg (simplified heuristic)
101        if data.starts_with(b"<") || data.starts_with(b"\xEF\xBB\xBF<") {
102            let text = std::str::from_utf8(&data[..data.len().min(1024)]).unwrap_or("");
103            if text.contains("<svg") {
104                return Some(Self::Svg);
105            }
106        }
107        // PDF: %PDF
108        if data.starts_with(b"%PDF") {
109            return Some(Self::Pdf);
110        }
111
112        None
113    }
114
115    /// Detect format from file extension.
116    pub fn from_extension(ext: &str) -> Option<Self> {
117        match ext.to_ascii_lowercase().as_str() {
118            "jpg" | "jpeg" => Some(Self::Jpeg),
119            "png" => Some(Self::Png),
120            "webp" => Some(Self::WebP),
121            "avif" => Some(Self::Avif),
122            "tif" | "tiff" => Some(Self::Tiff),
123            "gif" => Some(Self::Gif),
124            "bmp" => Some(Self::Bmp),
125            "qoi" => Some(Self::Qoi),
126            "jxl" => Some(Self::Jxl),
127            "svg" => Some(Self::Svg),
128            "pdf" => Some(Self::Pdf),
129            "heic" | "heif" => Some(Self::Heic),
130            _ => None,
131        }
132    }
133
134    /// Detect format from a file path: try magic bytes first, then extension.
135    pub fn from_path(path: &Path) -> Option<Self> {
136        // Try magic bytes first
137        if let Ok(data) = std::fs::read(path) {
138            if let Some(fmt) = Self::from_bytes(&data) {
139                return Some(fmt);
140            }
141        }
142        // Fallback to extension
143        path.extension()
144            .and_then(|e| e.to_str())
145            .and_then(Self::from_extension)
146    }
147
148    /// Detect format from extension only (for output paths that don't exist yet).
149    pub fn from_path_extension(path: &Path) -> Option<Self> {
150        path.extension()
151            .and_then(|e| e.to_str())
152            .and_then(Self::from_extension)
153    }
154
155    /// Get the canonical file extension for this format.
156    pub fn extension(&self) -> &'static str {
157        match self {
158            Self::Jpeg => "jpg",
159            Self::Png => "png",
160            Self::WebP => "webp",
161            Self::Avif => "avif",
162            Self::Tiff => "tiff",
163            Self::Gif => "gif",
164            Self::Bmp => "bmp",
165            Self::Qoi => "qoi",
166            Self::Jxl => "jxl",
167            Self::Svg => "svg",
168            Self::Pdf => "pdf",
169            Self::Heic => "heic",
170        }
171    }
172
173    /// Get the MIME type for this format.
174    pub fn mime_type(&self) -> &'static str {
175        match self {
176            Self::Jpeg => "image/jpeg",
177            Self::Png => "image/png",
178            Self::WebP => "image/webp",
179            Self::Avif => "image/avif",
180            Self::Tiff => "image/tiff",
181            Self::Gif => "image/gif",
182            Self::Bmp => "image/bmp",
183            Self::Qoi => "image/qoi",
184            Self::Jxl => "image/jxl",
185            Self::Svg => "image/svg+xml",
186            Self::Pdf => "application/pdf",
187            Self::Heic => "image/heic",
188        }
189    }
190
191    /// Convert to the `image` crate's format enum.
192    pub fn to_image_format(&self) -> Option<image::ImageFormat> {
193        match self {
194            Self::Jpeg => Some(image::ImageFormat::Jpeg),
195            Self::Png => Some(image::ImageFormat::Png),
196            Self::WebP => Some(image::ImageFormat::WebP),
197            Self::Avif => Some(image::ImageFormat::Avif),
198            Self::Tiff => Some(image::ImageFormat::Tiff),
199            Self::Gif => Some(image::ImageFormat::Gif),
200            Self::Bmp => Some(image::ImageFormat::Bmp),
201            Self::Qoi => Some(image::ImageFormat::Qoi),
202            Self::Heic => None,
203            _ => None,
204        }
205    }
206
207    /// All supported formats.
208    pub fn all() -> &'static [Self] {
209        &[
210            Self::Jpeg,
211            Self::Png,
212            Self::WebP,
213            Self::Avif,
214            Self::Tiff,
215            Self::Gif,
216            Self::Bmp,
217            Self::Qoi,
218            Self::Jxl,
219            Self::Svg,
220            Self::Pdf,
221            Self::Heic,
222        ]
223    }
224
225    /// Whether this format is available for encoding in the current build.
226    pub fn can_encode(&self) -> bool {
227        match self {
228            Self::Jpeg
229            | Self::Png
230            | Self::WebP
231            | Self::Bmp
232            | Self::Gif
233            | Self::Tiff
234            | Self::Qoi => true,
235            Self::Avif => cfg!(feature = "avif"),
236            Self::Jxl | Self::Svg | Self::Pdf | Self::Heic => false,
237        }
238    }
239
240    /// Whether this format is available for decoding in the current build.
241    pub fn can_decode(&self) -> bool {
242        match self {
243            Self::Jpeg
244            | Self::Png
245            | Self::WebP
246            | Self::Bmp
247            | Self::Gif
248            | Self::Tiff
249            | Self::Qoi
250            | Self::Avif => true,
251            Self::Jxl => cfg!(feature = "jxl"),
252            Self::Svg => cfg!(feature = "svg"),
253            Self::Pdf => cfg!(feature = "pdf"),
254            Self::Heic => cfg!(all(feature = "heic", target_vendor = "apple")),
255        }
256    }
257}
258
259impl std::fmt::Display for ImageFormat {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::Jpeg => write!(f, "JPEG"),
263            Self::Png => write!(f, "PNG"),
264            Self::WebP => write!(f, "WebP"),
265            Self::Avif => write!(f, "AVIF"),
266            Self::Tiff => write!(f, "TIFF"),
267            Self::Gif => write!(f, "GIF"),
268            Self::Bmp => write!(f, "BMP"),
269            Self::Qoi => write!(f, "QOI"),
270            Self::Jxl => write!(f, "JPEG XL"),
271            Self::Svg => write!(f, "SVG"),
272            Self::Pdf => write!(f, "PDF"),
273            Self::Heic => write!(f, "HEIC"),
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn detect_jpeg_magic() {
284        let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
285        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Jpeg));
286    }
287
288    #[test]
289    fn detect_png_magic() {
290        let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
291        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Png));
292    }
293
294    #[test]
295    fn detect_webp_magic() {
296        let mut data = vec![0u8; 12];
297        data[..4].copy_from_slice(b"RIFF");
298        data[8..12].copy_from_slice(b"WEBP");
299        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::WebP));
300    }
301
302    #[test]
303    fn detect_gif_magic() {
304        assert_eq!(
305            ImageFormat::from_bytes(b"GIF89a\x01\x00"),
306            Some(ImageFormat::Gif)
307        );
308    }
309
310    #[test]
311    fn detect_bmp_magic() {
312        assert_eq!(
313            ImageFormat::from_bytes(b"BM\x00\x00\x00\x00"),
314            Some(ImageFormat::Bmp)
315        );
316    }
317
318    #[test]
319    fn detect_heic_magic() {
320        // ftyp box with "heic" brand
321        let mut data = vec![0u8; 12];
322        data[4..8].copy_from_slice(b"ftyp");
323        data[8..12].copy_from_slice(b"heic");
324        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Heic));
325
326        // ftyp box with "heix" brand
327        data[8..12].copy_from_slice(b"heix");
328        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Heic));
329
330        // ftyp box with "hevc" brand
331        data[8..12].copy_from_slice(b"hevc");
332        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Heic));
333
334        // ftyp box with "hevx" brand
335        data[8..12].copy_from_slice(b"hevx");
336        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Heic));
337    }
338
339    #[test]
340    fn detect_heic_from_extension() {
341        assert_eq!(ImageFormat::from_extension("heic"), Some(ImageFormat::Heic));
342        assert_eq!(ImageFormat::from_extension("heif"), Some(ImageFormat::Heic));
343        assert_eq!(ImageFormat::from_extension("HEIC"), Some(ImageFormat::Heic));
344    }
345
346    #[test]
347    fn detect_from_extension() {
348        assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpeg));
349        assert_eq!(ImageFormat::from_extension("JPEG"), Some(ImageFormat::Jpeg));
350        assert_eq!(ImageFormat::from_extension("png"), Some(ImageFormat::Png));
351        assert_eq!(ImageFormat::from_extension("webp"), Some(ImageFormat::WebP));
352        assert_eq!(ImageFormat::from_extension("xyz"), None);
353    }
354
355    #[test]
356    fn detect_pdf_magic() {
357        assert_eq!(
358            ImageFormat::from_bytes(b"%PDF-1.4 some content"),
359            Some(ImageFormat::Pdf)
360        );
361    }
362
363    #[test]
364    fn detect_pdf_extension() {
365        assert_eq!(ImageFormat::from_extension("pdf"), Some(ImageFormat::Pdf));
366        assert_eq!(ImageFormat::from_extension("PDF"), Some(ImageFormat::Pdf));
367    }
368
369    #[test]
370    fn pdf_can_encode_false() {
371        assert!(!ImageFormat::Pdf.can_encode());
372    }
373
374    #[test]
375    fn pdf_can_decode_depends_on_feature() {
376        // This test verifies can_decode returns the correct value
377        // based on whether the pdf feature is enabled.
378        let can_decode = ImageFormat::Pdf.can_decode();
379        if cfg!(feature = "pdf") {
380            assert!(can_decode);
381        } else {
382            assert!(!can_decode);
383        }
384    }
385
386    #[test]
387    fn pdf_format_properties() {
388        assert_eq!(ImageFormat::Pdf.extension(), "pdf");
389        assert_eq!(ImageFormat::Pdf.mime_type(), "application/pdf");
390        assert_eq!(ImageFormat::Pdf.to_image_format(), None);
391        assert_eq!(ImageFormat::Pdf.to_string(), "PDF");
392    }
393
394    #[test]
395    fn detect_mif1_with_avif_compat() {
396        // mif1 with avif compatible brand → AVIF
397        let mut data = vec![0u8; 24];
398        // ftyp box size = 24
399        data[0..4].copy_from_slice(&24u32.to_be_bytes());
400        data[4..8].copy_from_slice(b"ftyp");
401        data[8..12].copy_from_slice(b"mif1");
402        // minor version (4 bytes)
403        data[16..20].copy_from_slice(b"avif");
404        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Avif));
405    }
406
407    #[test]
408    fn detect_mif1_with_heic_compat() {
409        // mif1 with heic compatible brand → HEIC
410        let mut data = vec![0u8; 24];
411        data[0..4].copy_from_slice(&24u32.to_be_bytes());
412        data[4..8].copy_from_slice(b"ftyp");
413        data[8..12].copy_from_slice(b"mif1");
414        data[16..20].copy_from_slice(b"heic");
415        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Heic));
416    }
417
418    #[test]
419    fn detect_mif1_default_avif() {
420        // mif1 with no recognizable compatible brand → defaults to AVIF
421        let mut data = vec![0u8; 20];
422        data[0..4].copy_from_slice(&20u32.to_be_bytes());
423        data[4..8].copy_from_slice(b"ftyp");
424        data[8..12].copy_from_slice(b"mif1");
425        data[16..20].copy_from_slice(b"miaf");
426        assert_eq!(ImageFormat::from_bytes(&data), Some(ImageFormat::Avif));
427    }
428
429    #[test]
430    fn unknown_bytes_returns_none() {
431        assert_eq!(ImageFormat::from_bytes(&[0x00, 0x01, 0x02, 0x03]), None);
432    }
433}