diff --git a/MANUAL.txt b/MANUAL.txt index 78bab9af..01f966af 100644 --- a/MANUAL.txt +++ b/MANUAL.txt @@ -171,6 +171,12 @@ Options: --force Write the output even if it is larger than the input + --min-gain + Only write optimized output when savings meet this threshold. Accepts percentages + (e.g. 1%, 0.5%) or byte sizes (e.g. 1024, 4KiB, 1MB). + + If savings is below the threshold, it is treated as no-change. + -z, --zopfli Use the much slower but stronger Zopfli compressor for main compression trials. Recommended use is with '-o max' and '--fast'. diff --git a/README.md b/README.md index 629182fe..95258d51 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ The most commonly used options are as follows: - Optimization: `-o 0` through `-o 6` (or `-o max`), lower is faster, higher is better compression. The default (`-o 2`) is quite fast and provides good compression. Higher levels can be notably better* but generally have increasingly diminishing returns. +- Minimum gain: `--min-gain ` only writes optimized output when savings reach a threshold. + Use a percentage (`1%`, `0.5%`) or bytes (`1024`, `4KiB`, `1MB`). - Strip: Used to remove metadata info from processed images. Used via `--strip [safe,all]`. Can save a few kilobytes if you don't need the metadata. "Safe" removes only metadata that will never affect rendering of the image. "All" removes all metadata that is not critical diff --git a/src/cli.rs b/src/cli.rs index e0b45296..aa0e3a76 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -340,6 +340,19 @@ be processed successfully. The output will always have correct checksums.") .long("force") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("min-gain") + .help("Require savings of at least before writing") + .long_help("\ +Only write optimized output if savings meets this threshold. The value may be specified as \ +a percentage (e.g. '1%' or '0.5%') or as a byte size (e.g. '1024', '4KiB', '1MB'). + +If savings is below the threshold, the result is treated as no-change: + - in-place output will not overwrite the input file + - explicit output path, stdout, and dry-run will use original data") + .long("min-gain") + .value_name("value"), + ) .arg( Arg::new("zopfli") .help("Use the much slower but stronger Zopfli compressor") diff --git a/src/lib.rs b/src/lib.rs index f517e745..6252baef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,7 +30,7 @@ pub use crate::{ error::PngError, filters::{FilterStrategy, RowFilter}, headers::StripChunks, - options::{InFile, Options, OutFile}, + options::{InFile, MinGain, Options, OutFile}, }; use crate::{ evaluate::{Candidate, Evaluator}, @@ -167,6 +167,19 @@ impl RawImage { /// /// Returns the original and optimized file sizes pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult { + optimize_with(input, output, opts, None) +} + +/// Perform optimization on the input file using the options provided, with an optional minimum +/// savings threshold. +/// +/// Returns the original and optimized file sizes +pub fn optimize_with( + input: &InFile, + output: &OutFile, + opts: &Options, + min_gain: Option, +) -> OptimizationResult { // Read in the file and try to decode as PNG. info!("Processing: {input}"); @@ -190,7 +203,7 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio let in_length = in_data.len(); - if is_fully_optimized(in_length, optimized_output.len(), opts) { + if should_keep_original(in_length, optimized_output.len(), opts, min_gain) { match (output, input) { // If output path is None, it also means same as the input path (OutFile::Path { path, .. }, InFile::Path(input_path)) @@ -285,6 +298,15 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio /// Perform optimization on the input file using the options provided, where the file is already /// loaded in-memory pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult> { + optimize_from_memory_with(data, opts, None) +} + +/// Perform optimization on in-memory image data with an optional minimum savings threshold. +pub fn optimize_from_memory_with( + data: &[u8], + opts: &Options, + min_gain: Option, +) -> PngResult> { // Read in the file and try to decode as PNG. info!("Processing from memory"); @@ -296,7 +318,7 @@ pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult> { // Run the optimizer on the decoded PNG. let optimized_output = optimize_png(&mut png, data, opts, deadline)?; - if is_fully_optimized(original_size, optimized_output.len(), opts) { + if should_keep_original(original_size, optimized_output.len(), opts, min_gain) { info!("Image already optimized"); Ok(data.to_vec()) } else { @@ -644,3 +666,13 @@ fn recompress_frames( const fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool { original_size <= optimized_size && !opts.force } + +fn should_keep_original( + original_size: usize, + optimized_size: usize, + opts: &Options, + min_gain: Option, +) -> bool { + is_fully_optimized(original_size, optimized_size, opts) + || min_gain.is_some_and(|threshold| !threshold.is_satisfied(original_size, optimized_size)) +} diff --git a/src/main.rs b/src/main.rs index 214a0ae9..39ddaf44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,8 +20,10 @@ use log::{Level, LevelFilter, error, warn}; #[cfg(feature = "zopfli")] use oxipng::ZopfliOptions; use oxipng::{ - Deflater, FilterStrategy, InFile, OptimizationResult, Options, OutFile, PngError, StripChunks, + Deflater, FilterStrategy, InFile, MinGain, OptimizationResult, Options, OutFile, PngError, + StripChunks, }; +use parse_size::parse_size; use rayon::prelude::*; use crate::cli::DISPLAY_CHUNKS; @@ -38,7 +40,7 @@ fn main() -> ExitCode { .after_long_help("") .get_matches_from(std::env::args()); - let (mut out_file, out_dir, opts) = match parse_opts_into_struct(&matches) { + let (mut out_file, out_dir, opts, min_gain) = match parse_opts_into_struct(&matches) { Ok(x) => x, Err(x) => { error!("{x}"); @@ -89,7 +91,7 @@ fn main() -> ExitCode { stdout().flush().ok(); } let process = |(input, output): &(InFile, OutFile)| { - let result = process_file(input, output, &opts); + let result = process_file(input, output, &opts, min_gain); if print_progress && matches!(result, OptimizationResult::Ok(_)) { let value = num_processed.fetch_add(1, AcqRel) + 1; print!("\rFiles processed: {}/{}...", value, total_files); @@ -115,7 +117,7 @@ fn main() -> ExitCode { num_succeeded += 1; total_in += *insize as i64; total_out += *outsize as i64; - if !opts.force && insize == outsize { + if insize == outsize && (!opts.force || min_gain.is_some()) { num_not_optimized += 1; } } @@ -237,7 +239,7 @@ fn apply_glob_pattern(path: PathBuf) -> Vec { fn parse_opts_into_struct( matches: &ArgMatches, -) -> Result<(OutFile, Option, Options), String> { +) -> Result<(OutFile, Option, Options, Option), String> { let log_level = match matches.get_count("verbose") { _ if matches.get_flag("quiet") => LevelFilter::Off, 0 => LevelFilter::Warn, @@ -344,6 +346,11 @@ fn parse_opts_into_struct( opts.force = matches.get_flag("force"); + let min_gain = matches + .get_one::("min-gain") + .map(|value| parse_min_gain(value)) + .transpose()?; + opts.fix_errors = matches.get_flag("fix"); opts.max_decompressed_size = matches.get_one::("max-size").map(|&x| x as usize); @@ -450,7 +457,7 @@ fn parse_opts_into_struct( .map_err(|err| err.to_string())?; } - Ok((out_file, out_dir, opts)) + Ok((out_file, out_dir, opts, min_gain)) } fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> { @@ -460,6 +467,33 @@ fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> { .map_err(|_| format!("Invalid chunk name {name}")) } +fn parse_min_gain(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("Minimum gain must not be empty".to_owned()); + } + + if let Some(percent) = value.strip_suffix('%') { + let parsed = percent + .trim() + .parse::() + .map_err(|_| format!("Invalid percentage for --min-gain: {value}"))?; + if !(0.0..=100.0).contains(&parsed) || !parsed.is_finite() { + return Err(format!( + "Percentage for --min-gain must be between 0% and 100%: {value}" + )); + } + return MinGain::ratio(parsed / 100.0) + .ok_or_else(|| format!("Invalid percentage for --min-gain: {value}")); + } + + let parsed = + parse_size(value).map_err(|_| format!("Invalid byte size for --min-gain: {value}"))?; + let bytes = usize::try_from(parsed) + .map_err(|_| format!("Byte size for --min-gain is too large: {value}"))?; + Ok(MinGain::bytes(bytes)) +} + fn parse_numeric_range_opts( input: &str, min_value: u8, @@ -513,7 +547,12 @@ fn parse_numeric_range_opts( Err(ERROR_MESSAGE.to_owned()) } -fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult { +fn process_file( + input: &InFile, + output: &OutFile, + opts: &Options, + min_gain: Option, +) -> OptimizationResult { 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)"); @@ -521,7 +560,7 @@ fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio } } - let result = oxipng::optimize(input, output, opts); + let result = oxipng::optimize_with(input, output, opts, min_gain); match &result { Ok(_) => {} Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => { @@ -631,7 +670,8 @@ fn format_bytes(count: i64, include_raw: bool) -> String { #[cfg(test)] mod tests { - use super::format_bytes; + use super::{format_bytes, parse_min_gain}; + use oxipng::MinGain; #[test] fn test_format_bytes() { @@ -641,4 +681,23 @@ mod tests { assert_eq!(format_bytes(2_000_000_000, false), "1.86 GiB"); assert_eq!(format_bytes(-1024, false), "-1.00 KiB"); } + + #[test] + fn test_parse_min_gain_valid_values() { + assert_eq!(parse_min_gain("1024"), Ok(MinGain::Bytes(1024))); + assert_eq!(parse_min_gain("4KiB"), Ok(MinGain::Bytes(4096))); + assert_eq!(parse_min_gain("1MB"), Ok(MinGain::Bytes(1_000_000))); + + let parsed = parse_min_gain("0.5%").expect("valid percent should parse"); + assert!(matches!(parsed, MinGain::Ratio(ratio) if (ratio - 0.005).abs() < f64::EPSILON)); + } + + #[test] + fn test_parse_min_gain_invalid_values() { + assert!(parse_min_gain("").is_err()); + assert!(parse_min_gain("-1").is_err()); + assert!(parse_min_gain("-0.5%").is_err()); + assert!(parse_min_gain("101%").is_err()); + assert!(parse_min_gain("abc").is_err()); + } } diff --git a/src/options.rs b/src/options.rs index 4e3e8d4f..7c54320f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -83,6 +83,50 @@ impl> From for InFile { } } +#[derive(Copy, Clone, Debug, PartialEq)] +/// Minimum savings threshold required to accept optimized output. +pub enum MinGain { + /// Require at least this many bytes to be saved. + Bytes(usize), + /// Require at least this ratio of the original size to be saved. + /// + /// The value is represented as a fraction in the range `0.0..=1.0`, + /// where `0.01` means 1%. + Ratio(f64), +} + +impl MinGain { + #[must_use] + pub const fn bytes(bytes: usize) -> Self { + Self::Bytes(bytes) + } + + #[must_use] + pub fn ratio(ratio: f64) -> Option { + if ratio.is_finite() && (0.0..=1.0).contains(&ratio) { + Some(Self::Ratio(ratio)) + } else { + None + } + } + + /// Check whether a given optimized size satisfies this threshold. + #[must_use] + pub fn is_satisfied(self, original_size: usize, optimized_size: usize) -> bool { + let Some(saved_bytes) = original_size.checked_sub(optimized_size) else { + return false; + }; + saved_bytes >= self.required_savings(original_size) + } + + fn required_savings(self, original_size: usize) -> usize { + match self { + Self::Bytes(bytes) => bytes, + Self::Ratio(ratio) => (original_size as f64 * ratio).ceil() as usize, + } + } +} + #[derive(Clone, Debug)] /// Options controlling the output of the `optimize` function pub struct Options { diff --git a/tests/lib.rs b/tests/lib.rs index f7634f87..44513426 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -1,4 +1,10 @@ -use std::{fs, fs::File, io::prelude::*}; +use std::{ + fs, + fs::File, + io::prelude::*, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; use oxipng::*; @@ -87,3 +93,91 @@ fn optimize_srgb_icc() { let result = oxipng::optimize_from_memory(&file, &opts); assert!(result.unwrap().len() < 1000); } + +fn temp_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("oxipng-{name}-{}-{nanos}.png", std::process::id())) +} + +#[test] +fn min_gain_bytes_skips_in_place_write() { + let input_data = fs::read("tests/files/verbose_mode.png").unwrap(); + let opts = Options::default(); + let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap(); + assert!( + optimized.len() < input_data.len(), + "fixture should produce measurable savings" + ); + let savings = input_data.len() - optimized.len(); + + let input_path = temp_path("min-gain-bytes-input"); + fs::write(&input_path, &input_data).unwrap(); + let mut permissions = fs::metadata(&input_path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&input_path, permissions).unwrap(); + + let result = oxipng::optimize_with( + &InFile::Path(input_path.clone()), + &OutFile::Path { + path: None, + preserve_attrs: false, + }, + &opts, + Some(MinGain::Bytes(savings + 1)), + ) + .unwrap(); + assert_eq!(result, (input_data.len(), input_data.len())); + assert_eq!(fs::read(&input_path).unwrap(), input_data); + + let mut permissions = fs::metadata(&input_path).unwrap().permissions(); + permissions.set_readonly(false); + fs::set_permissions(&input_path, permissions).unwrap(); + fs::remove_file(&input_path).ok(); +} + +#[test] +fn min_gain_percentage_threshold_behavior() { + let input_data = fs::read("tests/files/verbose_mode.png").unwrap(); + let opts = Options::default(); + let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap(); + assert!( + optimized.len() < input_data.len(), + "fixture should produce measurable savings" + ); + let savings_ratio = (input_data.len() - optimized.len()) as f64 / input_data.len() as f64; + let high_threshold = MinGain::ratio((savings_ratio + 0.001).min(1.0)).unwrap(); + let low_threshold = MinGain::ratio(savings_ratio / 2.0).unwrap(); + + let input_path = temp_path("min-gain-percent-input"); + let high_output = temp_path("min-gain-percent-high"); + let low_output = temp_path("min-gain-percent-low"); + fs::write(&input_path, &input_data).unwrap(); + + let high_result = oxipng::optimize_with( + &InFile::Path(input_path.clone()), + &OutFile::from_path(high_output.clone()), + &opts, + Some(high_threshold), + ) + .unwrap(); + assert_eq!(high_result, (input_data.len(), input_data.len())); + assert_eq!(fs::read(&high_output).unwrap(), input_data); + + let low_result = oxipng::optimize_with( + &InFile::Path(input_path.clone()), + &OutFile::from_path(low_output.clone()), + &opts, + Some(low_threshold), + ) + .unwrap(); + assert_eq!(low_result.0, input_data.len()); + assert!(low_result.1 < input_data.len()); + assert_eq!(fs::read(&low_output).unwrap().len(), low_result.1); + + fs::remove_file(&input_path).ok(); + fs::remove_file(&high_output).ok(); + fs::remove_file(&low_output).ok(); +}