slimg-core 0.6.0

Image optimization library — encode, decode, convert, resize with MozJPEG, OxiPNG, WebP, AVIF, and QOI
use std::borrow::Cow;
use std::fs;
use std::path::{Path, PathBuf};

use crate::codec::{EncodeOptions, ImageData, get_codec};
use crate::crop::{self, CropMode};
use crate::error::{Error, Result};
use crate::extend::{self, ExtendMode, FillColor};
use crate::format::Format;
use crate::resize::{self, ResizeMode};

/// Options for a conversion pipeline.
#[derive(Debug, Clone)]
pub struct PipelineOptions {
    /// Target output format.
    pub format: Format,
    /// Encoding quality (0..=100).
    pub quality: u8,
    /// Optional resize to apply before encoding.
    pub resize: Option<ResizeMode>,
    /// Optional crop to apply before encoding.
    pub crop: Option<CropMode>,
    /// Optional extend (padding) to apply after crop and before resize.
    pub extend: Option<ExtendMode>,
    /// Fill color for the extended region. When `None`, core defaults to
    /// opaque white; note the Kotlin/Python bindings pass an explicit
    /// transparent fill by default instead.
    pub fill_color: Option<FillColor>,
}

/// Result of a pipeline conversion.
#[derive(Debug, Clone)]
pub struct PipelineResult {
    /// Encoded image bytes.
    pub data: Vec<u8>,
    /// Format of the encoded data.
    pub format: Format,
    /// Width of the output image in pixels.
    pub width: u32,
    /// Height of the output image in pixels.
    pub height: u32,
}

impl PipelineResult {
    /// Write the encoded data to a file.
    pub fn save(&self, path: &Path) -> Result<()> {
        fs::write(path, &self.data)?;
        Ok(())
    }
}

/// Detect the format from magic bytes and decode the raw image data.
pub fn decode(data: &[u8]) -> Result<(ImageData, Format)> {
    let format = Format::from_magic_bytes(data)
        .ok_or_else(|| Error::UnknownFormat("unrecognised magic bytes".to_string()))?;

    let codec = get_codec(format);
    let image = codec.decode(data)?;
    Ok((image, format))
}

/// Read a file from disk, detect its format, and decode it.
pub fn decode_file(path: &Path) -> Result<(ImageData, Format)> {
    let data = fs::read(path)?;
    decode(&data)
}

/// Convert an image to the specified format, optionally resizing first.
pub fn convert(image: &ImageData, options: &PipelineOptions) -> Result<PipelineResult> {
    if !options.format.can_encode() {
        return Err(Error::EncodingNotSupported(options.format));
    }

    let image = match &options.crop {
        Some(mode) => Cow::Owned(crop::crop(image, mode)?),
        None => Cow::Borrowed(image),
    };

    let image = match &options.extend {
        Some(mode) => {
            let fill = options
                .fill_color
                .unwrap_or(FillColor::Solid([255, 255, 255, 255]));
            Cow::Owned(extend::extend(&image, mode, &fill)?)
        }
        None => image,
    };

    let image = match &options.resize {
        Some(mode) => Cow::Owned(resize::resize(&image, mode)?),
        None => image,
    };

    let codec = get_codec(options.format);
    let encode_opts = EncodeOptions {
        quality: options.quality,
    };
    let data = codec.encode(&image, &encode_opts)?;

    Ok(PipelineResult {
        data,
        format: options.format,
        width: image.width,
        height: image.height,
    })
}

/// Decode the data and re-encode in the same format at the given quality.
///
/// Note: this is a full decode → re-encode round trip, not a lossless
/// transcode. For lossy formats (JPEG, WebP, AVIF, lossy JXL) each pass
/// introduces additional generation loss, and a high `quality` can produce
/// a *larger* file than the input.
pub fn optimize(data: &[u8], quality: u8) -> Result<PipelineResult> {
    let (image, format) = decode(data)?;

    if !format.can_encode() {
        return Err(Error::EncodingNotSupported(format));
    }

    let codec = get_codec(format);
    let encode_opts = EncodeOptions { quality };
    let encoded = codec.encode(&image, &encode_opts)?;

    Ok(PipelineResult {
        data: encoded,
        format,
        width: image.width,
        height: image.height,
    })
}

/// Derive an output path for the converted image.
///
/// - If `output` is `None`, uses the input directory with the new extension.
/// - If `output` is a directory, places the file there with the new extension.
/// - Otherwise, uses `output` as-is.
pub fn output_path(input: &Path, format: Format, output: Option<&Path>) -> PathBuf {
    let new_ext = format.extension();

    match output {
        None => input.with_extension(new_ext),
        Some(out) if out.is_dir() => {
            let stem = input.file_stem().unwrap_or_default();
            out.join(stem).with_extension(new_ext)
        }
        Some(out) => out.to_path_buf(),
    }
}

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

    #[test]
    fn output_path_changes_extension() {
        let result = output_path(Path::new("/tmp/photo.jpg"), Format::WebP, None);
        assert_eq!(result, PathBuf::from("/tmp/photo.webp"));
    }

    #[test]
    fn output_path_with_explicit_output() {
        let result = output_path(
            Path::new("/tmp/photo.jpg"),
            Format::Png,
            Some(Path::new("/out/result.png")),
        );
        assert_eq!(result, PathBuf::from("/out/result.png"));
    }

    #[test]
    fn optimize_reencodes_same_format() {
        let image = ImageData::new(4, 4, vec![200u8; 64]);
        let options = PipelineOptions {
            format: Format::Qoi,
            quality: 80,
            resize: None,
            crop: None,
            extend: None,
            fill_color: None,
        };
        let encoded = convert(&image, &options).unwrap();

        let result = optimize(&encoded.data, 80).unwrap();
        assert_eq!(result.format, Format::Qoi);
        assert_eq!(result.width, 4);
        assert_eq!(result.height, 4);
        assert!(!result.data.is_empty());
    }

    #[test]
    fn optimize_unknown_format_returns_error() {
        assert!(optimize(&[0u8; 16], 80).is_err());
    }

    #[test]
    fn pipeline_result_save_writes_file() {
        let dir = std::env::temp_dir().join("slimg-pipeline-save-test");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("out.bin");

        let result = PipelineResult {
            data: vec![1, 2, 3],
            format: Format::Qoi,
            width: 1,
            height: 1,
        };
        result.save(&path).unwrap();

        assert_eq!(std::fs::read(&path).unwrap(), vec![1, 2, 3]);
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn jxl_encode_succeeds() {
        let image = ImageData::new(2, 2, vec![128u8; 16]);
        let options = PipelineOptions {
            format: Format::Jxl,
            quality: 80,
            resize: None,
            crop: None,
            extend: None,
            fill_color: None,
        };
        let result = convert(&image, &options);
        assert!(result.is_ok(), "converting to JXL should succeed");
    }
}