bim 0.1.0

A content-first static site generator for blogs, digital gardens, and knowledge bases
Documentation
use crate::config::Config;
use anyhow::Result;
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, ImageFormat};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

const SUPPORTED_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"];

pub fn process_images(config: &Config) -> Result<()> {
    let images_dir = config.source_dir.join("images");
    let output_dir = config.output_dir.join("images");

    if !images_dir.exists() {
        tracing::info!("No images directory found, skipping image processing");
        return Ok(());
    }

    std::fs::create_dir_all(&output_dir)?;

    let image_files: Vec<PathBuf> = WalkDir::new(&images_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_file())
        .filter(|e| {
            e.path()
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
                .unwrap_or(false)
        })
        .map(|e| e.path().to_path_buf())
        .collect();

    tracing::info!("Processing {} images", image_files.len());

    let results: Vec<Result<(), anyhow::Error>> = image_files
        .par_iter()
        .map(|path| {
            let relative = path.strip_prefix(&images_dir).unwrap_or(path);
            let output_path = output_dir.join(relative);

            // Check if we need to process
            if !config.force_image_rebuild && output_path.exists() {
                if let (Ok(src_meta), Ok(dst_meta)) =
                    (std::fs::metadata(path), std::fs::metadata(&output_path))
                {
                    if let (Ok(src_time), Ok(dst_time)) = (src_meta.modified(), dst_meta.modified())
                    {
                        if dst_time >= src_time {
                            tracing::debug!("Skipping unchanged image: {:?}", path);
                            return Ok(());
                        }
                    }
                }
            }

            process_single_image(path, &output_path, config)
        })
        .collect();

    // Report any errors
    for result in results {
        if let Err(e) = result {
            tracing::warn!("Image processing error: {}", e);
        }
    }

    Ok(())
}

fn process_single_image(input: &Path, output: &Path, config: &Config) -> Result<()> {
    tracing::debug!("Processing image: {:?}", input);

    // Create parent directory if needed
    if let Some(parent) = output.parent() {
        std::fs::create_dir_all(parent)?;
    }

    // Load image
    let img = image::open(input)?;

    // Strip EXIF by re-encoding (image crate doesn't preserve EXIF by default)
    let img = strip_exif(img);

    // Resize if needed
    let img = resize_image(img, config.max_image_width, config.max_image_height);

    // Determine output format
    let format = get_output_format(input);

    // Save image
    save_image(&img, output, format, config.image_quality)?;

    Ok(())
}

fn strip_exif(img: DynamicImage) -> DynamicImage {
    // Re-encoding through DynamicImage strips EXIF metadata
    // The image crate doesn't preserve metadata by default
    img
}

fn resize_image(img: DynamicImage, max_width: u32, max_height: u32) -> DynamicImage {
    let (width, height) = img.dimensions();

    if width <= max_width && height <= max_height {
        return img;
    }

    // Calculate new dimensions maintaining aspect ratio
    let ratio_w = max_width as f64 / width as f64;
    let ratio_h = max_height as f64 / height as f64;
    let ratio = ratio_w.min(ratio_h);

    let new_width = (width as f64 * ratio) as u32;
    let new_height = (height as f64 * ratio) as u32;

    img.resize(new_width, new_height, FilterType::Lanczos3)
}

fn get_output_format(path: &Path) -> ImageFormat {
    path.extension()
        .and_then(|ext| ext.to_str())
        .and_then(|ext| match ext.to_lowercase().as_str() {
            "jpg" | "jpeg" => Some(ImageFormat::Jpeg),
            "png" => Some(ImageFormat::Png),
            "gif" => Some(ImageFormat::Gif),
            "webp" => Some(ImageFormat::WebP),
            "bmp" => Some(ImageFormat::Bmp),
            "ico" => Some(ImageFormat::Ico),
            _ => None,
        })
        .unwrap_or(ImageFormat::Jpeg)
}

fn save_image(img: &DynamicImage, path: &Path, format: ImageFormat, quality: u8) -> Result<()> {
    match format {
        ImageFormat::Jpeg => {
            let mut file = std::fs::File::create(path)?;
            let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
            img.write_with_encoder(encoder)?;
        }
        ImageFormat::Png => {
            img.save_with_format(path, ImageFormat::Png)?;
        }
        ImageFormat::WebP => {
            img.save_with_format(path, ImageFormat::WebP)?;
        }
        _ => {
            img.save(path)?;
        }
    }

    Ok(())
}

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

    #[test]
    fn test_get_output_format() {
        assert_eq!(get_output_format(Path::new("test.jpg")), ImageFormat::Jpeg);
        assert_eq!(get_output_format(Path::new("test.png")), ImageFormat::Png);
        assert_eq!(get_output_format(Path::new("test.webp")), ImageFormat::WebP);
    }

    #[test]
    fn test_resize_no_change() {
        let img = DynamicImage::new_rgb8(100, 100);
        let resized = resize_image(img, 200, 200);
        assert_eq!(resized.dimensions(), (100, 100));
    }

    #[test]
    fn test_resize_needed() {
        let img = DynamicImage::new_rgb8(2000, 1000);
        let resized = resize_image(img, 1000, 500);
        let (w, h) = resized.dimensions();
        assert!(w <= 1000);
        assert!(h <= 500);
    }
}