Feature/manpage (#596)
This PR adds a build script to generate a man page using clap_mangen, as per this example: https://github.com/sondr3/clap-man-example/blob/main/build.rs I'm not sure what to actually do with the man file from here, I guess it's up to the packaging process to do something with it? See https://github.com/shssoichiro/oxipng/issues/69#issuecomment-1963352536 Note I couldn't see a way to include the `DISPLAY` chunk names from the constant as we did before. They're now just hardcoded into the help and will require manually updating if the list changes. Closes #526 --------- Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
parent
2c3321b68a
commit
f4e631bce7
9 changed files with 439 additions and 360 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ target
|
||||||
*.out.png
|
*.out.png
|
||||||
/.idea
|
/.idea
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/generated
|
||||||
|
|
|
||||||
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -134,6 +134,16 @@ version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
|
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_mangen"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f2e32b579dae093c2424a8b7e2bea09c89da01e1ce5065eb2f0a6f1cc15cc1f"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"roff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -455,6 +465,7 @@ version = "9.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitvec",
|
"bitvec",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_mangen",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"filetime",
|
"filetime",
|
||||||
|
|
@ -529,6 +540,12 @@ dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roff"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
|
||||||
10
Cargo.toml
10
Cargo.toml
|
|
@ -78,6 +78,8 @@ features = ["png"]
|
||||||
version = "0.24.6"
|
version = "0.24.6"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
clap = "4.3.8"
|
||||||
|
clap_mangen = "0.2.12"
|
||||||
rustc_version = "0.4.0"
|
rustc_version = "0.4.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
@ -98,3 +100,11 @@ opt-level = 2
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
assets = [
|
||||||
|
["target/release/oxipng", "usr/bin/", "755"],
|
||||||
|
["generated/assets/oxipng.1", "usr/share/man/man1/", "644"],
|
||||||
|
["README.md", "usr/share/doc/oxipng/", "644"],
|
||||||
|
["CHANGELOG.md", "usr/share/doc/oxipng/", "644"],
|
||||||
|
]
|
||||||
|
|
|
||||||
34
build.rs
Normal file
34
build.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
fs::File,
|
||||||
|
io::{BufWriter, Error},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use clap_mangen::Man;
|
||||||
|
|
||||||
|
include!("src/cli.rs");
|
||||||
|
|
||||||
|
fn build_manpages(outdir: &Path) -> Result<(), Error> {
|
||||||
|
let app = build_command();
|
||||||
|
|
||||||
|
let file = Path::new(&outdir).join("oxipng.1");
|
||||||
|
let mut file = BufWriter::new(File::create(file)?);
|
||||||
|
|
||||||
|
Man::new(app).render(&mut file)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Error> {
|
||||||
|
println!("cargo:rerun-if-changed=src/cli.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/display_chunks.rs");
|
||||||
|
|
||||||
|
// Create `generated/assets/` folder.
|
||||||
|
let path = env::current_dir()?.join("generated").join("assets");
|
||||||
|
std::fs::create_dir_all(&path).unwrap();
|
||||||
|
|
||||||
|
build_manpages(&path)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
357
src/cli.rs
Normal file
357
src/cli.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{value_parser, Arg, ArgAction, Command};
|
||||||
|
|
||||||
|
include!("display_chunks.rs");
|
||||||
|
|
||||||
|
pub fn build_command() -> Command {
|
||||||
|
// Note: clap 'wrap_help' is enabled to automatically wrap lines according to terminal width.
|
||||||
|
// To keep things tidy though, short help descriptions should be no more than 54 characters,
|
||||||
|
// so that they can fit on a single line in an 80 character terminal.
|
||||||
|
// Long help descriptions are soft wrapped here at 90 characters (column 91) but this does not
|
||||||
|
// affect output, it simply matches what is rendered when help is output to a file.
|
||||||
|
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.",
|
||||||
|
DISPLAY_CHUNKS
|
||||||
|
.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_help("\
|
||||||
|
Strip all metadata chunks except those in the comma-separated list. The special value \
|
||||||
|
'display' includes chunks that affect the image appearance, equivalent to '--strip safe'.
|
||||||
|
|
||||||
|
E.g. '--keep eXIf,display' will strip chunks, keeping only eXIf and those that affect the \
|
||||||
|
image appearance.")
|
||||||
|
.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("Filters to try (0-9; see '--help' for details)")
|
||||||
|
.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"),
|
||||||
|
)
|
||||||
|
.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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/display_chunks.rs
Normal file
4
src/display_chunks.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/// List of chunks that affect image display and will be kept when using the `Safe` chunk strip option
|
||||||
|
pub const DISPLAY_CHUNKS: [[u8; 4]; 7] = [
|
||||||
|
*b"cICP", *b"iCCP", *b"sRGB", *b"pHYs", *b"acTL", *b"fcTL", *b"fdAT",
|
||||||
|
];
|
||||||
|
|
@ -5,6 +5,7 @@ use rgb::{RGB16, RGBA8};
|
||||||
use crate::{
|
use crate::{
|
||||||
colors::{BitDepth, ColorType},
|
colors::{BitDepth, ColorType},
|
||||||
deflate::{crc32, inflate},
|
deflate::{crc32, inflate},
|
||||||
|
display_chunks::DISPLAY_CHUNKS,
|
||||||
error::PngError,
|
error::PngError,
|
||||||
interlace::Interlacing,
|
interlace::Interlacing,
|
||||||
AtomicMin, Deflaters, PngResult,
|
AtomicMin, Deflaters, PngResult,
|
||||||
|
|
@ -86,17 +87,12 @@ pub enum StripChunks {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StripChunks {
|
impl StripChunks {
|
||||||
/// List of chunks that affect image display and will be kept when using the `Safe` option
|
|
||||||
pub const DISPLAY: [[u8; 4]; 7] = [
|
|
||||||
*b"cICP", *b"iCCP", *b"sRGB", *b"pHYs", *b"acTL", *b"fcTL", *b"fdAT",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(crate) fn keep(&self, name: &[u8; 4]) -> bool {
|
pub(crate) fn keep(&self, name: &[u8; 4]) -> bool {
|
||||||
match &self {
|
match &self {
|
||||||
StripChunks::None => true,
|
StripChunks::None => true,
|
||||||
StripChunks::Keep(names) => names.contains(name),
|
StripChunks::Keep(names) => names.contains(name),
|
||||||
StripChunks::Strip(names) => !names.contains(name),
|
StripChunks::Strip(names) => !names.contains(name),
|
||||||
StripChunks::Safe => Self::DISPLAY.contains(name),
|
StripChunks::Safe => DISPLAY_CHUNKS.contains(name),
|
||||||
StripChunks::All => false,
|
StripChunks::All => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ pub use crate::{
|
||||||
mod atomicmin;
|
mod atomicmin;
|
||||||
mod colors;
|
mod colors;
|
||||||
mod deflate;
|
mod deflate;
|
||||||
|
mod display_chunks;
|
||||||
mod error;
|
mod error;
|
||||||
mod evaluate;
|
mod evaluate;
|
||||||
mod filters;
|
mod filters;
|
||||||
|
|
|
||||||
367
src/main.rs
367
src/main.rs
|
|
@ -20,365 +20,24 @@ mod rayon;
|
||||||
use std::num::NonZeroU8;
|
use std::num::NonZeroU8;
|
||||||
use std::{ffi::OsString, fs::DirBuilder, io::Write, path::PathBuf, process::exit, time::Duration};
|
use std::{ffi::OsString, fs::DirBuilder, io::Write, path::PathBuf, process::exit, time::Duration};
|
||||||
|
|
||||||
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
|
use clap::ArgMatches;
|
||||||
|
mod cli;
|
||||||
use indexmap::IndexSet;
|
use indexmap::IndexSet;
|
||||||
use log::{error, warn, Level, LevelFilter};
|
use log::{error, warn, Level, LevelFilter};
|
||||||
use oxipng::{Deflaters, InFile, Options, OutFile, RowFilter, StripChunks};
|
use oxipng::{Deflaters, InFile, Options, OutFile, RowFilter, StripChunks};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
use crate::cli::DISPLAY_CHUNKS;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Note: clap 'wrap_help' is enabled to automatically wrap lines according to terminal width.
|
let matches = cli::build_command()
|
||||||
// To keep things tidy though, short help descriptions should be no more than 54 characters,
|
// Set the value parser for filters which isn't appropriate to do in the build_command function
|
||||||
// so that they can fit on a single line in an 80 character terminal.
|
.mut_arg("filters", |arg| {
|
||||||
// Long help descriptions are soft wrapped here at 90 characters (column 91) but this does not
|
arg.value_parser(|x: &str| {
|
||||||
// affect output, it simply matches what is rendered when help is output to a file.
|
parse_numeric_range_opts(x, 0, RowFilter::LAST)
|
||||||
let matches = Command::new("oxipng")
|
.map_err(|_| "Invalid option for filters")
|
||||||
.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::DISPLAY
|
|
||||||
.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_help("\
|
|
||||||
Strip all metadata chunks except those in the comma-separated list. The special value \
|
|
||||||
'display' includes chunks that affect the image appearance, equivalent to '--strip safe'.
|
|
||||||
|
|
||||||
E.g. '--keep eXIf,display' will strip chunks, keeping only eXIf and those that affect the \
|
|
||||||
image appearance.")
|
|
||||||
.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_help("Run `oxipng --help` to see full details of all options")
|
||||||
.after_long_help("")
|
.after_long_help("")
|
||||||
.get_matches_from(std::env::args());
|
.get_matches_from(std::env::args());
|
||||||
|
|
@ -624,7 +283,7 @@ fn parse_opts_into_struct(
|
||||||
})
|
})
|
||||||
.collect::<Result<IndexSet<_>, _>>()?;
|
.collect::<Result<IndexSet<_>, _>>()?;
|
||||||
if keep_display {
|
if keep_display {
|
||||||
names.extend(StripChunks::DISPLAY.iter().cloned());
|
names.extend(DISPLAY_CHUNKS.iter().cloned());
|
||||||
}
|
}
|
||||||
opts.strip = StripChunks::Keep(names)
|
opts.strip = StripChunks::Keep(names)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue