Skip to main content

slimg_core/codec/
jpeg.rs

1use crate::error::{Error, Result};
2use crate::format::Format;
3
4use super::{Codec, EncodeOptions, ImageData};
5
6/// JPEG codec backed by MozJPEG.
7pub struct JpegCodec;
8
9impl Codec for JpegCodec {
10    fn format(&self) -> Format {
11        Format::Jpeg
12    }
13
14    fn decode(&self, data: &[u8]) -> Result<ImageData> {
15        // mozjpeg uses setjmp/longjmp internally, which translates to panics
16        // in Rust. We must catch those to turn them into proper errors.
17        let data = data.to_vec();
18        let result = std::panic::catch_unwind(move || -> Result<ImageData> {
19            let decompress = mozjpeg::Decompress::new_mem(&data)
20                .map_err(|e| Error::Decode(format!("mozjpeg decompress init: {e}")))?;
21
22            let width = decompress.width() as u32;
23            let height = decompress.height() as u32;
24
25            let mut decompressor = decompress
26                .rgba()
27                .map_err(|e| Error::Decode(format!("mozjpeg rgba conversion: {e}")))?;
28
29            let pixels: Vec<[u8; 4]> = decompressor
30                .read_scanlines()
31                .map_err(|e| Error::Decode(format!("mozjpeg read scanlines: {e}")))?;
32
33            decompressor
34                .finish()
35                .map_err(|e| Error::Decode(format!("mozjpeg finish: {e}")))?;
36
37            let rgba_data: Vec<u8> = pixels.into_iter().flatten().collect();
38
39            Ok(ImageData::new(width, height, rgba_data))
40        });
41
42        match result {
43            Ok(inner) => inner,
44            Err(panic) => {
45                let msg = panic_message(&panic);
46                Err(Error::Decode(format!("mozjpeg panicked: {msg}")))
47            }
48        }
49    }
50
51    fn encode(&self, image: &ImageData, options: &EncodeOptions) -> Result<Vec<u8>> {
52        let width = image.width;
53        let height = image.height;
54        let rgb_data = image.to_rgb();
55        let quality = options.quality as f32;
56
57        let result = std::panic::catch_unwind(move || -> Result<Vec<u8>> {
58            let mut compress = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB);
59
60            compress.set_size(width as usize, height as usize);
61            compress.set_quality(quality);
62            compress.set_progressive_mode();
63            compress.set_optimize_scans(true);
64            compress.set_optimize_coding(true);
65
66            let mut compressor = compress
67                .start_compress(Vec::new())
68                .map_err(|e| Error::Encode(format!("mozjpeg compress start: {e}")))?;
69
70            compressor
71                .write_scanlines(&rgb_data)
72                .map_err(|e| Error::Encode(format!("mozjpeg write scanlines: {e}")))?;
73
74            let output = compressor
75                .finish()
76                .map_err(|e| Error::Encode(format!("mozjpeg finish: {e}")))?;
77
78            Ok(output)
79        });
80
81        match result {
82            Ok(inner) => inner,
83            Err(panic) => {
84                let msg = panic_message(&panic);
85                Err(Error::Encode(format!("mozjpeg panicked: {msg}")))
86            }
87        }
88    }
89}
90
91/// Extract a human-readable message from a `catch_unwind` panic payload.
92fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
93    if let Some(s) = panic.downcast_ref::<&str>() {
94        (*s).to_string()
95    } else if let Some(s) = panic.downcast_ref::<String>() {
96        s.clone()
97    } else {
98        "unknown panic".to_string()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn create_test_image(width: u32, height: u32) -> ImageData {
107        let size = (width * height * 4) as usize;
108        let mut data = vec![0u8; size];
109        for y in 0..height {
110            for x in 0..width {
111                let i = ((y * width + x) * 4) as usize;
112                data[i] = (x * 255 / width) as u8; // R
113                data[i + 1] = (y * 255 / height) as u8; // G
114                data[i + 2] = 128; // B
115                data[i + 3] = 255; // A
116            }
117        }
118        ImageData::new(width, height, data)
119    }
120
121    #[test]
122    fn encode_and_decode_roundtrip() {
123        let codec = JpegCodec;
124        let original = create_test_image(64, 48);
125        let options = EncodeOptions { quality: 90 };
126
127        let encoded = codec.encode(&original, &options).expect("encode failed");
128
129        // Verify JPEG magic bytes
130        assert!(
131            encoded.len() >= 3,
132            "encoded data too short: {} bytes",
133            encoded.len()
134        );
135        assert_eq!(
136            &encoded[..3],
137            &[0xFF, 0xD8, 0xFF],
138            "missing JPEG magic bytes"
139        );
140
141        // Decode back and verify dimensions
142        let decoded = codec.decode(&encoded).expect("decode failed");
143        assert_eq!(decoded.width, original.width);
144        assert_eq!(decoded.height, original.height);
145        assert_eq!(
146            decoded.data.len(),
147            (decoded.width * decoded.height * 4) as usize
148        );
149    }
150
151    #[test]
152    fn encode_produces_smaller_at_lower_quality() {
153        let codec = JpegCodec;
154        let image = create_test_image(128, 96);
155
156        let high = codec
157            .encode(&image, &EncodeOptions { quality: 95 })
158            .expect("encode q95 failed");
159        let low = codec
160            .encode(&image, &EncodeOptions { quality: 30 })
161            .expect("encode q30 failed");
162
163        assert!(
164            low.len() < high.len(),
165            "low quality ({} bytes) should be smaller than high quality ({} bytes)",
166            low.len(),
167            high.len(),
168        );
169    }
170
171    #[test]
172    fn decode_invalid_data_returns_error() {
173        let codec = JpegCodec;
174        let result = codec.decode(b"not a jpeg");
175        assert!(result.is_err(), "decoding invalid data should fail");
176    }
177}