Skip to main content

oxihuman_core/
image_codec.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Image encode/decode providing format detection, BMP and PNG codec implementations,
5//! and header construction utilities.
6
7use zune_core::bytestream::ZCursor;
8use zune_core::colorspace::ColorSpace;
9use zune_core::options::EncoderOptions;
10use zune_png::{PngDecoder, PngEncoder};
11
12#[allow(dead_code)]
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ImageFormat {
15    Png,
16    Jpeg,
17    Bmp,
18    Tga,
19    Hdr,
20    Gif,
21    Webp,
22    Tiff,
23    Unknown,
24}
25
26#[allow(dead_code)]
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum PixelFormat {
29    Rgb8,
30    Rgba8,
31    Grayscale8,
32    Rgba16,
33}
34
35#[allow(dead_code)]
36#[derive(Debug, Clone)]
37pub struct ImageHeader {
38    pub width: u32,
39    pub height: u32,
40    pub format: ImageFormat,
41    pub pixel_format: PixelFormat,
42}
43
44#[allow(dead_code)]
45#[derive(Debug, Clone)]
46pub struct EncodeConfig {
47    pub quality: u8,
48    pub format: ImageFormat,
49}
50
51/// Decode result carrying both metadata and raw pixel data.
52#[derive(Debug, Clone)]
53pub struct RawDecodeResult {
54    pub width: usize,
55    pub height: usize,
56    pub pixels: Vec<u8>,
57}
58
59#[allow(dead_code)]
60#[derive(Debug, Clone)]
61pub struct DecodeResult {
62    pub header: ImageHeader,
63    pub pixel_count: usize,
64    pub byte_size: usize,
65}
66
67/// Errors that can occur during image encode/decode operations.
68#[derive(Debug, thiserror::Error)]
69pub enum ImageError {
70    #[error("Image encoding failed: {0}")]
71    EncodeError(String),
72    #[error("Image decoding failed: {0}")]
73    DecodeError(String),
74    #[error("Invalid or unrecognised magic bytes")]
75    InvalidMagic,
76    #[error("Input data was truncated or too short")]
77    TruncatedInput,
78    #[error("Unsupported compression method in BMP")]
79    UnsupportedCompression,
80}
81
82/// Detect image format from leading magic bytes.
83pub fn detect_format(bytes: &[u8]) -> ImageFormat {
84    if bytes.len() >= 8 && bytes[..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
85        return ImageFormat::Png;
86    }
87    if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
88        return ImageFormat::Jpeg;
89    }
90    if bytes.len() >= 2 && &bytes[..2] == b"BM" {
91        return ImageFormat::Bmp;
92    }
93    if bytes.len() >= 6 && (&bytes[..6] == b"GIF87a" || &bytes[..6] == b"GIF89a") {
94        return ImageFormat::Gif;
95    }
96    if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
97        return ImageFormat::Webp;
98    }
99    if bytes.len() >= 4 && (&bytes[..4] == b"II*\x00" || &bytes[..4] == b"MM\x00*") {
100        return ImageFormat::Tiff;
101    }
102    ImageFormat::Unknown
103}
104
105/// Encode 24-bit RGB pixel data as a BMP file.
106///
107/// `pixels` must be tightly packed RGB (3 bytes per pixel), row-major top-to-bottom.
108pub fn bmp_encode_rgb(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
109    let stride = width as usize * 3;
110    let row_bytes = (stride + 3) & !3; // pad rows to 4-byte boundary
111    let pixel_data_size = row_bytes * height as usize;
112    let file_size = (54 + pixel_data_size) as u32;
113    let mut buf = Vec::with_capacity(file_size as usize);
114
115    // File header (14 bytes)
116    buf.extend_from_slice(b"BM");
117    buf.extend_from_slice(&file_size.to_le_bytes());
118    buf.extend_from_slice(&0u32.to_le_bytes()); // reserved
119    buf.extend_from_slice(&54u32.to_le_bytes()); // pixel data offset
120
121    // DIB header / BITMAPINFOHEADER (40 bytes)
122    buf.extend_from_slice(&40u32.to_le_bytes()); // header size
123    buf.extend_from_slice(&(width as i32).to_le_bytes());
124    buf.extend_from_slice(&(height as i32).to_le_bytes()); // positive = bottom-up
125    buf.extend_from_slice(&1u16.to_le_bytes()); // color planes
126    buf.extend_from_slice(&24u16.to_le_bytes()); // bits per pixel
127    buf.extend_from_slice(&0u32.to_le_bytes()); // no compression
128    buf.extend_from_slice(&(pixel_data_size as u32).to_le_bytes());
129    buf.extend_from_slice(&2835u32.to_le_bytes()); // h ppm
130    buf.extend_from_slice(&2835u32.to_le_bytes()); // v ppm
131    buf.extend_from_slice(&0u32.to_le_bytes()); // palette colours
132    buf.extend_from_slice(&0u32.to_le_bytes()); // important colours
133
134    // Pixel data: rows in reverse order (bottom-up), BGR byte order
135    let padding = row_bytes - stride;
136    for row in (0..height as usize).rev() {
137        let row_src = &pixels[row * stride..(row + 1) * stride];
138        for px in row_src.chunks_exact(3) {
139            buf.push(px[2]); // B
140            buf.push(px[1]); // G
141            buf.push(px[0]); // R
142        }
143        buf.extend(std::iter::repeat_n(0u8, padding));
144    }
145    buf
146}
147
148/// Encode 32-bit RGBA pixel data as a BMP file.
149///
150/// `pixels` must be tightly packed RGBA (4 bytes per pixel), row-major top-to-bottom.
151pub fn bmp_encode_rgba(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
152    let stride = width as usize * 4;
153    let row_bytes = stride; // 32bpp rows are always 4-byte aligned
154    let pixel_data_size = row_bytes * height as usize;
155    let file_size = (54 + pixel_data_size) as u32;
156    let mut buf = Vec::with_capacity(file_size as usize);
157
158    // File header (14 bytes)
159    buf.extend_from_slice(b"BM");
160    buf.extend_from_slice(&file_size.to_le_bytes());
161    buf.extend_from_slice(&0u32.to_le_bytes()); // reserved
162    buf.extend_from_slice(&54u32.to_le_bytes()); // pixel data offset
163
164    // DIB header / BITMAPINFOHEADER (40 bytes)
165    buf.extend_from_slice(&40u32.to_le_bytes()); // header size
166    buf.extend_from_slice(&(width as i32).to_le_bytes());
167    buf.extend_from_slice(&(height as i32).to_le_bytes()); // positive = bottom-up
168    buf.extend_from_slice(&1u16.to_le_bytes()); // color planes
169    buf.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
170    buf.extend_from_slice(&0u32.to_le_bytes()); // no compression
171    buf.extend_from_slice(&(pixel_data_size as u32).to_le_bytes());
172    buf.extend_from_slice(&2835u32.to_le_bytes()); // h ppm
173    buf.extend_from_slice(&2835u32.to_le_bytes()); // v ppm
174    buf.extend_from_slice(&0u32.to_le_bytes()); // palette colours
175    buf.extend_from_slice(&0u32.to_le_bytes()); // important colours
176
177    // Pixel data: rows in reverse order (bottom-up), BGRA byte order
178    for row in (0..height as usize).rev() {
179        let row_src = &pixels[row * stride..(row + 1) * stride];
180        for px in row_src.chunks_exact(4) {
181            buf.push(px[2]); // B
182            buf.push(px[1]); // G
183            buf.push(px[0]); // R
184            buf.push(px[3]); // A
185        }
186    }
187    buf
188}
189
190/// Decode BMP bytes (24bpp or 32bpp, uncompressed) into raw RGB/RGBA pixels.
191pub fn bmp_decode(bytes: &[u8]) -> Result<RawDecodeResult, ImageError> {
192    if bytes.len() < 54 || &bytes[0..2] != b"BM" {
193        return Err(ImageError::InvalidMagic);
194    }
195    let pixel_data_offset = u32::from_le_bytes(
196        bytes[10..14]
197            .try_into()
198            .map_err(|_| ImageError::TruncatedInput)?,
199    ) as usize;
200    let width = i32::from_le_bytes(
201        bytes[18..22]
202            .try_into()
203            .map_err(|_| ImageError::TruncatedInput)?,
204    );
205    let height = i32::from_le_bytes(
206        bytes[22..26]
207            .try_into()
208            .map_err(|_| ImageError::TruncatedInput)?,
209    );
210    let bpp = u16::from_le_bytes(
211        bytes[28..30]
212            .try_into()
213            .map_err(|_| ImageError::TruncatedInput)?,
214    );
215    let compression = u32::from_le_bytes(
216        bytes[30..34]
217            .try_into()
218            .map_err(|_| ImageError::TruncatedInput)?,
219    );
220
221    if compression != 0 {
222        return Err(ImageError::UnsupportedCompression);
223    }
224
225    let (abs_height, bottom_up) = if height < 0 {
226        (-height as usize, false)
227    } else {
228        (height as usize, true)
229    };
230    let abs_width = width.unsigned_abs() as usize;
231    let channels = (bpp / 8) as usize;
232
233    if channels != 3 && channels != 4 {
234        return Err(ImageError::DecodeError(format!("Unsupported bpp: {}", bpp)));
235    }
236
237    let row_stride_padded = (abs_width * channels + 3) & !3;
238    let expected_pixel_bytes = row_stride_padded * abs_height;
239
240    let pixel_bytes = bytes
241        .get(pixel_data_offset..)
242        .ok_or(ImageError::TruncatedInput)?;
243
244    if pixel_bytes.len() < expected_pixel_bytes {
245        return Err(ImageError::TruncatedInput);
246    }
247
248    let mut pixels = Vec::with_capacity(abs_width * abs_height * channels);
249    for row_idx in 0..abs_height {
250        let src_row = if bottom_up {
251            abs_height - 1 - row_idx
252        } else {
253            row_idx
254        };
255        let row_start = src_row * row_stride_padded;
256        let row_data = &pixel_bytes[row_start..row_start + abs_width * channels];
257        for px in row_data.chunks_exact(channels) {
258            // BMP stores BGR/BGRA; convert to RGB/RGBA
259            pixels.push(px[2]); // R
260            pixels.push(px[1]); // G
261            pixels.push(px[0]); // B
262            if channels == 4 {
263                pixels.push(px[3]); // A
264            }
265        }
266    }
267
268    Ok(RawDecodeResult {
269        width: abs_width,
270        height: abs_height,
271        pixels,
272    })
273}
274
275/// Encode raw RGB pixels as a PNG file using zune-png.
276pub fn png_encode_rgb(width: usize, height: usize, pixels: &[u8]) -> Result<Vec<u8>, ImageError> {
277    let opts = EncoderOptions::new(
278        width,
279        height,
280        ColorSpace::RGB,
281        zune_core::bit_depth::BitDepth::Eight,
282    );
283    let mut encoder = PngEncoder::new(pixels, opts);
284    let mut out: Vec<u8> = Vec::new();
285    encoder
286        .encode(&mut out)
287        .map_err(|e| ImageError::EncodeError(format!("{:?}", e)))?;
288    Ok(out)
289}
290
291/// Decode PNG bytes into raw pixel data.
292pub fn png_decode(bytes: &[u8]) -> Result<RawDecodeResult, ImageError> {
293    let mut decoder = PngDecoder::new(ZCursor::new(bytes));
294    let raw_pixels = decoder
295        .decode_raw()
296        .map_err(|e| ImageError::DecodeError(e.to_string()))?;
297    let (width, height) = decoder
298        .dimensions()
299        .ok_or_else(|| ImageError::DecodeError("No dimensions after decode".into()))?;
300    Ok(RawDecodeResult {
301        width,
302        height,
303        pixels: raw_pixels,
304    })
305}
306
307#[allow(dead_code)]
308pub fn default_encode_config(fmt: ImageFormat) -> EncodeConfig {
309    EncodeConfig {
310        quality: 90,
311        format: fmt,
312    }
313}
314
315/// Returns encoded bytes for the given image data and config.
316/// BMP and PNG are routed to real implementations when the pixel buffer matches
317/// the expected size. All other formats use a 4-byte stub magic header.
318#[allow(dead_code)]
319pub fn encode_stub(header: &ImageHeader, pixels: &[u8], cfg: &EncodeConfig) -> Vec<u8> {
320    let pixel_count = (header.width as usize) * (header.height as usize);
321    match cfg.format {
322        ImageFormat::Bmp => {
323            let expected_rgba = pixel_count * 4;
324            let expected_rgb = pixel_count * 3;
325            if header.pixel_format == PixelFormat::Rgba8 && pixels.len() == expected_rgba {
326                bmp_encode_rgba(header.width, header.height, pixels)
327            } else if pixels.len() == expected_rgb {
328                bmp_encode_rgb(header.width, header.height, pixels)
329            } else {
330                // Pixel buffer doesn't match; return BMP magic stub
331                vec![
332                    0x42u8,
333                    0x4Du8,
334                    (header.width & 0xFF) as u8,
335                    (header.height & 0xFF) as u8,
336                ]
337            }
338        }
339        ImageFormat::Png => {
340            match png_encode_rgb(header.width as usize, header.height as usize, pixels) {
341                Ok(encoded) => encoded,
342                Err(_) => {
343                    vec![
344                        0x89u8,
345                        0x00,
346                        (header.width & 0xFF) as u8,
347                        (header.height & 0xFF) as u8,
348                    ]
349                }
350            }
351        }
352        _ => {
353            let fmt_byte = match cfg.format {
354                ImageFormat::Jpeg => 0xFFu8,
355                ImageFormat::Tga => 0x00u8,
356                ImageFormat::Hdr => 0x23u8,
357                ImageFormat::Gif => 0x47u8,
358                ImageFormat::Webp => 0x52u8,
359                ImageFormat::Tiff => 0x49u8,
360                _ => 0x00u8,
361            };
362            vec![
363                fmt_byte,
364                0x00,
365                (header.width & 0xFF) as u8,
366                (header.height & 0xFF) as u8,
367            ]
368        }
369    }
370}
371
372/// Returns `None` for empty data, otherwise `Some(DecodeResult)` with a dummy header.
373/// Real BMP and PNG are decoded using their respective implementations.
374#[allow(dead_code)]
375pub fn decode_stub(data: &[u8]) -> Option<DecodeResult> {
376    if data.is_empty() {
377        return None;
378    }
379
380    let fmt = detect_format(data);
381
382    match fmt {
383        ImageFormat::Bmp => {
384            if let Ok(raw) = bmp_decode(data) {
385                let channels = raw.pixels.len() / (raw.width * raw.height).max(1);
386                let pixel_count = raw.width * raw.height;
387                let pf = if channels == 4 {
388                    PixelFormat::Rgba8
389                } else {
390                    PixelFormat::Rgb8
391                };
392                let header = ImageHeader {
393                    width: raw.width as u32,
394                    height: raw.height as u32,
395                    format: ImageFormat::Bmp,
396                    pixel_format: pf,
397                };
398                return Some(DecodeResult {
399                    byte_size: raw.pixels.len(),
400                    pixel_count,
401                    header,
402                });
403            }
404        }
405        ImageFormat::Png => {
406            if let Ok(raw) = png_decode(data) {
407                let pixel_count = raw.width * raw.height;
408                let channels = raw.pixels.len() / pixel_count.max(1);
409                let pf = if channels == 4 {
410                    PixelFormat::Rgba8
411                } else {
412                    PixelFormat::Rgb8
413                };
414                let header = ImageHeader {
415                    width: raw.width as u32,
416                    height: raw.height as u32,
417                    format: ImageFormat::Png,
418                    pixel_format: pf,
419                };
420                return Some(DecodeResult {
421                    byte_size: raw.pixels.len(),
422                    pixel_count,
423                    header,
424                });
425            }
426        }
427        _ => {}
428    }
429
430    // Fallback: return a stub result for unknown / unsupported formats
431    let header = ImageHeader {
432        width: 1,
433        height: 1,
434        format: ImageFormat::Unknown,
435        pixel_format: PixelFormat::Rgba8,
436    };
437    let pixel_count = (header.width * header.height) as usize;
438    let bpp = pixel_format_bytes_per_pixel(&header.pixel_format) as usize;
439    Some(DecodeResult {
440        byte_size: pixel_count * bpp,
441        pixel_count,
442        header,
443    })
444}
445
446#[allow(dead_code)]
447pub fn image_format_name(fmt: &ImageFormat) -> &'static str {
448    match fmt {
449        ImageFormat::Png => "PNG",
450        ImageFormat::Jpeg => "JPEG",
451        ImageFormat::Bmp => "BMP",
452        ImageFormat::Tga => "TGA",
453        ImageFormat::Hdr => "HDR",
454        ImageFormat::Gif => "GIF",
455        ImageFormat::Webp => "WEBP",
456        ImageFormat::Tiff => "TIFF",
457        ImageFormat::Unknown => "Unknown",
458    }
459}
460
461#[allow(dead_code)]
462pub fn pixel_format_bytes_per_pixel(fmt: &PixelFormat) -> u32 {
463    match fmt {
464        PixelFormat::Rgb8 => 3,
465        PixelFormat::Rgba8 => 4,
466        PixelFormat::Grayscale8 => 1,
467        PixelFormat::Rgba16 => 8,
468    }
469}
470
471#[allow(dead_code)]
472pub fn image_byte_size(header: &ImageHeader) -> usize {
473    let pixels = (header.width as usize) * (header.height as usize);
474    let bpp = pixel_format_bytes_per_pixel(&header.pixel_format) as usize;
475    pixels * bpp
476}
477
478#[allow(dead_code)]
479pub fn image_header_to_json(h: &ImageHeader) -> String {
480    format!(
481        "{{\"width\":{},\"height\":{},\"format\":\"{}\",\"pixel_format\":\"{}\"}}",
482        h.width,
483        h.height,
484        image_format_name(&h.format),
485        pixel_format_name(&h.pixel_format),
486    )
487}
488
489fn pixel_format_name(fmt: &PixelFormat) -> &'static str {
490    match fmt {
491        PixelFormat::Rgb8 => "RGB8",
492        PixelFormat::Rgba8 => "RGBA8",
493        PixelFormat::Grayscale8 => "Grayscale8",
494        PixelFormat::Rgba16 => "RGBA16",
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_default_encode_config() {
504        let cfg = default_encode_config(ImageFormat::Png);
505        assert_eq!(cfg.quality, 90);
506        assert_eq!(cfg.format, ImageFormat::Png);
507    }
508
509    #[test]
510    fn test_encode_stub_nonempty() {
511        let header = ImageHeader {
512            width: 4,
513            height: 4,
514            format: ImageFormat::Png,
515            pixel_format: PixelFormat::Rgba8,
516        };
517        let cfg = default_encode_config(ImageFormat::Png);
518        // Pass correctly-sized pixel buffer for PNG (3 channels for RGB)
519        let pixels = vec![128u8; 4 * 4 * 3];
520        let bytes = encode_stub(&header, &pixels, &cfg);
521        assert!(!bytes.is_empty());
522    }
523
524    #[test]
525    fn test_decode_stub_empty_returns_none() {
526        let result = decode_stub(&[]);
527        assert!(result.is_none());
528    }
529
530    #[test]
531    fn test_decode_stub_nonempty_returns_some() {
532        let result = decode_stub(&[0x89, 0x50]);
533        assert!(result.is_some());
534        let dr = result.expect("should succeed");
535        assert!(dr.pixel_count > 0);
536        assert!(dr.byte_size > 0);
537    }
538
539    #[test]
540    fn test_image_format_name() {
541        assert_eq!(image_format_name(&ImageFormat::Png), "PNG");
542        assert_eq!(image_format_name(&ImageFormat::Jpeg), "JPEG");
543        assert_eq!(image_format_name(&ImageFormat::Bmp), "BMP");
544        assert_eq!(image_format_name(&ImageFormat::Tga), "TGA");
545        assert_eq!(image_format_name(&ImageFormat::Hdr), "HDR");
546    }
547
548    #[test]
549    fn test_pixel_format_bytes_per_pixel() {
550        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgb8), 3);
551        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgba8), 4);
552        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Grayscale8), 1);
553        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgba16), 8);
554    }
555
556    #[test]
557    fn test_image_byte_size() {
558        let header = ImageHeader {
559            width: 2,
560            height: 3,
561            format: ImageFormat::Bmp,
562            pixel_format: PixelFormat::Rgb8,
563        };
564        assert_eq!(image_byte_size(&header), 18); // 2*3*3
565    }
566
567    #[test]
568    fn test_image_header_to_json() {
569        let h = ImageHeader {
570            width: 1920,
571            height: 1080,
572            format: ImageFormat::Jpeg,
573            pixel_format: PixelFormat::Rgba8,
574        };
575        let json = image_header_to_json(&h);
576        assert!(json.contains("1920"));
577        assert!(json.contains("JPEG"));
578        assert!(json.contains("RGBA8"));
579    }
580
581    #[test]
582    fn test_encode_different_formats() {
583        let header = ImageHeader {
584            width: 1,
585            height: 1,
586            format: ImageFormat::Tga,
587            pixel_format: PixelFormat::Grayscale8,
588        };
589        let cfg_jpeg = default_encode_config(ImageFormat::Jpeg);
590        let cfg_bmp = default_encode_config(ImageFormat::Bmp);
591        let b1 = encode_stub(&header, &[128], &cfg_jpeg);
592        let b2 = encode_stub(&header, &[128], &cfg_bmp);
593        assert_ne!(b1[0], b2[0]);
594    }
595
596    // --- Slice E: BMP tests ---
597
598    #[test]
599    fn test_bmp_encode_decode_24bit() {
600        let pixels: Vec<u8> = vec![
601            255, 0, 0, 0, 255, 0, // row 0: red, green
602            0, 0, 255, 255, 255, 0, // row 1: blue, yellow
603        ];
604        let encoded = bmp_encode_rgb(2, 2, &pixels);
605        assert!(encoded.starts_with(b"BM"));
606        let decoded = bmp_decode(&encoded).expect("BMP decode");
607        assert_eq!(decoded.width, 2);
608        assert_eq!(decoded.height, 2);
609        assert_eq!(&decoded.pixels[..3], &[255, 0, 0]); // first pixel = red
610    }
611
612    #[test]
613    fn test_bmp_padding_alignment() {
614        // 3x1 RGB: row needs 1 byte padding (3*3=9 → padded to 12)
615        let pixels: Vec<u8> = vec![255, 0, 0, 0, 255, 0, 0, 0, 255];
616        let encoded = bmp_encode_rgb(3, 1, &pixels);
617        // File size = 54 + 12 = 66
618        let file_size = u32::from_le_bytes(encoded[2..6].try_into().expect("file size slice"));
619        assert_eq!(file_size, 66);
620    }
621
622    // --- Slice E: PNG tests ---
623
624    #[test]
625    fn test_png_encode_decode_rgb() {
626        let pixels: Vec<u8> = vec![255, 0, 0, 0, 255, 0]; // 2x1 RGB
627        let encoded = png_encode_rgb(2, 1, &pixels).expect("PNG encode");
628        assert_eq!(
629            &encoded[..8],
630            &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
631        );
632        let decoded = png_decode(&encoded).expect("PNG decode");
633        assert_eq!(decoded.width, 2);
634        assert_eq!(decoded.height, 1);
635    }
636
637    // --- Slice E: format detection tests ---
638
639    #[test]
640    fn test_format_detection_png() {
641        let magic = [0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
642        assert_eq!(detect_format(&magic), ImageFormat::Png);
643    }
644
645    #[test]
646    fn test_format_detection_jpeg() {
647        let magic = [0xFFu8, 0xD8, 0xFF, 0xE0];
648        assert_eq!(detect_format(&magic), ImageFormat::Jpeg);
649    }
650
651    #[test]
652    fn test_format_detection_bmp() {
653        assert_eq!(detect_format(b"BM\x00"), ImageFormat::Bmp);
654    }
655
656    #[test]
657    fn test_format_detection_unknown() {
658        assert_eq!(detect_format(b"\x00\x01\x02\x03"), ImageFormat::Unknown);
659    }
660}