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 use hand-coded implementations; JPEG, GIF, WebP, TIFF delegate to
317/// their respective codec modules. TGA and HDR return a 4-byte magic-stub header
318/// (those formats are not yet implemented).
319#[allow(dead_code)]
320pub fn encode_stub(header: &ImageHeader, pixels: &[u8], cfg: &EncodeConfig) -> Vec<u8> {
321    let pixel_count = (header.width as usize) * (header.height as usize);
322    match cfg.format {
323        ImageFormat::Bmp => {
324            let expected_rgba = pixel_count * 4;
325            let expected_rgb = pixel_count * 3;
326            if header.pixel_format == PixelFormat::Rgba8 && pixels.len() == expected_rgba {
327                bmp_encode_rgba(header.width, header.height, pixels)
328            } else if pixels.len() == expected_rgb {
329                bmp_encode_rgb(header.width, header.height, pixels)
330            } else {
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(_) => vec![
343                    0x89u8,
344                    0x00,
345                    (header.width & 0xFF) as u8,
346                    (header.height & 0xFF) as u8,
347                ],
348            }
349        }
350        ImageFormat::Jpeg => {
351            let quality = cfg.quality.clamp(1, 100);
352            match super::image_jpeg::jpeg_encode_rgb(header.width, header.height, pixels, quality) {
353                Ok(encoded) => encoded,
354                Err(_) => vec![
355                    0xFFu8,
356                    0xD8,
357                    (header.width & 0xFF) as u8,
358                    (header.height & 0xFF) as u8,
359                ],
360            }
361        }
362        ImageFormat::Gif => {
363            match super::image_gif::gif_encode_rgb(header.width, header.height, pixels) {
364                Ok(encoded) => encoded,
365                Err(_) => vec![
366                    0x47u8,
367                    0x49,
368                    (header.width & 0xFF) as u8,
369                    (header.height & 0xFF) as u8,
370                ],
371            }
372        }
373        ImageFormat::Webp => {
374            match super::image_webp::webp_encode_rgb(header.width, header.height, pixels) {
375                Ok(encoded) => encoded,
376                Err(_) => vec![
377                    0x52u8,
378                    0x49,
379                    (header.width & 0xFF) as u8,
380                    (header.height & 0xFF) as u8,
381                ],
382            }
383        }
384        ImageFormat::Tiff => {
385            match super::image_tiff::tiff_encode_rgb(header.width, header.height, pixels) {
386                Ok(encoded) => encoded,
387                Err(_) => vec![
388                    0x49u8,
389                    0x49,
390                    (header.width & 0xFF) as u8,
391                    (header.height & 0xFF) as u8,
392                ],
393            }
394        }
395        _ => {
396            let fmt_byte = match cfg.format {
397                ImageFormat::Tga => 0x00u8,
398                ImageFormat::Hdr => 0x23u8,
399                _ => 0x00u8,
400            };
401            vec![
402                fmt_byte,
403                0x00,
404                (header.width & 0xFF) as u8,
405                (header.height & 0xFF) as u8,
406            ]
407        }
408    }
409}
410
411/// Returns `None` for empty data, otherwise `Some(DecodeResult)` wrapping decoded metadata.
412/// BMP, PNG, JPEG, GIF, WebP, and TIFF are decoded using their respective implementations.
413/// TGA and HDR fall back to a 1×1 placeholder result.
414#[allow(dead_code)]
415pub fn decode_stub(data: &[u8]) -> Option<DecodeResult> {
416    if data.is_empty() {
417        return None;
418    }
419
420    let fmt = detect_format(data);
421
422    let raw_result: Option<(RawDecodeResult, ImageFormat)> = match fmt {
423        ImageFormat::Bmp => bmp_decode(data).ok().map(|r| (r, ImageFormat::Bmp)),
424        ImageFormat::Png => png_decode(data).ok().map(|r| (r, ImageFormat::Png)),
425        ImageFormat::Jpeg => super::image_jpeg::jpeg_decode(data)
426            .ok()
427            .map(|r| (r, ImageFormat::Jpeg)),
428        ImageFormat::Gif => super::image_gif::gif_decode(data)
429            .ok()
430            .map(|r| (r, ImageFormat::Gif)),
431        ImageFormat::Webp => super::image_webp::webp_decode(data)
432            .ok()
433            .map(|r| (r, ImageFormat::Webp)),
434        ImageFormat::Tiff => super::image_tiff::tiff_decode(data)
435            .ok()
436            .map(|r| (r, ImageFormat::Tiff)),
437        _ => None,
438    };
439
440    if let Some((raw, img_fmt)) = raw_result {
441        let pixel_count = raw.width * raw.height;
442        let channels = raw.pixels.len().checked_div(pixel_count).unwrap_or(3);
443        let pf = if channels == 4 {
444            PixelFormat::Rgba8
445        } else {
446            PixelFormat::Rgb8
447        };
448        let header = ImageHeader {
449            width: raw.width as u32,
450            height: raw.height as u32,
451            format: img_fmt,
452            pixel_format: pf,
453        };
454        return Some(DecodeResult {
455            byte_size: raw.pixels.len(),
456            pixel_count,
457            header,
458        });
459    }
460
461    // Fallback for unsupported formats (TGA, HDR, Unknown)
462    let header = ImageHeader {
463        width: 1,
464        height: 1,
465        format: ImageFormat::Unknown,
466        pixel_format: PixelFormat::Rgba8,
467    };
468    let pixel_count = (header.width * header.height) as usize;
469    let bpp = pixel_format_bytes_per_pixel(&header.pixel_format) as usize;
470    Some(DecodeResult {
471        byte_size: pixel_count * bpp,
472        pixel_count,
473        header,
474    })
475}
476
477#[allow(dead_code)]
478pub fn image_format_name(fmt: &ImageFormat) -> &'static str {
479    match fmt {
480        ImageFormat::Png => "PNG",
481        ImageFormat::Jpeg => "JPEG",
482        ImageFormat::Bmp => "BMP",
483        ImageFormat::Tga => "TGA",
484        ImageFormat::Hdr => "HDR",
485        ImageFormat::Gif => "GIF",
486        ImageFormat::Webp => "WEBP",
487        ImageFormat::Tiff => "TIFF",
488        ImageFormat::Unknown => "Unknown",
489    }
490}
491
492#[allow(dead_code)]
493pub fn pixel_format_bytes_per_pixel(fmt: &PixelFormat) -> u32 {
494    match fmt {
495        PixelFormat::Rgb8 => 3,
496        PixelFormat::Rgba8 => 4,
497        PixelFormat::Grayscale8 => 1,
498        PixelFormat::Rgba16 => 8,
499    }
500}
501
502#[allow(dead_code)]
503pub fn image_byte_size(header: &ImageHeader) -> usize {
504    let pixels = (header.width as usize) * (header.height as usize);
505    let bpp = pixel_format_bytes_per_pixel(&header.pixel_format) as usize;
506    pixels * bpp
507}
508
509#[allow(dead_code)]
510pub fn image_header_to_json(h: &ImageHeader) -> String {
511    format!(
512        "{{\"width\":{},\"height\":{},\"format\":\"{}\",\"pixel_format\":\"{}\"}}",
513        h.width,
514        h.height,
515        image_format_name(&h.format),
516        pixel_format_name(&h.pixel_format),
517    )
518}
519
520fn pixel_format_name(fmt: &PixelFormat) -> &'static str {
521    match fmt {
522        PixelFormat::Rgb8 => "RGB8",
523        PixelFormat::Rgba8 => "RGBA8",
524        PixelFormat::Grayscale8 => "Grayscale8",
525        PixelFormat::Rgba16 => "RGBA16",
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_default_encode_config() {
535        let cfg = default_encode_config(ImageFormat::Png);
536        assert_eq!(cfg.quality, 90);
537        assert_eq!(cfg.format, ImageFormat::Png);
538    }
539
540    #[test]
541    fn test_encode_stub_nonempty() {
542        let header = ImageHeader {
543            width: 4,
544            height: 4,
545            format: ImageFormat::Png,
546            pixel_format: PixelFormat::Rgba8,
547        };
548        let cfg = default_encode_config(ImageFormat::Png);
549        // Pass correctly-sized pixel buffer for PNG (3 channels for RGB)
550        let pixels = vec![128u8; 4 * 4 * 3];
551        let bytes = encode_stub(&header, &pixels, &cfg);
552        assert!(!bytes.is_empty());
553    }
554
555    #[test]
556    fn test_decode_stub_empty_returns_none() {
557        let result = decode_stub(&[]);
558        assert!(result.is_none());
559    }
560
561    #[test]
562    fn test_decode_stub_nonempty_returns_some() {
563        let result = decode_stub(&[0x89, 0x50]);
564        assert!(result.is_some());
565        let dr = result.expect("should succeed");
566        assert!(dr.pixel_count > 0);
567        assert!(dr.byte_size > 0);
568    }
569
570    #[test]
571    fn test_image_format_name() {
572        assert_eq!(image_format_name(&ImageFormat::Png), "PNG");
573        assert_eq!(image_format_name(&ImageFormat::Jpeg), "JPEG");
574        assert_eq!(image_format_name(&ImageFormat::Bmp), "BMP");
575        assert_eq!(image_format_name(&ImageFormat::Tga), "TGA");
576        assert_eq!(image_format_name(&ImageFormat::Hdr), "HDR");
577    }
578
579    #[test]
580    fn test_pixel_format_bytes_per_pixel() {
581        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgb8), 3);
582        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgba8), 4);
583        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Grayscale8), 1);
584        assert_eq!(pixel_format_bytes_per_pixel(&PixelFormat::Rgba16), 8);
585    }
586
587    #[test]
588    fn test_image_byte_size() {
589        let header = ImageHeader {
590            width: 2,
591            height: 3,
592            format: ImageFormat::Bmp,
593            pixel_format: PixelFormat::Rgb8,
594        };
595        assert_eq!(image_byte_size(&header), 18); // 2*3*3
596    }
597
598    #[test]
599    fn test_image_header_to_json() {
600        let h = ImageHeader {
601            width: 1920,
602            height: 1080,
603            format: ImageFormat::Jpeg,
604            pixel_format: PixelFormat::Rgba8,
605        };
606        let json = image_header_to_json(&h);
607        assert!(json.contains("1920"));
608        assert!(json.contains("JPEG"));
609        assert!(json.contains("RGBA8"));
610    }
611
612    #[test]
613    fn test_encode_different_formats() {
614        let header = ImageHeader {
615            width: 1,
616            height: 1,
617            format: ImageFormat::Tga,
618            pixel_format: PixelFormat::Grayscale8,
619        };
620        let cfg_jpeg = default_encode_config(ImageFormat::Jpeg);
621        let cfg_bmp = default_encode_config(ImageFormat::Bmp);
622        let b1 = encode_stub(&header, &[128], &cfg_jpeg);
623        let b2 = encode_stub(&header, &[128], &cfg_bmp);
624        assert_ne!(b1[0], b2[0]);
625    }
626
627    // --- Slice E: BMP tests ---
628
629    #[test]
630    fn test_bmp_encode_decode_24bit() {
631        let pixels: Vec<u8> = vec![
632            255, 0, 0, 0, 255, 0, // row 0: red, green
633            0, 0, 255, 255, 255, 0, // row 1: blue, yellow
634        ];
635        let encoded = bmp_encode_rgb(2, 2, &pixels);
636        assert!(encoded.starts_with(b"BM"));
637        let decoded = bmp_decode(&encoded).expect("BMP decode");
638        assert_eq!(decoded.width, 2);
639        assert_eq!(decoded.height, 2);
640        assert_eq!(&decoded.pixels[..3], &[255, 0, 0]); // first pixel = red
641    }
642
643    #[test]
644    fn test_bmp_padding_alignment() {
645        // 3x1 RGB: row needs 1 byte padding (3*3=9 → padded to 12)
646        let pixels: Vec<u8> = vec![255, 0, 0, 0, 255, 0, 0, 0, 255];
647        let encoded = bmp_encode_rgb(3, 1, &pixels);
648        // File size = 54 + 12 = 66
649        let file_size = u32::from_le_bytes(encoded[2..6].try_into().expect("file size slice"));
650        assert_eq!(file_size, 66);
651    }
652
653    // --- Slice E: PNG tests ---
654
655    #[test]
656    fn test_png_encode_decode_rgb() {
657        let pixels: Vec<u8> = vec![255, 0, 0, 0, 255, 0]; // 2x1 RGB
658        let encoded = png_encode_rgb(2, 1, &pixels).expect("PNG encode");
659        assert_eq!(
660            &encoded[..8],
661            &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
662        );
663        let decoded = png_decode(&encoded).expect("PNG decode");
664        assert_eq!(decoded.width, 2);
665        assert_eq!(decoded.height, 1);
666    }
667
668    // --- Slice E: format detection tests ---
669
670    #[test]
671    fn test_format_detection_png() {
672        let magic = [0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
673        assert_eq!(detect_format(&magic), ImageFormat::Png);
674    }
675
676    #[test]
677    fn test_format_detection_jpeg() {
678        let magic = [0xFFu8, 0xD8, 0xFF, 0xE0];
679        assert_eq!(detect_format(&magic), ImageFormat::Jpeg);
680    }
681
682    #[test]
683    fn test_format_detection_bmp() {
684        assert_eq!(detect_format(b"BM\x00"), ImageFormat::Bmp);
685    }
686
687    #[test]
688    fn test_format_detection_unknown() {
689        assert_eq!(detect_format(b"\x00\x01\x02\x03"), ImageFormat::Unknown);
690    }
691}