diff --git a/Cargo.lock b/Cargo.lock index 1be020ed..d88ffe4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itoa" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" + [[package]] name = "libc" version = "0.2.175" @@ -346,6 +352,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -398,6 +410,7 @@ dependencies = [ "rayon", "rgb", "rustc-hash", + "serde_json", "zopfli", ] @@ -426,6 +439,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + [[package]] name = "pxfm" version = "0.1.24" @@ -435,6 +457,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + [[package]] name = "radium" version = "0.7.0" @@ -489,6 +520,48 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -507,6 +580,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" @@ -523,6 +607,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + [[package]] name = "utf8parse" version = "0.2.2" @@ -633,6 +723,12 @@ dependencies = [ "tap", ] +[[package]] +name = "zmij" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4af59da1029247450b54ba43e0b62c8e376582464bbe5504dd525fe521e7e8fd" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 9c333ecc..0719ce76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,46 +38,25 @@ name = "zopfli" required-features = ["zopfli"] [dependencies] -zopfli = { version = "0.8.3", optional = true, default-features = false, features = ["std", "zlib"] } -rgb = "0.8.52" +bitvec = "1.0.1" +clap = { version = "4.5.53", optional = true, features = ["wrap_help"] } +crossbeam-channel = { version = "0.5.15", optional = true } +env_logger = { version = "0.11.8", optional = true, default-features = false, features = ["auto-color"] } +image = { version = "0.25.9", optional = true, default-features = false, features = ["png"] } indexmap = "2.13.0" libdeflater = "1.25.0" log = "0.4.29" -bitvec = "1.0.1" +parse-size = { version = "1.1.0", optional = true } +rayon = { version = "1.11.0", optional = true } +rgb = "0.8.52" rustc-hash = "2.1.1" +zopfli = { version = "0.8.3", optional = true, default-features = false, features = ["std", "zlib"] } -[dependencies.env_logger] -optional = true -default-features = false -features = ["auto-color"] -version = "0.11.8" +[target.'cfg(windows)'.dependencies] +glob = { version = "0.3.3", optional = true } -[dependencies.crossbeam-channel] -optional = true -version = "0.5.15" - -[dependencies.rayon] -optional = true -version = "1.11.0" - -[dependencies.clap] -optional = true -version = "4.5.54" -features = ["wrap_help"] - -[dependencies.parse-size] -optional = true -version = "1.1.0" - -[target.'cfg(windows)'.dependencies.glob] -optional = true -version = "0.3.3" - -[dependencies.image] -optional = true -default-features = false -features = ["png"] -version = "0.25.9" +[dev-dependencies] +serde_json = "1.0.147" [features] binary = ["dep:clap", "dep:glob", "dep:env_logger", "dep:parse-size"] diff --git a/src/cli.rs b/src/cli.rs index ca607ceb..e0b45296 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -226,6 +226,14 @@ losslessly.") .action(ArgAction::SetTrue) .conflicts_with("verbose"), ) + .arg( + Arg::new("json") + .help("Print results as JSON") + .short('j') + .long("json") + .action(ArgAction::SetTrue) + .conflicts_with("stdout"), + ) .arg( Arg::new("filters") .help("Filters to try (0-9; see '--help' for details)") diff --git a/src/evaluate.rs b/src/evaluate.rs index b9c572b8..f25f628d 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -118,7 +118,7 @@ impl Evaluator { /// Check if the image is smaller than others pub fn try_image(&self, image: Arc) { - let description = format!("{}", image.ihdr.color_type); + let description = image.ihdr.color_type.to_string(); self.try_image_with_description(image, &description); } diff --git a/src/lib.rs b/src/lib.rs index 28d956ec..f517e745 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,7 @@ pub mod internal_tests { } pub type PngResult = Result; +pub type OptimizationResult = PngResult<(usize, usize)>; #[derive(Debug)] /// A raw image definition which can be used to create an optimized png @@ -165,7 +166,7 @@ impl RawImage { /// Perform optimization on the input file using the options provided /// /// Returns the original and optimized file sizes -pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> PngResult<(usize, usize)> { +pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult { // Read in the file and try to decode as PNG. info!("Processing: {input}"); diff --git a/src/main.rs b/src/main.rs index e1cf60ff..214a0ae9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,9 @@ use indexmap::IndexSet; use log::{Level, LevelFilter, error, warn}; #[cfg(feature = "zopfli")] use oxipng::ZopfliOptions; -use oxipng::{Deflater, FilterStrategy, InFile, Options, OutFile, PngError, StripChunks}; +use oxipng::{ + Deflater, FilterStrategy, InFile, OptimizationResult, Options, OutFile, PngError, StripChunks, +}; use rayon::prelude::*; use crate::cli::DISPLAY_CHUNKS; @@ -51,15 +53,22 @@ fn main() -> ExitCode { #[cfg(not(windows))] let inputs: Vec<_> = file_args.collect(); let using_stdin = inputs.len() == 1 && inputs[0].to_str() == Some("-"); + if using_stdin && out_dir.is_some() { + error!("Cannot use --dir when reading from stdin."); + return ExitCode::FAILURE; + } + if using_stdin && matches!(out_file, OutFile::Path { path: None, .. }) { + out_file = OutFile::StdOut; + } + let using_stdout = matches!(out_file, OutFile::StdOut); + let json = matches.get_flag("json"); + if using_stdout && json { + error!("Cannot use --json when writing to stdout."); + return ExitCode::FAILURE; + } + let files = if using_stdin { - if out_dir.is_some() { - error!("Cannot use --dir when reading from stdin."); - return ExitCode::FAILURE; - } - if matches!(out_file, OutFile::Path { path: None, .. }) { - out_file = OutFile::StdOut; - } - vec![(InFile::StdIn, out_file.clone())] + vec![(InFile::StdIn, out_file)] } else { collect_files( inputs, @@ -71,7 +80,7 @@ fn main() -> ExitCode { }; let is_verbose = matches.get_count("verbose") > 0; - let print_summary = !matches.get_flag("quiet") && !matches!(out_file, OutFile::StdOut); + let print_summary = !matches.get_flag("quiet") && !using_stdout; let print_progress = print_summary && !is_verbose && stdout().is_terminal(); let total_files = files.len(); let num_processed = AtomicUsize::new(0); @@ -79,9 +88,9 @@ fn main() -> ExitCode { print!("Files processed: 0/{}...", total_files); stdout().flush().ok(); } - let process = |(input, output): (InFile, OutFile)| { - let result = process_file(&input, &output, &opts); - if print_progress && matches!(result, OptimizationResult::Ok(_, _)) { + let process = |(input, output): &(InFile, OutFile)| { + let result = process_file(input, output, &opts); + if print_progress && matches!(result, OptimizationResult::Ok(_)) { let value = num_processed.fetch_add(1, AcqRel) + 1; print!("\rFiles processed: {}/{}...", value, total_files); stdout().flush().ok(); @@ -89,9 +98,9 @@ fn main() -> ExitCode { result }; let results: Vec = if matches.get_flag("parallel-files") { - files.into_par_iter().map(process).collect() + files.par_iter().map(process).collect() } else { - files.into_iter().map(process).collect() + files.iter().map(process).collect() }; // Collect stats @@ -100,23 +109,25 @@ fn main() -> ExitCode { let mut num_failed = 0; let mut total_in: i64 = 0; let mut total_out: i64 = 0; - for result in results { + for result in &results { match result { - OptimizationResult::Ok(insize, outsize) => { + Ok((insize, outsize)) => { num_succeeded += 1; - total_in += insize as i64; - total_out += outsize as i64; + total_in += *insize as i64; + total_out += *outsize as i64; if !opts.force && insize == outsize { num_not_optimized += 1; } } - OptimizationResult::Failed => num_failed += 1, - OptimizationResult::Skipped => {} + Err(PngError::C2PAMetadataPreventsChanges | PngError::InflatedDataTooLong(_)) => {} + Err(_) => num_failed += 1, } } - // Print summary - if print_summary { + // Print results + if json { + json_output(&files, &results); + } else if print_summary { let in_bytes = format_bytes(total_in, true); let out_bytes = format_bytes(total_out, true); let saved = total_in - total_out; @@ -143,6 +154,13 @@ fn main() -> ExitCode { } } + // For optimizing single files, this will return the correct exit code always. + // For recursive optimization, the correct choice is a bit subjective. + // We're choosing to return a 0 exit code if ANY file in the set + // runs correctly. + // The reason for this is that recursion may pick up files that are not + // PNG files, and return an error for them. + // We don't really want to return an error code for those files. if num_succeeded > 0 { ExitCode::SUCCESS } else if num_failed > 0 { @@ -152,13 +170,6 @@ fn main() -> ExitCode { } } -#[derive(Eq, PartialEq, Ord, PartialOrd)] -enum OptimizationResult { - Ok(usize, usize), - Failed, - Skipped, -} - fn collect_files( files: Vec, out_dir: &Option, @@ -506,28 +517,91 @@ fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio if let (Some(max_size), InFile::Path(path)) = (opts.max_decompressed_size, input) { if path.metadata().is_ok_and(|m| m.len() > max_size as u64) { warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)"); - return OptimizationResult::Skipped; + return Err(PngError::InflatedDataTooLong(max_size)); } } - match oxipng::optimize(input, output, opts) { - // For optimizing single files, this will return the correct exit code always. - // For recursive optimization, the correct choice is a bit subjective. - // We're choosing to return a 0 exit code if ANY file in the set - // runs correctly. - // The reason for this is that recursion may pick up files that are not - // PNG files, and return an error for them. - // We don't really want to return an error code for those files. - Ok((insize, outsize)) => OptimizationResult::Ok(insize, outsize), + let result = oxipng::optimize(input, output, opts); + match &result { + Ok(_) => {} Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => { warn!("{input}: Skipped: {e}"); - OptimizationResult::Skipped } Err(e) => { error!("{input}: {e}"); - OptimizationResult::Failed } } + result +} + +/// Write optimization results as json. +/// ``` +/// { +/// "results": [ +/// { +/// "input": string, +/// "status": "success", +/// "output": string|null, +/// "insize": number, +/// "outsize": number +/// }, +/// { +/// "input": string, +/// "status": "error", +/// "error": string +/// } +/// ] +/// } +/// ``` +fn json_output(files: &[(InFile, OutFile)], results: &[OptimizationResult]) { + print!(r#"{{"results":["#); + let mut first = true; + results + .iter() + .zip(files) + .for_each(|(result, (input, output))| { + if !first { + print!(","); + } + print!(r#"{{"input":"{}","#, json_escape(&input.to_string())); + match result { + Ok((insize, outsize)) => { + let outpath = match output { + OutFile::None => "null".to_owned(), + OutFile::Path { path: None, .. } => { + format!(r#""{}""#, json_escape(&input.to_string())) + } + OutFile::Path { path: Some(p), .. } => { + format!(r#""{}""#, json_escape(&p.display().to_string())) + } + OutFile::StdOut => unreachable!(), + }; + print!( + r#""status":"success","output":{},"insize":{},"outsize":{}}}"#, + outpath, insize, outsize + ); + } + Err(e) => { + print!( + r#""status":"error","error":"{}"}}"#, + json_escape(&e.to_string()) + ); + } + } + first = false; + }); + print!("]}}"); +} + +fn json_escape(string: &str) -> String { + string + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\t", "\\t") + .replace("\r", "\\r") + .replace("\x08", "\\b") + .replace("\x0c", "\\f") } /// Format byte counts as IEC units to 3 significant figures. diff --git a/tests/files/json.png b/tests/files/json.png new file mode 100644 index 00000000..7c7f12ce Binary files /dev/null and b/tests/files/json.png differ diff --git a/tests/flags.rs b/tests/flags.rs index 63646c9d..6b3fdda9 100644 --- a/tests/flags.rs +++ b/tests/flags.rs @@ -1,10 +1,12 @@ use std::{ fs::remove_file, path::{Path, PathBuf}, + process::Command, }; use indexmap::indexset; use oxipng::{internal_tests::*, *}; +use serde_json::Value; const GRAY: u8 = 0; const RGB: u8 = 2; @@ -662,3 +664,39 @@ fn zopfli_mode() { BitDepth::Eight, ); } +#[test] +fn json_success() { + let path = "tests/files/json.png"; + let output = Command::new(env!("CARGO_BIN_EXE_oxipng")) + .args(["--json", "--nx", "--nz", path]) + .output() + .expect("Failed to run executable"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let data: Value = serde_json::from_str(&stdout).expect("Failed to parse JSON"); + let result = &data["results"][0]; + assert_eq!(result["input"], path); + assert_eq!(result["status"], "success"); + assert_eq!(result["output"], path); + assert_eq!(result["insize"], result["outsize"]); +} + +#[test] +fn json_dry_run() { + let path = "tests/files/json.png"; + let path2 = "tests/files/escape chars \\ \" \n \t \r \x08 \x0c.png"; + let output = Command::new(env!("CARGO_BIN_EXE_oxipng")) + .args(["--json", "--dry-run", path, path2]) + .output() + .expect("Failed to run executable"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let data: Value = serde_json::from_str(&stdout).expect("Failed to parse JSON"); + let result = &data["results"][0]; + assert_eq!(result["input"], path); + assert_eq!(result["status"], "success"); + assert_eq!(result["output"], Value::Null); + let result2 = &data["results"][1]; + assert_eq!(result2["input"], path2); + assert_eq!(result2["status"], "error"); +}