Skip to main content

bnto_encode/
encode.rs

1// Shared image encoding — single entrypoint for all image processors.
2//
3// All processors that produce raster output route through this module
4// for final encoding. This prevents duplicated encode functions and ensures
5// quality/compression params are consistently applied across all formats.
6
7use std::io::Cursor;
8
9use bnto_core::errors::BntoError;
10use image::DynamicImage;
11use image::codecs::jpeg::JpegEncoder;
12use image::codecs::png::{CompressionType, FilterType, PngEncoder};
13
14use crate::format::ImageFormat;
15
16/// Encode a DynamicImage into the target format with quality control.
17///
18/// - **JPEG**: `quality` controls lossy encoding (1 = smallest, 100 = best).
19/// - **PNG**: `quality` maps to compression effort (lossless format, so quality
20///   affects speed/size tradeoff, not visual fidelity). <33 = Fast, <66 = Default, >=66 = Best.
21/// - **WebP**: Lossless only (image crate limitation). Quality param is accepted
22///   but has no effect until lossy WebP support is added via jSquash.
23pub fn encode_image(
24    img: &DynamicImage,
25    format: ImageFormat,
26    quality: u8,
27) -> Result<Vec<u8>, BntoError> {
28    match format {
29        ImageFormat::Jpeg => encode_jpeg(img, quality),
30        ImageFormat::Png => encode_png(img, quality),
31        ImageFormat::WebP => encode_webp(img),
32    }
33}
34
35fn encode_jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, BntoError> {
36    let mut output = Vec::new();
37    let encoder = JpegEncoder::new_with_quality(&mut output, quality);
38    img.write_with_encoder(encoder)
39        .map_err(|e| BntoError::ProcessingFailed(format!("Failed to encode JPEG: {e}")))?;
40    Ok(output)
41}
42
43fn encode_png(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, BntoError> {
44    let compression = quality_to_png_compression(quality);
45    let mut output = Vec::new();
46    let encoder = PngEncoder::new_with_quality(&mut output, compression, FilterType::Adaptive);
47    img.write_with_encoder(encoder)
48        .map_err(|e| BntoError::ProcessingFailed(format!("Failed to encode PNG: {e}")))?;
49    Ok(output)
50}
51
52fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>, BntoError> {
53    let mut output = Vec::new();
54    let mut cursor_out = Cursor::new(&mut output);
55    img.write_to(&mut cursor_out, image::ImageFormat::WebP)
56        .map_err(|e| BntoError::ProcessingFailed(format!("Failed to encode WebP: {e}")))?;
57    Ok(output)
58}
59
60/// Map a quality value (1-100) to PNG compression level.
61/// PNG is lossless — quality affects encode speed vs file size, not visual fidelity.
62fn quality_to_png_compression(quality: u8) -> CompressionType {
63    if quality < 33 {
64        CompressionType::Fast
65    } else if quality < 66 {
66        CompressionType::Default
67    } else {
68        CompressionType::Best
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use image::{DynamicImage, Rgb, RgbImage};
76
77    fn create_test_image(width: u32, height: u32) -> DynamicImage {
78        let mut img = RgbImage::new(width, height);
79        for y in 0..height {
80            for x in 0..width {
81                img.put_pixel(x, y, Rgb([(x * 3) as u8, (y * 5) as u8, 128]));
82            }
83        }
84        DynamicImage::ImageRgb8(img)
85    }
86
87    #[test]
88    fn test_encode_jpeg_produces_valid_output() {
89        let img = create_test_image(50, 50);
90        let result = encode_image(&img, ImageFormat::Jpeg, 80).unwrap();
91        assert_eq!(result[0], 0xFF);
92        assert_eq!(result[1], 0xD8);
93    }
94
95    #[test]
96    fn test_encode_png_produces_valid_output() {
97        let img = create_test_image(50, 50);
98        let result = encode_image(&img, ImageFormat::Png, 80).unwrap();
99        assert_eq!(&result[..4], &[0x89, 0x50, 0x4E, 0x47]);
100    }
101
102    #[test]
103    fn test_encode_webp_produces_valid_output() {
104        let img = create_test_image(50, 50);
105        let result = encode_image(&img, ImageFormat::WebP, 80).unwrap();
106        assert_eq!(&result[..4], b"RIFF");
107        assert_eq!(&result[8..12], b"WEBP");
108    }
109
110    #[test]
111    fn test_jpeg_quality_affects_size() {
112        let img = create_test_image(100, 100);
113        let low = encode_image(&img, ImageFormat::Jpeg, 20).unwrap();
114        let high = encode_image(&img, ImageFormat::Jpeg, 90).unwrap();
115        assert!(
116            low.len() < high.len(),
117            "Low quality ({}) should be smaller than high quality ({})",
118            low.len(),
119            high.len()
120        );
121    }
122
123    #[test]
124    fn test_quality_to_png_compression_mapping() {
125        assert!(matches!(
126            quality_to_png_compression(1),
127            CompressionType::Fast
128        ));
129        assert!(matches!(
130            quality_to_png_compression(32),
131            CompressionType::Fast
132        ));
133        assert!(matches!(
134            quality_to_png_compression(33),
135            CompressionType::Default
136        ));
137        assert!(matches!(
138            quality_to_png_compression(65),
139            CompressionType::Default
140        ));
141        assert!(matches!(
142            quality_to_png_compression(66),
143            CompressionType::Best
144        ));
145        assert!(matches!(
146            quality_to_png_compression(100),
147            CompressionType::Best
148        ));
149    }
150}