diff --git a/CHANGELOG.md b/CHANGELOG.md index 138cf591..c1c88ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ -**Version 0.1.2** (unreleased) +**Version 0.2.0** (unreleased) - Fix program version that is displayed when running `oxipng -V` - Ensure `--quiet` mode is actually quiet (@SethDusek [#20](https://github.com/shssoichiro/oxipng/pull/20)) - Write status/debug information to stderr instead of stdout - Use heuristics to determine best combination for `-o1` ([#21](https://github.com/shssoichiro/oxipng/issues/21)) + - [SEMVER_MAJOR] Allow 'safe', 'all', or comma-separated list as options for `--strip` + - [SEMVER_MINOR] Add `-s` alias for `--strip` **Version 0.1.1** - Fix `oxipng *` writing all input files to one output file ([#15](https://github.com/shssoichiro/oxipng/issues/15)) diff --git a/src/lib.rs b/src/lib.rs index 8af96a30..aa04b22d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,7 @@ pub struct Options { pub color_type_reduction: bool, pub palette_reduction: bool, pub idat_recoding: bool, - pub strip: bool, + pub strip: png::Headers, pub use_heuristics: bool, } @@ -227,9 +227,28 @@ pub fn optimize(filepath: &Path, opts: &Options) -> Result<(), String> { } } - if opts.strip { + match opts.strip.clone() { // Strip headers - png.aux_headers = HashMap::new(); + png::Headers::None => (), + png::Headers::Some(hdrs) => { + for hdr in &hdrs { + png.aux_headers.remove(hdr); + } + } + png::Headers::Safe => { + const PRESERVED_HEADERS: [&'static str; 9] = ["cHRM", "gAMA", "iCCP", "sBIT", "sRGB", + "bKGD", "hIST", "pHYs", "sPLT"]; + let mut preserved = HashMap::new(); + for (hdr, contents) in png.aux_headers.iter() { + if PRESERVED_HEADERS.contains(&hdr.as_ref()) { + preserved.insert(hdr.clone(), contents.clone()); + } + } + png.aux_headers = preserved; + } + png::Headers::All => { + png.aux_headers = HashMap::new(); + } } let output_data = png.output(); diff --git a/src/main.rs b/src/main.rs index ace4df52..d93b9e9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ extern crate clap; extern crate regex; use clap::{App, Arg, ArgMatches}; +use oxipng::png; use regex::Regex; use std::collections::HashSet; use std::io::{Write, stderr}; @@ -46,163 +47,166 @@ fn main() { color_type_reduction: true, palette_reduction: true, idat_recoding: true, - strip: false, + strip: png::Headers::None, use_heuristics: false, }; - let matches = App::new("oxipng") - .version(VERSION_STRING) - .author("Joshua Holmer ") - .about("Losslessly improves compression of PNG files") - .arg(Arg::with_name("files") - .help("File(s) to compress") - .index(1) - .multiple(true) - .required(true)) - .arg(Arg::with_name("optimization") - .help("Optimization level - Default: 2") - .short("o") - .long("opt") - .takes_value(true) - .possible_value("0") - .possible_value("1") - .possible_value("2") - .possible_value("3") - .possible_value("4") - .possible_value("5") - .possible_value("6")) - .arg(Arg::with_name("backup") - .help("Back up modified files") - .short("b") - .long("backup")) - .arg(Arg::with_name("force") - .help("Write output even if larger than the original") - .short("F") - .long("force")) - .arg(Arg::with_name("recursive") - .help("Recurse into subdirectories") - .short("r") - .long("recursive")) - .arg(Arg::with_name("output_dir") - .help("Write output file(s) to ") - .long("dir") - .takes_value(true) - .conflicts_with("output_file") - .conflicts_with("stdout")) - .arg(Arg::with_name("output_file") - .help("Write output file to ") - .long("out") - .takes_value(true) - .conflicts_with("output_dir") - .conflicts_with("stdout")) - .arg(Arg::with_name("stdout") - .help("Write output to stdout") - .long("stdout") - .conflicts_with("output_dir") - .conflicts_with("output_file")) - .arg(Arg::with_name("fix") - .help("Enable error recovery") - .long("fix")) - .arg(Arg::with_name("no-clobber") - .help("Do not overwrite existing files") - .long("no-clobber")) - .arg(Arg::with_name("pretend") - .help("Do not write any files, only calculate compression gains") - .short("P") - .long("pretend")) - .arg(Arg::with_name("preserve") - .help("Preserve file attributes if possible") - .short("p") - .long("preserve")) - .arg(Arg::with_name("quiet") - .help("Run in quiet mode") - .short("q") - .long("quiet") - .conflicts_with("verbose")) - .arg(Arg::with_name("verbose") - .help("Run in verbose mode") - .short("v") - .long("verbose") - .conflicts_with("quiet")) - .arg(Arg::with_name("filters") - .help("PNG delta filters (0-5) - Default: 0,5") - .short("f") - .long("filters") - .takes_value(true) - .validator(|x| { - match parse_numeric_range_opts(&x, 0, 5) { - Ok(_) => Ok(()), - Err(_) => Err("Invalid option for filters".to_owned()), - } - })) - .arg(Arg::with_name("interlace") - .help("PNG interlace type") - .short("i") - .long("interlace") - .takes_value(true) - .possible_value("0") - .possible_value("1")) - .arg(Arg::with_name("compression") - .help("zlib compression levels (1-9) - Default: 9") - .long("zc") - .takes_value(true) - .validator(|x| { - match parse_numeric_range_opts(&x, 1, 9) { - Ok(_) => Ok(()), - Err(_) => Err("Invalid option for compression".to_owned()), - } - })) - .arg(Arg::with_name("memory") - .help("zlib memory levels (1-9) - Default: 9") - .long("zm") - .takes_value(true) - .validator(|x| { - match parse_numeric_range_opts(&x, 1, 9) { - Ok(_) => Ok(()), - Err(_) => Err("Invalid option for memory".to_owned()), - } - })) - .arg(Arg::with_name("strategies") - .help("zlib compression strategies (0-3) - Default: 0-3") - .long("zs") - .takes_value(true) - .validator(|x| { - match parse_numeric_range_opts(&x, 0, 3) { - Ok(_) => Ok(()), - Err(_) => Err("Invalid option for strategies".to_owned()), - } - })) - .arg(Arg::with_name("window") - .help("zlib window size - Default: 32k") - .long("zw") - .takes_value(true) - .possible_value("256") - .possible_value("512") - .possible_value("1k") - .possible_value("2k") - .possible_value("4k") - .possible_value("8k") - .possible_value("16k") - .possible_value("32k")) - .arg(Arg::with_name("no-bit-reduction") - .help("No bit depth reduction") - .long("nb")) - .arg(Arg::with_name("no-color-reduction") - .help("No color type reduction") - .long("nc")) - .arg(Arg::with_name("no-palette-reduction") - .help("No palette reduction") - .long("np")) - .arg(Arg::with_name("no-reductions") - .help("No reductions") - .long("nx")) - .arg(Arg::with_name("no-recoding") - .help("No IDAT recoding unless necessary") - .long("nz")) - .arg(Arg::with_name("strip") - .help("Strip all metadata objects") - .long("strip")) - .after_help("Optimization levels: + let matches = + App::new("oxipng") + .version(VERSION_STRING) + .author("Joshua Holmer ") + .about("Losslessly improves compression of PNG files") + .arg(Arg::with_name("files") + .help("File(s) to compress") + .index(1) + .multiple(true) + .required(true)) + .arg(Arg::with_name("optimization") + .help("Optimization level - Default: 2") + .short("o") + .long("opt") + .takes_value(true) + .possible_value("0") + .possible_value("1") + .possible_value("2") + .possible_value("3") + .possible_value("4") + .possible_value("5") + .possible_value("6")) + .arg(Arg::with_name("backup") + .help("Back up modified files") + .short("b") + .long("backup")) + .arg(Arg::with_name("force") + .help("Write output even if larger than the original") + .short("F") + .long("force")) + .arg(Arg::with_name("recursive") + .help("Recurse into subdirectories") + .short("r") + .long("recursive")) + .arg(Arg::with_name("output_dir") + .help("Write output file(s) to ") + .long("dir") + .takes_value(true) + .conflicts_with("output_file") + .conflicts_with("stdout")) + .arg(Arg::with_name("output_file") + .help("Write output file to ") + .long("out") + .takes_value(true) + .conflicts_with("output_dir") + .conflicts_with("stdout")) + .arg(Arg::with_name("stdout") + .help("Write output to stdout") + .long("stdout") + .conflicts_with("output_dir") + .conflicts_with("output_file")) + .arg(Arg::with_name("fix") + .help("Enable error recovery") + .long("fix")) + .arg(Arg::with_name("no-clobber") + .help("Do not overwrite existing files") + .long("no-clobber")) + .arg(Arg::with_name("pretend") + .help("Do not write any files, only calculate compression gains") + .short("P") + .long("pretend")) + .arg(Arg::with_name("preserve") + .help("Preserve file attributes if possible") + .short("p") + .long("preserve")) + .arg(Arg::with_name("quiet") + .help("Run in quiet mode") + .short("q") + .long("quiet") + .conflicts_with("verbose")) + .arg(Arg::with_name("verbose") + .help("Run in verbose mode") + .short("v") + .long("verbose") + .conflicts_with("quiet")) + .arg(Arg::with_name("filters") + .help("PNG delta filters (0-5) - Default: 0,5") + .short("f") + .long("filters") + .takes_value(true) + .validator(|x| { + match parse_numeric_range_opts(&x, 0, 5) { + Ok(_) => Ok(()), + Err(_) => Err("Invalid option for filters".to_owned()), + } + })) + .arg(Arg::with_name("interlace") + .help("PNG interlace type") + .short("i") + .long("interlace") + .takes_value(true) + .possible_value("0") + .possible_value("1")) + .arg(Arg::with_name("compression") + .help("zlib compression levels (1-9) - Default: 9") + .long("zc") + .takes_value(true) + .validator(|x| { + match parse_numeric_range_opts(&x, 1, 9) { + Ok(_) => Ok(()), + Err(_) => Err("Invalid option for compression".to_owned()), + } + })) + .arg(Arg::with_name("memory") + .help("zlib memory levels (1-9) - Default: 9") + .long("zm") + .takes_value(true) + .validator(|x| { + match parse_numeric_range_opts(&x, 1, 9) { + Ok(_) => Ok(()), + Err(_) => Err("Invalid option for memory".to_owned()), + } + })) + .arg(Arg::with_name("strategies") + .help("zlib compression strategies (0-3) - Default: 0-3") + .long("zs") + .takes_value(true) + .validator(|x| { + match parse_numeric_range_opts(&x, 0, 3) { + Ok(_) => Ok(()), + Err(_) => Err("Invalid option for strategies".to_owned()), + } + })) + .arg(Arg::with_name("window") + .help("zlib window size - Default: 32k") + .long("zw") + .takes_value(true) + .possible_value("256") + .possible_value("512") + .possible_value("1k") + .possible_value("2k") + .possible_value("4k") + .possible_value("8k") + .possible_value("16k") + .possible_value("32k")) + .arg(Arg::with_name("no-bit-reduction") + .help("No bit depth reduction") + .long("nb")) + .arg(Arg::with_name("no-color-reduction") + .help("No color type reduction") + .long("nc")) + .arg(Arg::with_name("no-palette-reduction") + .help("No palette reduction") + .long("np")) + .arg(Arg::with_name("no-reductions") + .help("No reductions") + .long("nx")) + .arg(Arg::with_name("no-recoding") + .help("No IDAT recoding unless necessary") + .long("nz")) + .arg(Arg::with_name("strip") + .help("Strip metadata objects ['safe', 'all', or comma-separated list]") + .long("strip") + .short("s") + .takes_value(true)) + .after_help("Optimization levels: -o 0 => --zc 3 --nz (0 or 1 trials) -o 1 => --zc 9 (1 trial, determined heuristically) -o 2 => --zc 9 --zs 0-3 --f 0,5 (8 trials) @@ -217,7 +221,7 @@ fn main() { Manually specifying a compression option (zc, zm, etc.) will override the optimization preset, regardless of the order you write the arguments.") - .get_matches(); + .get_matches(); let mut opts = default_opts; @@ -477,8 +481,27 @@ fn parse_opts_into_struct(matches: &ArgMatches, opts: &mut oxipng::Options) -> R opts.idat_recoding = false; } - if matches.is_present("strip") { - opts.strip = true; + if let Some(hdrs) = matches.value_of("strip") { + let hdrs = hdrs.split(',').map(|x| x.trim().to_owned()).collect::>(); + if hdrs.contains(&"safe".to_owned()) || hdrs.contains(&"all".to_owned()) { + if hdrs.len() > 1 { + return Err("'safe' or 'all' presets for --strip should be used by themselves" + .to_owned()); + } + if hdrs[0] == "safe" { + opts.strip = png::Headers::Safe; + } else { + opts.strip = png::Headers::All; + } + } else { + const FORBIDDEN_CHUNKS: [&'static str; 5] = ["IHDR", "IDAT", "tRNS", "PLTE", "IEND"]; + for i in &hdrs { + if FORBIDDEN_CHUNKS.contains(&i.as_ref()) { + return Err(format!("{} chunk is not allowed to be stripped", i)); + } + } + opts.strip = png::Headers::Some(hdrs); + } } Ok(()) @@ -488,8 +511,8 @@ fn parse_numeric_range_opts(input: &str, min_value: u8, max_value: u8) -> Result, String> { - let one_item = Regex::new(format!("^[{}-{}]$", min_value, max_value).as_ref()).unwrap(); - let multiple_items = Regex::new(format!("^([{}-{}])(,|-)([{}-{}])$", + let one_item = Regex::new(format!(r"^[{}-{}]$", min_value, max_value).as_ref()).unwrap(); + let multiple_items = Regex::new(format!(r"^([{}-{}])(,|-)([{}-{}])$", min_value, max_value, min_value, diff --git a/src/png.rs b/src/png.rs index 91d5b052..f2ccebdc 100644 --- a/src/png.rs +++ b/src/png.rs @@ -89,6 +89,14 @@ impl BitDepth { } } +#[derive(Debug,PartialEq,Clone)] +pub enum Headers { + None, + Some(Vec), + Safe, + All, +} + #[derive(Debug,Clone)] pub struct ScanLines<'a> { pub png: &'a PngData, diff --git a/tests/files/strip_headers.png b/tests/files/strip_headers_all.png similarity index 96% rename from tests/files/strip_headers.png rename to tests/files/strip_headers_all.png index 3f9bdb1a..d714a05d 100644 Binary files a/tests/files/strip_headers.png and b/tests/files/strip_headers_all.png differ diff --git a/tests/files/strip_headers_list.png b/tests/files/strip_headers_list.png new file mode 100644 index 00000000..d714a05d Binary files /dev/null and b/tests/files/strip_headers_list.png differ diff --git a/tests/files/strip_headers_none.png b/tests/files/strip_headers_none.png new file mode 100644 index 00000000..d714a05d Binary files /dev/null and b/tests/files/strip_headers_none.png differ diff --git a/tests/files/strip_headers_safe.png b/tests/files/strip_headers_safe.png new file mode 100644 index 00000000..d714a05d Binary files /dev/null and b/tests/files/strip_headers_safe.png differ diff --git a/tests/filters.rs b/tests/filters.rs index 702cd376..f544ef45 100644 --- a/tests/filters.rs +++ b/tests/filters.rs @@ -44,7 +44,7 @@ fn get_opts(input: &Path) -> oxipng::Options { color_type_reduction: true, palette_reduction: true, idat_recoding: true, - strip: false, + strip: png::Headers::None, use_heuristics: false, } } diff --git a/tests/flags.rs b/tests/flags.rs index 1e956653..cc71450b 100644 --- a/tests/flags.rs +++ b/tests/flags.rs @@ -44,7 +44,7 @@ fn get_opts(input: &Path) -> oxipng::Options { color_type_reduction: true, palette_reduction: true, idat_recoding: true, - strip: false, + strip: png::Headers::None, use_heuristics: false, } } @@ -105,15 +105,17 @@ fn verbose_mode() { } #[test] -fn strip_headers() { - let input = PathBuf::from("tests/files/strip_headers.png"); +fn strip_headers_list() { + let input = PathBuf::from("tests/files/strip_headers_list.png"); let mut opts = get_opts(&input); - opts.strip = true; + opts.strip = png::Headers::Some(vec!["iCCP".to_owned(), "tEXt".to_owned()]); let output = opts.out_file.clone(); let png = png::PngData::new(&input).unwrap(); assert!(png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); match oxipng::optimize(&input, &opts) { Ok(_) => (), @@ -130,6 +132,131 @@ fn strip_headers() { }; assert!(!png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(!png.aux_headers.contains_key("iCCP")); + + let old_png = image::open(&input).unwrap(); + let new_png = image::open(&output).unwrap(); + + // Conversion should be lossless + assert!(old_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>() == + new_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>()); + + remove_file(output).ok(); +} + +#[test] +fn strip_headers_safe() { + let input = PathBuf::from("tests/files/strip_headers_safe.png"); + let mut opts = get_opts(&input); + opts.strip = png::Headers::Safe; + let output = opts.out_file.clone(); + + let png = png::PngData::new(&input).unwrap(); + + assert!(png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); + + match oxipng::optimize(&input, &opts) { + Ok(_) => (), + Err(x) => panic!(x.to_owned()), + }; + assert!(output.exists()); + + let png = match png::PngData::new(&output) { + Ok(x) => x, + Err(x) => { + remove_file(output).ok(); + panic!(x.to_owned()) + } + }; + + assert!(!png.aux_headers.contains_key("tEXt")); + assert!(!png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); + + let old_png = image::open(&input).unwrap(); + let new_png = image::open(&output).unwrap(); + + // Conversion should be lossless + assert!(old_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>() == + new_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>()); + + remove_file(output).ok(); +} + +#[test] +fn strip_headers_all() { + let input = PathBuf::from("tests/files/strip_headers_all.png"); + let mut opts = get_opts(&input); + opts.strip = png::Headers::All; + let output = opts.out_file.clone(); + + let png = png::PngData::new(&input).unwrap(); + + assert!(png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); + + match oxipng::optimize(&input, &opts) { + Ok(_) => (), + Err(x) => panic!(x.to_owned()), + }; + assert!(output.exists()); + + let png = match png::PngData::new(&output) { + Ok(x) => x, + Err(x) => { + remove_file(output).ok(); + panic!(x.to_owned()) + } + }; + + assert!(!png.aux_headers.contains_key("tEXt")); + assert!(!png.aux_headers.contains_key("iTXt")); + assert!(!png.aux_headers.contains_key("iCCP")); + + let old_png = image::open(&input).unwrap(); + let new_png = image::open(&output).unwrap(); + + // Conversion should be lossless + assert!(old_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>() == + new_png.pixels().map(|x| x.2.channels().to_owned()).collect::>>()); + + remove_file(output).ok(); +} + +#[test] +fn strip_headers_none() { + let input = PathBuf::from("tests/files/strip_headers_none.png"); + let mut opts = get_opts(&input); + opts.strip = png::Headers::None; + let output = opts.out_file.clone(); + + let png = png::PngData::new(&input).unwrap(); + + assert!(png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); + + match oxipng::optimize(&input, &opts) { + Ok(_) => (), + Err(x) => panic!(x.to_owned()), + }; + assert!(output.exists()); + + let png = match png::PngData::new(&output) { + Ok(x) => x, + Err(x) => { + remove_file(output).ok(); + panic!(x.to_owned()) + } + }; + + assert!(png.aux_headers.contains_key("tEXt")); + assert!(png.aux_headers.contains_key("iTXt")); + assert!(png.aux_headers.contains_key("iCCP")); let old_png = image::open(&input).unwrap(); let new_png = image::open(&output).unwrap(); diff --git a/tests/reduction.rs b/tests/reduction.rs index e33a474a..6c0cafde 100644 --- a/tests/reduction.rs +++ b/tests/reduction.rs @@ -44,7 +44,7 @@ fn get_opts(input: &Path) -> oxipng::Options { color_type_reduction: true, palette_reduction: true, idat_recoding: true, - strip: false, + strip: png::Headers::None, use_heuristics: false, } }