diff --git a/Cargo.lock b/Cargo.lock index 15f1397..74c8754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,12 +84,6 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[package]] -name = "bitflags" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" - [[package]] name = "bitstream-io" version = "2.6.0" @@ -128,9 +122,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "cc" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "jobserver", "libc", @@ -211,16 +205,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" -[[package]] -name = "errno" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "exr" version = "1.73.0" @@ -236,12 +220,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "fastrand" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" - [[package]] name = "fdeflate" version = "0.3.6" @@ -253,9 +231,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.34" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", @@ -410,12 +388,6 @@ dependencies = [ "cc", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" - [[package]] name = "log" version = "0.4.22" @@ -559,7 +531,7 @@ version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -733,19 +705,6 @@ version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" -[[package]] -name = "rustix" -version = "0.38.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" -dependencies = [ - "bitflags 2.6.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - [[package]] name = "same-file" version = "1.0.6" @@ -813,12 +772,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "spriterator" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.3" dependencies = [ "image", - "once_cell", - "rayon", - "tempfile", "walkdir", ] @@ -852,19 +808,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "tempfile" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" -dependencies = [ - "cfg-if", - "fastrand", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1036,16 +979,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", + "windows-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a7148d..db0ca3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spriterator" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.3" edition = "2021" authors = ["Dmitrii Korchemkin "] description = "Generates sprite sheets from images in the specified directory." @@ -13,9 +13,4 @@ readme = "README.md" [dependencies] image = "0.25.5" -once_cell = "1.20.2" -rayon = "1.10.0" walkdir = "2.5.0" - -[dev-dependencies] -tempfile = "3.14.0" diff --git a/README.md b/README.md index 620ce80..a9d5190 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,47 @@ + # Spriterator [![build](https://github.com/krchmkn/spriterator/actions/workflows/build.yml/badge.svg)](https://github.com/krchmkn/spriterator/actions/workflows/build.yml) -[Spriterator](https://crates.io/crates/spriterator) is a Rust library that generates compact sprite sheets from images in a specified directory. It arranges images row by row to minimize empty space and avoid gaps, even if the images are of different sizes. If the images exceed a specified maximum height, the library will create multiple sprite sheets. +[Spriterator](https://crates.io/crates/spriterator) is a Rust library that creates sprite sheets by combining multiple images from a specified directory into a compact format. -This library supports common image formats such as PNG, JPEG, GIF, and WebP, and it can use optional parallel processing (via `rayon`) for efficient image loading. +## Example -## Features +The following example demonstrates how to use `Spriterator` to create sprite sheets from images in a directory. -- **Recursive Directory Scanning**: Finds all images within nested directories. -- **Compact Layout with No Spacing**: Arranges images tightly in rows without gaps, regardless of size. -- **Multiple Sheets if Necessary**: Generates multiple sprite sheets when images exceed specified height limits. -- **Optional Parallel Processing**: Speeds up loading and processing of images (requires `rayon` feature). -- **Supported Formats**: Handles common image formats (`png`, `jpg`, `jpeg`, `gif`, `bmp`, `ico`, `tiff`, `webp`). +```rust +use spriterator::Spriterator; +use std::fs; +use std::path::Path; -## Usage +fn prepare_directory(path: &str) -> std::io::Result<()> { + let dir_path = Path::new(path); -Here is an example of using `Spriterator` to generate sprite sheets: + if dir_path.exists() { + fs::remove_dir_all(dir_path)?; + } -```rust -use spriterator::Spriterator; + fs::create_dir_all(dir_path)?; + + Ok(()) +} fn main() -> Result<(), Box> { - let spriterator = Spriterator::new("path/to/images", 1024, 2048); + let ext = "png"; + let output_dir = format!("/parth/to/sprites/{}", ext); + + prepare_directory(output_dir.as_str())?; + + let size = 1024; + let spriterator = Spriterator::new( + format!("/parth/to/images/{}", ext).as_str(), + size, + size, + ); let sprites = spriterator.generate()?; - // Save each generated sprite sheet for (index, sprite) in sprites.iter().enumerate() { - sprite.save(format!("sprite_sheet_{}.webp", index))?; + sprite.save(format!("{}/{}.{}", output_dir, index, ext))?; } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 0b75530..53b44aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,13 @@ -use image::{GenericImage, RgbaImage}; -use once_cell::sync::Lazy; -use rayon::prelude::*; -use std::collections::HashSet; -use std::path::Path; +use image::imageops::crop_imm; +use image::{DynamicImage, GenericImage, RgbaImage}; +use std::error::Error; use walkdir::WalkDir; -/// A static set of supported image file extensions for quick lookup. -static IMAGE_EXTENSIONS: Lazy> = Lazy::new(|| { - let exts = ["png", "jpg", "jpeg", "gif", "bmp", "ico", "tiff", "webp"]; - exts.iter().cloned().collect() -}); +/// Supported file extensions for image files. +const SUPPORTED_EXTENSIONS: [&str; 2] = ["png", "webp"]; -/// `Spriterator` is a struct that provides functionality to generate compact sprite sheets from images. -/// It allows specifying maximum width and height, creating multiple sheets if necessary. +/// A struct that generates sprite sheets by arranging images in a grid layout +/// and trimming transparent areas from each sprite. pub struct Spriterator { dir_path: String, max_width: u32, @@ -25,8 +20,8 @@ impl Spriterator { /// # Arguments /// /// * `dir_path` - Path to the directory containing images. - /// * `max_width` - Maximum width of each sprite sheet in pixels. - /// * `max_height` - Maximum height of each sprite sheet in pixels. + /// * `max_width` - Maximum width for each sprite. + /// * `max_height` - Maximum height for each sprite. pub fn new(dir_path: &str, max_width: u32, max_height: u32) -> Self { Self { dir_path: dir_path.to_string(), @@ -35,66 +30,170 @@ impl Spriterator { } } - /// Generates multiple sprite sheets from images in the specified directory with no spacing. - /// - /// This method arranges images row by row, minimizing empty space. If images exceed the specified - /// maximum height, a new sprite sheet is created. Returns a vector of `RgbaImage` sprites. + /// Generates a vector of sprites by arranging images in rows, respecting the specified maximum + /// width and height. Each sprite is trimmed to remove transparent areas. /// /// # Returns /// - /// * `Ok(Vec)` containing the generated sprite sheets. - /// * `Err` if no images are found or an error occurs during processing. - pub fn generate(&self) -> Result, Box> { - let images: Vec = WalkDir::new(&self.dir_path) - .into_iter() - .par_bridge() - .filter_map(|entry| entry.ok()) - .filter(|entry| entry.path().is_file() && is_image(entry.path())) - .filter_map(|entry| image::open(entry.path()).ok().map(|img| img.to_rgba8())) - .collect(); - - if images.is_empty() { - return Err("No images found in the specified directory.".into()); - } + /// A `Result` containing a vector of `RgbaImage` objects on success, or an error if image + /// loading or processing fails. + pub fn generate(&self) -> Result, Box> { + let images = self.get_images()?; let mut sprites = Vec::new(); let mut current_sprite = RgbaImage::new(self.max_width, self.max_height); - let mut current_x = 0; - let mut current_y = 0; - let mut row_height = 0; + let (mut current_x, mut current_y, mut row_height) = (0, 0, 0); for img in &images { + // Move to the next row if the current image exceeds max width if current_x + img.width() > self.max_width { - // Start a new row if the image does not fit in the current row current_y += row_height; current_x = 0; row_height = 0; } + // Start a new sprite if the current image exceeds max height if current_y + img.height() > self.max_height { - // Save the current sprite and start a new one if the image does not fit in the current sprite - sprites.push(current_sprite); + let trimmed_sprite = self.trim_transparent(¤t_sprite); + sprites.push(trimmed_sprite); + current_sprite = RgbaImage::new(self.max_width, self.max_height); + current_x = 0; current_y = 0; + row_height = 0; } - // Copy the image into the current sprite current_sprite.copy_from(img, current_x, current_y)?; row_height = row_height.max(img.height()); current_x += img.width(); } - // Add the last sprite to the vector if it's not empty - sprites.push(current_sprite); + let trimmed_sprite = self.trim_transparent(¤t_sprite); + sprites.push(trimmed_sprite); Ok(sprites) } + + /// Retrieves images from the specified directory that match the supported file extensions + /// and checks their dimensions. + /// + /// # Returns + /// + /// A `Result` containing a vector of `DynamicImage` objects on success, or an error if + /// no valid images are found or loading fails. + fn get_images(&self) -> Result, Box> { + let images: Vec = WalkDir::new(&self.dir_path) + .into_iter() + .filter_map(|entry| { + let path = entry.ok()?.path().to_path_buf(); + + let is_image = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| SUPPORTED_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false); + + if path.is_file() && is_image { + let img = image::open(&path) + .map_err(|e| { + eprintln!("Error opening image {}: {}", path.display(), e); + e + }) + .ok()?; + + if img.width() > self.max_width || img.height() > self.max_height { + eprintln!( + "Error: Image {} dimensions {}x{} exceed max dimensions {}x{}.", + path.display(), + img.width(), + img.height(), + self.max_width, + self.max_height + ); + return None; + } + + Some(img) + } else { + None + } + }) + .collect(); + + if images.is_empty() { + return Err(format!( + "No images with supported extensions {:?} were found in the specified directory.", + SUPPORTED_EXTENSIONS + ) + .into()); + } + + Ok(images) + } + + /// Trims transparent areas from the sprite by cropping to the smallest non-transparent area. + /// + /// # Arguments + /// + /// * `sprite` - The sprite image to trim. + /// + /// # Returns + /// + /// An `RgbaImage` containing the trimmed sprite. + fn trim_transparent(&self, sprite: &RgbaImage) -> RgbaImage { + let (mut max_x, mut max_y) = (0, 0); + let mut min_x = sprite.width(); + let mut min_y = sprite.height(); + + for (x, y, pixel) in sprite.enumerate_pixels() { + if pixel[3] > 0 { + max_x = max_x.max(x); + max_y = max_y.max(y); + min_x = min_x.min(x); + min_y = min_y.min(y); + } + } + + let is_completely_transparent = max_x == 0 && max_y == 0 && sprite.get_pixel(0, 0)[3] == 0; + if is_completely_transparent { + return RgbaImage::new(1, 1); + } + + crop_imm(sprite, min_x, min_y, max_x - min_x + 1, max_y - min_y + 1).to_image() + } } -/// Checks if a file has an extension that matches common image formats. -fn is_image(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| IMAGE_EXTENSIONS.contains(ext.to_ascii_lowercase().as_str())) - .unwrap_or(false) +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + #[test] + fn test_spriterator_creation() { + let spriterator = Spriterator::new("test_dir", 1024, 1024); + assert_eq!(spriterator.dir_path, "test_dir"); + assert_eq!(spriterator.max_width, 1024); + assert_eq!(spriterator.max_height, 1024); + } + + #[test] + fn test_empty_directory_error() { + let spriterator = Spriterator::new("empty_dir", 1024, 1024); + let result = spriterator.generate(); + assert!(result.is_err()); + } + + #[test] + fn test_trim_transparent() { + let spriterator = Spriterator::new("test_dir", 1024, 1024); + let mut image = RgbaImage::new(10, 10); + for x in 2..8 { + for y in 2..8 { + image.put_pixel(x, y, Rgba([255, 0, 0, 255])); + } + } + let trimmed = spriterator.trim_transparent(&image); + assert_eq!(trimmed.width(), 6); + assert_eq!(trimmed.height(), 6); + } }