diff --git a/README.md b/README.md index 680d38c..f152bc3 100644 --- a/README.md +++ b/README.md @@ -112,15 +112,17 @@ Arguments: Name of the file in which to save the spritesheet Options: - -r, --ratio Set the output pixel ratio [default: 1] - --retina Set the pixel ratio to 2 (equivalent to `--ratio=2`) - --unique Store only unique images in the spritesheet, and map them to multiple names - --recursive Include images in sub-directories - --spacing Add pixel spacing between sprites [default: 0] - -m, --minify-index-file Remove whitespace from the JSON index file - --sdf Output a spritesheet using a signed distance field for each sprite - -h, --help Print help - -V, --version Print version + -r, --ratio Set the output pixel ratio [default: 1] + --retina Set the pixel ratio to 2 (equivalent to `--ratio=2`) + --unique Store only unique images in the spritesheet, and map them to multiple names + --recursive Include images in sub-directories + --spacing Add pixel spacing between sprites [default: 0] + --oxipng Specify the PNG optimization level (0–6, default: 2) + --zopfli Optimize the output PNG with zopfli (1–255, very slow) + -m, --minify-index-file Remove whitespace from the JSON index file + --sdf Output a spritesheet using a signed distance field for each sprite + -h, --help Print help + -V, --version Print version ``` ## Using Spreet as a Rust library diff --git a/src/bin/spreet/cli.rs b/src/bin/spreet/cli.rs index 00379e9..9534bee 100644 --- a/src/bin/spreet/cli.rs +++ b/src/bin/spreet/cli.rs @@ -28,6 +28,12 @@ pub struct Cli { /// Add pixel spacing between sprites #[arg(long, default_value_t = 0, value_parser = is_non_negative)] pub spacing: u8, + /// Specify the PNG optimization level (0–6, default: 2) + #[arg(long, group = "optlevel", value_name = "LEVEL", value_parser = is_max_6)] + pub oxipng: Option, + /// Optimize the output PNG with zopfli (1–255, very slow) + #[arg(long, group = "optlevel", value_name = "ITERATIONS", value_parser = is_positive)] + pub zopfli: Option, /// Remove whitespace from the JSON index file #[arg(short, long)] pub minify_index_file: bool, @@ -64,3 +70,13 @@ fn is_non_negative(s: &str) -> Result { Ok(result) }) } + +/// Clap validator to ensure that an unsigned integer parsed from a string is no more than 6. +fn is_max_6(s: &str) -> Result { + u8::from_str(s) + .map_err(|e| e.to_string()) + .and_then(|result| match result { + i if i <= 6 => Ok(result), + _ => Err(String::from("must be a number no more than 6")), + }) +} diff --git a/src/bin/spreet/main.rs b/src/bin/spreet/main.rs index 8de2687..80581c5 100644 --- a/src/bin/spreet/main.rs +++ b/src/bin/spreet/main.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; +use std::num::NonZero; use clap::Parser; -use spreet::{get_svg_input_paths, load_svg, sprite_name, Sprite, Spritesheet}; +use spreet::{get_svg_input_paths, load_svg, sprite_name, Optlevel, Sprite, Spritesheet}; mod cli; @@ -64,10 +65,19 @@ fn main() { std::process::exit(exitcode::DATAERR); }; + let optlevel = match (args.oxipng, args.zopfli) { + (None, None) => Optlevel::default(), + (Some(level), None) => Optlevel::Oxipng { level }, + (None, Some(iterations)) => Optlevel::Zopfli { + iterations: NonZero::new(iterations).unwrap(), + }, + (Some(_), Some(_)) => unreachable!(), + }; + // Save the bitmapped spritesheet to a local PNG. let file_prefix = args.output; let spritesheet_path = format!("{file_prefix}.png"); - if let Err(e) = spritesheet.save_spritesheet(&spritesheet_path) { + if let Err(e) = spritesheet.save_spritesheet_at(&spritesheet_path, optlevel) { eprintln!("Error: could not save spritesheet to {spritesheet_path} ({e})"); std::process::exit(exitcode::IOERR); }; diff --git a/src/sprite/mod.rs b/src/sprite/mod.rs index e866e29..ea5e3c8 100644 --- a/src/sprite/mod.rs +++ b/src/sprite/mod.rs @@ -2,6 +2,7 @@ use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::fs::File; use std::io::Write; +use std::num::NonZero; use std::path::Path; use crunch::{Item, PackedItem, PackedItems, Rotation}; @@ -399,6 +400,12 @@ struct PixmapItem { sprite: Sprite, } +/// Optimization level for PNG image output. +pub enum Optlevel { + Oxipng { level: u8 }, + Zopfli { iterations: NonZero }, +} + impl Spritesheet { pub fn new( sprites: BTreeMap, @@ -500,15 +507,35 @@ impl Spritesheet { /// Encode the spritesheet to the in-memory PNG image. /// /// The `spritesheet` `Pixmap` is converted to an in-memory PNG, optimised using the [`oxipng`] - /// library. + /// library with the default optimization level. /// /// The spritesheet will match an index that can be retrieved with [`Self::get_index`]. /// /// [`oxipng`]: https://github.com/shssoichiro/oxipng pub fn encode_png(&self) -> SpreetResult> { + self.encode_png_at(Optlevel::default()) + } + + /// Encode the spritesheet to the in-memory PNG image with the specified optimization level. + /// + /// The `spritesheet` `Pixmap` is converted to an in-memory PNG, optimised using the [`oxipng`] + /// library. + /// + /// The spritesheet will match an index that can be retrieved with [`Self::get_index`]. + /// + /// [`oxipng`]: https://github.com/shssoichiro/oxipng + pub fn encode_png_at(&self, optlevel: Optlevel) -> SpreetResult> { + let options = match optlevel { + Optlevel::Oxipng { level } => oxipng::Options::from_preset(level.min(6)), + Optlevel::Zopfli { iterations } => oxipng::Options { + deflate: oxipng::Deflaters::Zopfli { iterations }, + ..oxipng::Options::max_compression() + }, + }; + Ok(optimize_from_memory( self.sheet.encode_png()?.as_slice(), - &oxipng::Options::default(), + &options, )?) } @@ -516,7 +543,8 @@ impl Spritesheet { /// /// A spritesheet, called an [image file] in the Mapbox Style Specification, is a PNG image /// containing all the individual sprite images. The `spritesheet` `Pixmap` is converted to an - /// in-memory PNG, optimised using the [`oxipng`] library, and saved to a local file. + /// in-memory PNG, optimised using the [`oxipng`] library with the default optimization level, + /// and saved to a local file. /// /// The spritesheet will match an index file that can be saved with [`Self::save_index`]. /// @@ -526,6 +554,24 @@ impl Spritesheet { Ok(std::fs::write(path, self.encode_png()?)?) } + /// Saves the spritesheet to a local file named `path` with the specified optimization level. + /// + /// A spritesheet, called an [image file] in the Mapbox Style Specification, is a PNG image + /// containing all the individual sprite images. The `spritesheet` `Pixmap` is converted to an + /// in-memory PNG, optimised using the [`oxipng`] library, and saved to a local file. + /// + /// The spritesheet will match an index file that can be saved with [`Self::save_index`]. + /// + /// [image file]: https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/#image-file + /// [`oxipng`]: https://github.com/shssoichiro/oxipng + pub fn save_spritesheet_at>( + &self, + path: P, + optlevel: Optlevel, + ) -> SpreetResult<()> { + Ok(std::fs::write(path, self.encode_png_at(optlevel)?)?) + } + /// Get the `sprite_index` that can be serialized to JSON. /// /// An [index file] is defined in the Mapbox Style Specification as a JSON document containing a @@ -560,6 +606,12 @@ impl Spritesheet { } } +impl Default for Optlevel { + fn default() -> Self { + Optlevel::Oxipng { level: 2 } + } +} + /// Returns the name (unique id within a spritesheet) taken from a file. /// /// The unique sprite name is the relative path from `path` to `base_path`