aer 0.2.1

A command-line toolkit for creatives.
Documentation
use std::io::Cursor;

use image::ImageFormat;

use super::{Asset, Environment, LayeredContext, MediaCategory, ProcessesAssets, ProcessingError};

/// Resizes images to fit within a given width and height,
/// preserving the image's original aspect ratio.
///
/// If the image is already within the given width and height,
/// this processor does nothing.
///
/// This processor uses a [Lanczos](https://mazzo.li/posts/lanczos.html)
/// filter when resizing images. This filter is one of the slowest, but
/// produces consistently high-quality results, making it best suited
/// for processing _static_ content.
pub struct ImageResizeProcessor {
    /// The maximum width of the resized image.
    width: u32,

    /// The maximum height of the resized image.
    height: u32,
}

impl ImageResizeProcessor {
    pub fn new(width: u32, height: u32) -> Self {
        Self { width, height }
    }
}

impl ProcessesAssets for ImageResizeProcessor {
    fn process(
        &self,
        _env: &Environment,
        _context: &LayeredContext,
        asset: &mut Asset,
    ) -> Result<bool, ProcessingError> {
        // Skip assets that aren't images.
        if asset.media_type().category() != MediaCategory::Image {
            return Ok(false);
        }

        // Extract image bytes.
        let asset_path = asset.path().clone();
        let image_format = ImageFormat::from_path(asset_path.as_str()).map_err(|e| {
            ProcessingError::Malformed {
                message: e.to_string().into(),
            }
        })?;
        let image_bytes = asset.as_mut_bytes()?;
        let image =
            image::load_from_memory(image_bytes).map_err(|e| ProcessingError::Malformed {
                message: e.to_string().into(),
            })?;

        // Skip resizing if the image is already inside the bounding box.
        if image.width() <= self.width && image.height() <= self.height {
            return Ok(false);
        }

        tracing::trace!("image: {}", asset_path);

        // Resize the image to fit the bounding box.
        let image = image.resize(
            self.width,
            self.height,
            image::imageops::FilterType::Lanczos3,
        );

        // Write resized image.
        image_bytes.clear();
        let mut cursor = Cursor::new(image_bytes);
        image
            .write_to(&mut cursor, image_format)
            .map_err(|e| ProcessingError::Malformed {
                message: e.to_string().into(),
            })?;

        Ok(true)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::proc::LayeredContext;

    #[test_log::test]
    #[test_log(default_log_filter = "debug")]
    fn resizes_image() {
        let _ = tracing_subscriber::fmt()
            .with_max_level(tracing::Level::DEBUG)
            .with_test_writer()
            .try_init();

        let source_bytes = std::fs::read("test/example.png").unwrap();

        // Wrap the source image in an asset.
        let mut asset = Asset::new("test/example.png".into(), source_bytes.clone());

        // Resize the image.
        let (width, height) = (300, 300);
        ImageResizeProcessor { width, height }
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();

        // Check the dimensions of the resized image.
        let resized_image = image::load_from_memory(asset.as_bytes()).unwrap();
        assert_eq!(width, resized_image.width());
        assert_eq!(243, resized_image.height());
    }

    #[test]
    fn skips_image_already_within_bounds() {
        let source_bytes = std::fs::read("test/example.png").unwrap();
        let original_bytes = source_bytes.clone();

        let mut asset = Asset::new("photo.png".into(), source_bytes);

        // Use dimensions larger than the test image.
        let modified = ImageResizeProcessor::new(9999, 9999)
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();

        assert!(!modified);
        assert_eq!(asset.as_bytes(), &original_bytes);
    }

    #[test]
    fn skips_non_image_assets() {
        let mut asset = Asset::new("style.css".into(), b"body {}".to_vec());
        let modified = ImageResizeProcessor::new(300, 300)
            .process(
                &Environment::test(),
                &LayeredContext::from_flat(Default::default()),
                &mut asset,
            )
            .unwrap();
        assert!(!modified);
    }
}