From 0e0d643fdcc95b4f7177384e330040f138d9aaec Mon Sep 17 00:00:00 2001 From: G2-Games Date: Thu, 29 May 2025 14:54:02 -0500 Subject: [PATCH] Added CLI tool --- Cargo.toml | 1 + sqp_tools/Cargo.toml | 15 ++++ sqp_tools/src/main.rs | 168 +++++++++++++++++++++++++++++++++++++++++ sqp_tools/src/utils.rs | 74 ++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 sqp_tools/Cargo.toml create mode 100644 sqp_tools/src/main.rs create mode 100644 sqp_tools/src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index 9fb9d87..dd8c736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "sqp", + "sqp_tools", ] [workspace.package] diff --git a/sqp_tools/Cargo.toml b/sqp_tools/Cargo.toml new file mode 100644 index 0000000..f163345 --- /dev/null +++ b/sqp_tools/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "sqp_tools" +version = "0.1.0" +edition = "2024" +authors.workspace = true + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +image = "0.25" +sqp = { path = "../sqp" } +text_io = "0.1" + +[lints] +workspace = true diff --git a/sqp_tools/src/main.rs b/sqp_tools/src/main.rs new file mode 100644 index 0000000..6112469 --- /dev/null +++ b/sqp_tools/src/main.rs @@ -0,0 +1,168 @@ +mod utils; + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; +use image::ImageReader; +use anyhow::{bail, Result}; +use sqp::{ColorFormat, CompressionType}; +use utils::{color_format, color_format_to_type, color_type_to_format, exists_decision, Assume}; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Subcommands, + + /// Overwrite output files + #[arg(short = 'n', long = "overwrite", conflicts_with = "assumeno")] + assumeyes: bool, + + /// Do not overwrite output files + #[arg(short = 'y', long = "preserve", conflicts_with = "assumeyes")] + assumeno: bool, +} + +#[derive(Debug, Subcommand)] +enum Subcommands { + /// Encode an image to SQP format + Encode(EncodeArgs), + + /// Decode an SQP image into another format + Decode(DecodeArgs), +} + +#[derive(Debug, Args)] +struct EncodeArgs { + /// Input image file of any type supported by `image` + input: PathBuf, + /// Output path to SQP location + output: PathBuf, + + /// Quality setting, a higher value = higher quality. + #[arg(default_value_t = 100, short, long, conflicts_with = "uncompressed", value_parser = clap::value_parser!(u8).range(1..=100))] + quality: u8, + + /// Create an uncompressed image. + /// + /// Incompatible with quality setting. + #[arg(short, long, conflicts_with = "quality")] + uncompressed: bool, + + /// The color format to use for the output image + /// + /// Valid values: + /// - RGBA8 + /// - RGB8 + /// - GrayA8 + /// - Gray8 + #[arg(short, long, value_parser = color_format, verbatim_doc_comment)] + color_format: Option, +} + +#[derive(Debug, Args)] +struct DecodeArgs { + /// Input SQP image file + input: PathBuf, + + /// Output image file + output: PathBuf, +} + +fn main() -> Result<()> { + let args = Cli::parse(); + + let assume = if args.assumeyes { + Some(Assume::Yes) + } else if args.assumeno { + Some(Assume::No) + } else { + None + }; + + match args.command { + Subcommands::Encode(a) => encode(a, assume), + Subcommands::Decode(a) => decode(a, assume), + } +} + +fn encode(args: EncodeArgs, assume: Option) -> Result<()> { + if !args.input.try_exists()? { + bail!("Input file {:?} does not exist", args.input); + } + + if args.output.try_exists()? + && !exists_decision("Output", "Overwrite", &args.output, assume) + { + return Ok(()) + } + + let image = ImageReader::open(args.input)? + .decode()?; + + let width = image.width(); + let height = image.height(); + + let color_format = args.color_format.or_else( + || color_type_to_format(image.color()) + ).unwrap_or(ColorFormat::Rgba8); + + let bitmap = match color_format { + ColorFormat::Rgba8 => image.into_rgba8().into_vec(), + ColorFormat::Rgb8 => image.into_rgb8().into_vec(), + ColorFormat::GrayA8 => image.into_luma_alpha8().into_vec(), + ColorFormat::Gray8 => image.into_luma8().into_vec(), + }; + + let (compression_type, quality) = if args.uncompressed { + (CompressionType::None, None) + } else if args.quality == 100 { + (CompressionType::Lossless, None) + } else { + (CompressionType::LossyDct, Some(args.quality)) + }; + + let sqp_img = sqp::SquishyPicture::from_raw( + width, + height, + color_format, + compression_type, + quality, + bitmap, + ); + + sqp_img.save(&args.output)?; + + Ok(()) +} + +fn decode(args: DecodeArgs, assume: Option) -> Result<()> { + if !args.input.try_exists()? { + bail!("Input file {:?} does not exist", args.input); + } + + if args.output.try_exists()? + && !exists_decision("Output", "Overwrite", &args.output, assume) + { + return Ok(()) + } + + let sqp_img = sqp::open(args.input)?; + + let width = sqp_img.width(); + let height = sqp_img.height(); + let color_format = color_format_to_type(sqp_img.color_format()); + + let mut img = sqp_img.into_raw(); + img.truncate(width as usize * height as usize * color_format.bytes_per_pixel() as usize); + + image::save_buffer( + args.output, + &img, + width, + height, + color_format, + )?; + + Ok(()) +} diff --git a/sqp_tools/src/utils.rs b/sqp_tools/src/utils.rs new file mode 100644 index 0000000..575c09f --- /dev/null +++ b/sqp_tools/src/utils.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +use image::ColorType; +use sqp::ColorFormat; +use text_io::read; + +pub enum Assume { + Yes, + No, +} + +pub fn color_format(s: &str) -> Result { + if !s.is_ascii() { + return Err(format!("Invalid color format {}", s)) + } + + let s_lower = s.to_lowercase(); + + let color_format = match s_lower.as_str() { + "rgba8" => ColorFormat::Rgba8, + "rgb8" => ColorFormat::Rgb8, + "graya8" => ColorFormat::GrayA8, + "gray8" => ColorFormat::Gray8, + _ => return Err(format!("Invalid color format {}", s)), + }; + + Ok(color_format) +} + +pub fn color_type_to_format(img_color_format: ColorType) -> Option { + Some(match img_color_format { + ColorType::L8 => ColorFormat::Gray8, + ColorType::La8 => ColorFormat::GrayA8, + ColorType::Rgb8 => ColorFormat::Rgb8, + ColorType::Rgba8 => ColorFormat::Rgba8, + _ => return None, + }) +} + +pub fn color_format_to_type(img_color_format: ColorFormat) -> ColorType { + match img_color_format { + ColorFormat::Gray8 => ColorType::L8, + ColorFormat::GrayA8 => ColorType::La8, + ColorFormat::Rgb8 => ColorType::Rgb8, + ColorFormat::Rgba8 => ColorType::Rgba8, + } +} + +pub fn exists_decision>(place: &str, action: &str, path: &P, assume: Option) -> bool { + let path = path.as_ref(); + + match assume { + Some(Assume::Yes) => return true, + Some(Assume::No) => return false, + None => (), + } + + loop { + print!("{place} file {path:?} already exists. {action}? [y/N] "); + + let opt: String = read!("{}\n"); + let opt = opt.to_lowercase(); + + if !opt.is_empty() && opt == "y" { + return true + } else if !opt.is_empty() && opt != "y" { + continue + } + + if opt.is_empty() { + return false + } + } +}