#![warn(trivial_casts, trivial_numeric_casts, unused_import_braces)]
#![deny(missing_debug_implementations, missing_copy_implementations)]
#![warn(clippy::expl_impl_clone_on_copy)]
#![warn(clippy::float_cmp_const)]
#![warn(clippy::linkedlist)]
#![warn(clippy::map_flatten)]
#![warn(clippy::match_same_arms)]
#![warn(clippy::mem_forget)]
#![warn(clippy::mut_mut)]
#![warn(clippy::mutex_integer)]
#![warn(clippy::needless_continue)]
#![warn(clippy::path_buf_push_overwrite)]
#![warn(clippy::range_plus_one)]
#![allow(clippy::cognitive_complexity)]
#[cfg(not(feature = "parallel"))]
mod rayon;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use indexmap::IndexSet;
use log::{error, warn, Level, LevelFilter};
use oxipng::Deflaters;
use oxipng::Options;
use oxipng::RowFilter;
use oxipng::StripChunks;
use oxipng::{InFile, OutFile};
use rayon::prelude::*;
use std::ffi::OsString;
use std::fs::DirBuilder;
use std::io::Write;
#[cfg(feature = "zopfli")]
use std::num::NonZeroU8;
use std::path::PathBuf;
use std::process::exit;
use std::time::Duration;
fn main() {
let matches = Command::new("oxipng")
.version(env!("CARGO_PKG_VERSION"))
.author("Joshua Holmer <jholmer.in@gmail.com>")
.about("Losslessly improve compression of PNG files")
.arg(
Arg::new("files")
.help("File(s) to compress (use '-' for stdin)")
.index(1)
.num_args(1..)
.use_value_delimiter(false)
.required(true)
.value_parser(value_parser!(PathBuf)),
)
.arg(
Arg::new("optimization")
.help("Optimization level (0-6, or max)")
.long_help("\
Set the optimization level preset. The default level 2 is quite fast and provides good \
compression. Lower levels are faster, higher levels provide better compression, though \
with increasingly diminishing returns.
0 => --zc 5 --fast (1 trial, determined heuristically)
1 => --zc 10 --fast (1 trial, determined heuristically)
2 => --zc 11 -f 0,1,6,7 --fast (4 fast trials, 1 main trial)
3 => --zc 11 -f 0,7,8,9 (4 trials)
4 => --zc 12 -f 0,7,8,9 (4 trials)
5 => --zc 12 -f 0,1,2,5,6,7,8,9 (8 trials)
6 => --zc 12 -f 0-9 (10 trials)
max => (stable alias for the max level)
Manually specifying a compression option (zc, f, etc.) will override the optimization \
preset, regardless of the order you write the arguments.")
.short('o')
.long("opt")
.value_name("level")
.default_value("2")
.value_parser(["0", "1", "2", "3", "4", "5", "6", "max"])
.hide_possible_values(true),
)
.arg(
Arg::new("backup")
.help("Back up modified files")
.short('b')
.long("backup")
.hide(true)
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("recursive")
.help("Recurse input directories, optimizing all PNG files")
.long_help("\
When directories are given as input, traverse the directory trees and optimize all PNG \
files found (files with “.png” or “.apng” extension).")
.short('r')
.long("recursive")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("output_dir")
.help("Write output file(s) to <directory>")
.long_help("\
Write output file(s) to <directory>. If the directory does not exist, it will be created. \
Note that this will not preserve the directory structure of the input files when used with \
'--recursive'.")
.long("dir")
.value_name("directory")
.value_parser(value_parser!(PathBuf))
.conflicts_with("output_file")
.conflicts_with("stdout"),
)
.arg(
Arg::new("output_file")
.help("Write output file to <file>")
.long("out")
.value_name("file")
.value_parser(value_parser!(PathBuf))
.conflicts_with("output_dir")
.conflicts_with("stdout"),
)
.arg(
Arg::new("stdout")
.help("Write output to stdout")
.long("stdout")
.action(ArgAction::SetTrue)
.conflicts_with("output_dir")
.conflicts_with("output_file"),
)
.arg(
Arg::new("preserve")
.help("Preserve file permissions and timestamps if possible")
.short('p')
.long("preserve")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("pretend")
.help("Do not write any files, only show compression results")
.short('P')
.long("pretend")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("strip-safe")
.help("Strip safely-removable chunks, same as '--strip safe'")
.short('s')
.action(ArgAction::SetTrue)
.conflicts_with("strip"),
)
.arg(
Arg::new("strip")
.help("Strip metadata (safe, all, or comma-separated list)\nCAUTION: 'all' will convert APNGs to standard PNGs")
.long_help(format!("\
Strip metadata chunks, where <mode> is one of:
safe => Strip all non-critical chunks, except for the following:
{}
all => Strip all non-critical chunks
<list> => Strip chunks in the comma-separated list, e.g. 'bKGD,cHRM'
CAUTION: 'all' will convert APNGs to standard PNGs.
Note that 'bKGD', 'sBIT' and 'hIST' will be forcibly stripped if the color type or bit \
depth is changed, regardless of any options set.",
StripChunks::KEEP_SAFE
.iter()
.map(|c| String::from_utf8_lossy(c))
.collect::<Vec<_>>()
.join(", ")))
.long("strip")
.value_name("mode")
.conflicts_with("strip-safe"),
)
.arg(
Arg::new("keep")
.help("Strip all metadata except in the comma-separated list")
.long("keep")
.value_name("list")
.conflicts_with("strip")
.conflicts_with("strip-safe"),
)
.arg(
Arg::new("alpha")
.help("Perform additional alpha channel optimization")
.long_help("\
Perform additional optimization on images with an alpha channel, by altering the color \
values of fully transparent pixels. This is generally recommended for better compression, \
but take care as while this is “visually lossless”, it is technically a lossy \
transformation and may be unsuitable for some applications.")
.short('a')
.long("alpha")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("interlace")
.help("Set PNG interlacing type (0, 1, keep)")
.long_help("\
Set the PNG interlacing type, where <type> is one of:
0 => Remove interlacing from all images that are processed
1 => Apply Adam7 interlacing on all images that are processed
keep => Keep the existing interlacing type of each image
Note that interlacing can add 25-50% to the size of an optimized image. Only use it if you \
believe the benefits outweigh the costs for your use case.")
.short('i')
.long("interlace")
.value_name("type")
.default_value("0")
.value_parser(["0", "1", "keep"])
.hide_possible_values(true),
)
.arg(
Arg::new("scale16")
.help("Forcibly reduce 16-bit images to 8-bit (lossy)")
.long_help("\
Forcibly reduce images with 16 bits per channel to 8 bits per channel. This is a lossy \
operation but can provide significant savings when you have no need for higher depth. \
Reduction is performed by scaling the values such that, e.g. 0x00FF is reduced to 0x01 \
rather than 0x00.
Without this flag, 16-bit images will only be reduced in depth if it can be done \
losslessly.")
.long("scale16")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("verbose")
.help("Run in verbose mode (use twice to increase verbosity)")
.short('v')
.long("verbose")
.action(ArgAction::Count)
.conflicts_with("quiet"),
)
.arg(
Arg::new("quiet")
.help("Run in quiet mode")
.short('q')
.long("quiet")
.action(ArgAction::SetTrue)
.conflicts_with("verbose"),
)
.arg(
Arg::new("filters")
.help(format!("Filters to try (0-{}; see '--help' for details)", RowFilter::LAST))
.long_help("\
Perform compression trials with each of the given filter types. You can specify a \
comma-separated list, or a range of values. E.g. '-f 0-3' is the same as '-f 0,1,2,3'.
PNG delta filters (apply the same filter to every line)
0 => None (recommended to always include this filter)
1 => Sub
2 => Up
3 => Average
4 => Paeth
Heuristic strategies (try to find the best delta filter for each line)
5 => MinSum Minimum sum of absolute differences
6 => Entropy Highest Shannon entropy
7 => Bigrams Lowest count of distinct bigrams
8 => BigEnt Highest Shannon entropy of bigrams
9 => Brute Smallest compressed size (slow)
The default value depends on the optimization level preset.")
.short('f')
.long("filters")
.value_name("list")
.value_parser(|x: &str| {
parse_numeric_range_opts(x, 0, RowFilter::LAST)
.map_err(|_| "Invalid option for filters")
}),
)
.arg(
Arg::new("fast")
.help("Use fast filter evaluation")
.long_help("\
Perform a fast compression evaluation of each enabled filter, followed by a single main \
compression trial of the best result. Recommended if you have more filters enabled than \
CPU cores.")
.long("fast")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("compression")
.help("Deflate compression level (1-12)")
.long_help("\
Deflate compression level (1-12) for main compression trials. The levels here are defined \
by the libdeflate compression library.
The default value depends on the optimization level preset.")
.long("zc")
.value_name("level")
.value_parser(1..=12)
.conflicts_with("zopfli"),
)
.arg(
Arg::new("no-bit-reduction")
.help("Do not change bit depth")
.long("nb")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-color-reduction")
.help("Do not change color type")
.long("nc")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-palette-reduction")
.help("Do not change color palette")
.long("np")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-grayscale-reduction")
.help("Do not change to or from grayscale")
.long("ng")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-reductions")
.help("Do not perform any transformations")
.long_help("\
Do not perform any transformations and do not deinterlace by default.")
.long("nx")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-recoding")
.help("Do not recompress unless transformations occur")
.long_help("\
Do not recompress IDAT unless required due to transformations. Recompression of other \
compressed chunks (such as iCCP) will also be disabled. Note that the combination of \
'--nx' and '--nz' will fully disable all optimization.")
.long("nz")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("fix")
.help("Disable checksum validation")
.long_help("\
Do not perform checksum validation of PNG chunks. This may allow some files with errors to \
be processed successfully.")
.long("fix")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("force")
.help("Write the output even if it is larger than the input")
.long("force")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("zopfli")
.help("Use the much slower but stronger Zopfli compressor")
.long_help("\
Use the much slower but stronger Zopfli compressor for main compression trials. \
Recommended use is with '-o max' and '--fast'.")
.short('Z')
.long("zopfli")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("timeout")
.help("Maximum amount of time to spend on optimizations")
.long_help("\
Maximum amount of time, in seconds, to spend on optimizations. Oxipng will check the \
timeout before each transformation or compression trial, and will stop trying to optimize \
the file if the timeout is exceeded. Note that this does not cut short any operations that \
are already in progress, so it is currently of limited effectiveness for large files with \
high compression levels.")
.value_name("secs")
.long("timeout")
.value_parser(value_parser!(u64)),
)
.arg(
Arg::new("threads")
.help("Set number of threads to use [default: num CPU cores]")
.long("threads")
.short('t')
.value_name("num")
.value_parser(value_parser!(usize)),
)
.after_help("Run `oxipng --help` to see full details of all options")
.after_long_help("")
.get_matches_from(std::env::args());
if matches.get_flag("backup") {
eprintln!("The --backup flag is no longer supported. Please use --out or --dir to preserve your existing files.");
exit(1)
}
let (out_file, out_dir, opts) = match parse_opts_into_struct(&matches) {
Ok(x) => x,
Err(x) => {
error!("{}", x);
exit(1)
}
};
let files = collect_files(
#[cfg(windows)]
matches
.get_many::<PathBuf>("files")
.unwrap()
.cloned()
.flat_map(apply_glob_pattern)
.collect(),
#[cfg(not(windows))]
matches
.get_many::<PathBuf>("files")
.unwrap()
.cloned()
.collect(),
&out_dir,
&out_file,
matches.get_flag("recursive"),
true,
);
let success = files.into_par_iter().filter(|(input, output)| {
match oxipng::optimize(input, output, &opts) {
Ok(_) => true,
Err(e) => {
error!("{}: {}", input, e);
false
}
}
});
if success.count() == 0 {
exit(1);
}
}
fn collect_files(
files: Vec<PathBuf>,
out_dir: &Option<PathBuf>,
out_file: &OutFile,
recursive: bool,
top_level: bool, ) -> Vec<(InFile, OutFile)> {
let mut in_out_pairs = Vec::new();
let allow_stdin = top_level && files.len() == 1;
for input in files {
let using_stdin = allow_stdin && input.to_str().map_or(false, |p| p == "-");
if !using_stdin && input.is_dir() {
if recursive {
match input.read_dir() {
Ok(dir) => {
let files = dir.filter_map(|x| x.ok().map(|x| x.path())).collect();
in_out_pairs
.extend(collect_files(files, out_dir, out_file, recursive, false));
}
Err(e) => {
warn!("{}: {}", input.display(), e);
}
}
} else {
warn!("{} is a directory, skipping", input.display());
}
continue;
};
let out_file =
if let (Some(out_dir), &OutFile::Path { preserve_attrs, .. }) = (out_dir, out_file) {
let path = Some(out_dir.join(input.file_name().unwrap()));
OutFile::Path {
path,
preserve_attrs,
}
} else {
(*out_file).clone()
};
let in_file = if using_stdin {
InFile::StdIn
} else {
if !top_level && {
let extension = input.extension().map(|f| f.to_ascii_lowercase());
extension != Some(OsString::from("png"))
&& extension != Some(OsString::from("apng"))
} {
continue;
}
InFile::Path(input)
};
in_out_pairs.push((in_file, out_file));
}
in_out_pairs
}
#[cfg(windows)]
fn apply_glob_pattern(path: PathBuf) -> Vec<PathBuf> {
let matches = path
.to_str()
.and_then(|pattern| glob::glob(pattern).ok())
.map(|paths| paths.flatten().collect::<Vec<_>>());
match matches {
Some(paths) if !paths.is_empty() => paths,
_ => vec![path],
}
}
fn parse_opts_into_struct(
matches: &ArgMatches,
) -> Result<(OutFile, Option<PathBuf>, Options), String> {
let log_level = match matches.get_count("verbose") {
_ if matches.get_flag("quiet") => LevelFilter::Off,
0 => LevelFilter::Info,
1 => LevelFilter::Debug,
_ => LevelFilter::Trace,
};
env_logger::builder()
.filter_module(module_path!(), log_level)
.format(|buf, record| {
let style = match record.level() {
Level::Error | Level::Warn => buf.default_level_style(record.level()),
_ => buf.style(), };
writeln!(buf, "{}", style.value(record.args()))
})
.init();
let mut opts = match matches.get_one::<String>("optimization") {
None => Options::default(),
Some(x) if x == "max" => Options::max_compression(),
Some(level) => Options::from_preset(level.parse::<u8>().unwrap()),
};
if let Some(x) = matches.get_one::<IndexSet<u8>>("filters") {
opts.filter.clear();
for &f in x {
opts.filter.insert(f.try_into().unwrap());
}
}
if let Some(&num) = matches.get_one::<u64>("timeout") {
opts.timeout = Some(Duration::from_secs(num));
}
let out_dir = if let Some(path) = matches.get_one::<PathBuf>("output_dir") {
if !path.exists() {
match DirBuilder::new().recursive(true).create(path) {
Ok(_) => (),
Err(x) => return Err(format!("Could not create output directory {}", x)),
};
} else if !path.is_dir() {
return Err(format!(
"{} is an existing file (not a directory), cannot create directory",
path.display()
));
}
Some(path.to_owned())
} else {
None
};
let out_file = if matches.get_flag("pretend") {
OutFile::None
} else if matches.get_flag("stdout") {
OutFile::StdOut
} else {
OutFile::Path {
path: matches.get_one::<PathBuf>("output_file").cloned(),
preserve_attrs: matches.get_flag("preserve"),
}
};
opts.optimize_alpha = matches.get_flag("alpha");
opts.scale_16 = matches.get_flag("scale16");
if matches.get_flag("fast") {
opts.fast_evaluation = matches.get_flag("fast");
}
opts.force = matches.get_flag("force");
opts.fix_errors = matches.get_flag("fix");
opts.bit_depth_reduction = !matches.get_flag("no-bit-reduction");
opts.color_type_reduction = !matches.get_flag("no-color-reduction");
opts.palette_reduction = !matches.get_flag("no-palette-reduction");
opts.grayscale_reduction = !matches.get_flag("no-grayscale-reduction");
if matches.get_flag("no-reductions") {
opts.bit_depth_reduction = false;
opts.color_type_reduction = false;
opts.palette_reduction = false;
opts.grayscale_reduction = false;
opts.interlace = None;
}
opts.idat_recoding = !matches.get_flag("no-recoding");
if let Some(x) = matches.get_one::<String>("interlace") {
opts.interlace = if x == "keep" {
None
} else {
x.parse::<u8>().unwrap().try_into().ok()
};
}
if let Some(keep) = matches.get_one::<String>("keep") {
let names = keep
.split(',')
.map(parse_chunk_name)
.collect::<Result<_, _>>()?;
opts.strip = StripChunks::Keep(names)
}
if let Some(strip) = matches.get_one::<String>("strip") {
if strip == "safe" {
opts.strip = StripChunks::Safe;
} else if strip == "all" {
opts.strip = StripChunks::All;
} else {
const FORBIDDEN_CHUNKS: [[u8; 4]; 5] =
[*b"IHDR", *b"IDAT", *b"tRNS", *b"PLTE", *b"IEND"];
let names = strip
.split(',')
.map(|x| {
if x == "safe" || x == "all" {
return Err(
"'safe' or 'all' presets for --strip should be used by themselves"
.to_owned(),
);
}
let name = parse_chunk_name(x)?;
if FORBIDDEN_CHUNKS.contains(&name) {
return Err(format!("{} chunk is not allowed to be stripped", x));
}
Ok(name)
})
.collect::<Result<_, _>>()?;
opts.strip = StripChunks::Strip(names);
}
}
if matches.get_flag("strip-safe") {
opts.strip = StripChunks::Safe;
}
if matches.get_flag("zopfli") {
#[cfg(feature = "zopfli")]
if let Some(iterations) = NonZeroU8::new(15) {
opts.deflate = Deflaters::Zopfli { iterations };
}
} else if let Deflaters::Libdeflater { compression } = &mut opts.deflate {
if let Some(x) = matches.get_one::<i64>("compression") {
*compression = *x as u8;
}
}
#[cfg(feature = "parallel")]
if let Some(&threads) = matches.get_one::<usize>("threads") {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global()
.map_err(|err| err.to_string())?;
}
Ok((out_file, out_dir, opts))
}
fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
name.trim()
.as_bytes()
.try_into()
.map_err(|_| format!("Invalid chunk name {}", name))
}
fn parse_numeric_range_opts(
input: &str,
min_value: u8,
max_value: u8,
) -> Result<IndexSet<u8>, String> {
const ERROR_MESSAGE: &str = "Not a valid input";
let mut items = IndexSet::new();
if let Ok(one_value) = input.parse::<u8>() {
if (min_value <= one_value) && (one_value <= max_value) {
items.insert(one_value);
return Ok(items);
}
}
let range_values = input.split('-').collect::<Vec<&str>>();
if range_values.len() == 2 {
let first_opt = range_values[0].parse::<u8>();
let second_opt = range_values[1].parse::<u8>();
if let (Ok(first), Ok(second)) = (first_opt, second_opt) {
if min_value <= first && first < second && second <= max_value {
for i in first..=second {
items.insert(i);
}
return Ok(items);
}
}
return Err(ERROR_MESSAGE.to_owned());
}
let list_items = input.split(',').collect::<Vec<&str>>();
if list_items.len() > 1 {
for value in list_items {
if let Ok(value_int) = value.parse::<u8>() {
if (min_value <= value_int)
&& (value_int <= max_value)
&& !items.contains(&value_int)
{
items.insert(value_int);
continue;
}
}
return Err(ERROR_MESSAGE.to_owned());
}
return Ok(items);
}
Err(ERROR_MESSAGE.to_owned())
}