545 lines
20 KiB
Rust
545 lines
20 KiB
Rust
extern crate bit_vec;
|
|
extern crate byteorder;
|
|
extern crate crc;
|
|
extern crate libc;
|
|
extern crate libz_sys;
|
|
extern crate miniz_sys;
|
|
extern crate num_cpus;
|
|
extern crate scoped_pool;
|
|
|
|
use scoped_pool::Pool;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs::{File, copy};
|
|
use std::io::{BufWriter, Write, stderr, stdout};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
pub mod deflate {
|
|
pub mod deflate;
|
|
pub mod libz_stream;
|
|
pub mod miniz_stream;
|
|
}
|
|
pub mod png;
|
|
|
|
#[derive(Clone,Debug)]
|
|
/// Options controlling the output of the `optimize` function
|
|
pub struct Options {
|
|
/// Whether the input file should be backed up before writing the output
|
|
pub backup: bool,
|
|
/// Path to write the output file to
|
|
pub out_file: PathBuf,
|
|
/// Used only in CLI interface
|
|
pub out_dir: Option<PathBuf>,
|
|
/// Write to stdout instead of a file
|
|
pub stdout: bool,
|
|
/// Attempt to fix errors when decoding the input file
|
|
pub fix_errors: bool,
|
|
/// Don't actually write any output, just calculate the best results
|
|
pub pretend: bool,
|
|
/// Used only in CLI interface
|
|
pub recursive: bool,
|
|
/// Overwrite existing output files
|
|
pub clobber: bool,
|
|
/// Create new output files if they don't exist
|
|
pub create: bool,
|
|
/// Write to output even if there was no improvement in compression
|
|
pub force: bool,
|
|
/// Ensure the output file has the same permissions as the input file
|
|
pub preserve_attrs: bool,
|
|
/// How verbose the console logging should be (`None` for quiet, `Some(0)` for normal, `Some(1)` for verbose)
|
|
pub verbosity: Option<u8>,
|
|
/// Which filters to try on the file (0-5)
|
|
pub filter: HashSet<u8>,
|
|
/// Whether to change the interlacing type of the file
|
|
/// `None` will not change the current interlacing type
|
|
/// `Some(x)` will change the file to interlacing mode `x`
|
|
pub interlace: Option<u8>,
|
|
/// Which zlib compression levels to try on the file (1-9)
|
|
pub compression: HashSet<u8>,
|
|
/// Which zlib memory levels to try on the file (1-9)
|
|
pub memory: HashSet<u8>,
|
|
/// Which zlib compression strategies to try on the file (0-3)
|
|
pub strategies: HashSet<u8>,
|
|
/// Window size to use when compressing the file, as `2^window` bytes
|
|
/// Doesn't affect compression but may affect speed and memory usage
|
|
/// 15 is recommended default, 8-15 are valid values
|
|
pub window: u8,
|
|
/// Whether to attempt bit depth reduction
|
|
pub bit_depth_reduction: bool,
|
|
/// Whether to attempt color type reduction
|
|
pub color_type_reduction: bool,
|
|
/// Whether to attempt palette reduction
|
|
pub palette_reduction: bool,
|
|
/// Whether to perform IDAT recoding
|
|
/// If any type of reduction is performed, IDAT recoding will be performed
|
|
/// regardless of this setting
|
|
pub idat_recoding: bool,
|
|
/// Which headers to strip from the PNG file, if any
|
|
pub strip: png::Headers,
|
|
/// Whether to use heuristics to pick the best filter and compression
|
|
/// Intended for use with `-o 1` from the CLI interface
|
|
pub use_heuristics: bool,
|
|
/// Number of threads to use, defaults to 1.5x CPU cores, rounded down
|
|
pub threads: usize,
|
|
}
|
|
|
|
impl Default for Options {
|
|
fn default() -> Options {
|
|
// Default settings based on -o 2 from the CLI interface
|
|
let mut filter = HashSet::new();
|
|
filter.insert(0);
|
|
filter.insert(5);
|
|
let mut compression = HashSet::new();
|
|
compression.insert(9);
|
|
let mut memory = HashSet::new();
|
|
memory.insert(9);
|
|
let mut strategies = HashSet::new();
|
|
for i in 0..4 {
|
|
strategies.insert(i);
|
|
}
|
|
|
|
// Default to 1 thread on single-core, otherwise use threads = 1.5x CPU cores
|
|
let num_cpus = num_cpus::get();
|
|
let thread_count = num_cpus + (num_cpus >> 1);
|
|
|
|
Options {
|
|
backup: false,
|
|
out_file: PathBuf::new(),
|
|
out_dir: None,
|
|
stdout: false,
|
|
pretend: false,
|
|
recursive: false,
|
|
fix_errors: false,
|
|
clobber: true,
|
|
create: true,
|
|
force: false,
|
|
preserve_attrs: false,
|
|
verbosity: Some(0),
|
|
filter: filter,
|
|
interlace: None,
|
|
compression: compression,
|
|
memory: memory,
|
|
strategies: strategies,
|
|
window: 15,
|
|
bit_depth_reduction: true,
|
|
color_type_reduction: true,
|
|
palette_reduction: true,
|
|
idat_recoding: true,
|
|
strip: png::Headers::None,
|
|
use_heuristics: false,
|
|
threads: thread_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Perform optimization on the input file using the options provided
|
|
pub fn optimize(filepath: &Path, opts: &Options) -> Result<(), String> {
|
|
// Read in the file and try to decode as PNG.
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Processing: {}", filepath.to_str().unwrap()).ok();
|
|
}
|
|
|
|
let in_file = Path::new(filepath);
|
|
let original_size = in_file.metadata().unwrap().len() as usize;
|
|
let mut png = match png::PngData::new(&in_file, opts.fix_errors) {
|
|
Ok(x) => x,
|
|
Err(x) => return Err(x),
|
|
};
|
|
|
|
// Run the optimizer on the decoded PNG.
|
|
let optimized_output = optimize_png(&mut png, original_size, opts);
|
|
|
|
if is_fully_optimized(original_size, optimized_output.len(), opts) {
|
|
writeln!(&mut stderr(), "File already optimized").ok();
|
|
return Ok(());
|
|
}
|
|
|
|
if opts.pretend {
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Running in pretend mode, no output").ok();
|
|
}
|
|
} else {
|
|
if opts.backup {
|
|
match copy(in_file,
|
|
in_file.with_extension(format!("bak.{}",
|
|
in_file.extension()
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()))) {
|
|
Ok(x) => x,
|
|
Err(_) => {
|
|
return Err(format!("Unable to write to backup file at {}",
|
|
opts.out_file.display()))
|
|
}
|
|
};
|
|
}
|
|
|
|
if opts.stdout {
|
|
let mut buffer = BufWriter::new(stdout());
|
|
match buffer.write_all(&optimized_output) {
|
|
Ok(_) => (),
|
|
Err(_) => return Err("Unable to write to stdout".to_owned()),
|
|
}
|
|
} else {
|
|
let out_file = match File::create(&opts.out_file) {
|
|
Ok(x) => x,
|
|
Err(_) => {
|
|
return Err(format!("Unable to write to file {}", opts.out_file.display()))
|
|
}
|
|
};
|
|
|
|
if opts.preserve_attrs {
|
|
match File::open(filepath) {
|
|
Ok(f) => {
|
|
match f.metadata() {
|
|
Ok(metadata) => {
|
|
// TODO: Implement full permission changing on Unix
|
|
// Not available in stable, requires block cfg statements
|
|
// See https://github.com/rust-lang/rust/issues/15701
|
|
{
|
|
match out_file.metadata() {
|
|
Ok(out_meta) => {
|
|
let readonly = metadata.permissions()
|
|
.readonly();
|
|
out_meta.permissions()
|
|
.set_readonly(readonly);
|
|
}
|
|
Err(_) => {
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(),
|
|
"Failed to set permissions on output file")
|
|
.ok();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(),
|
|
"Failed to read permissions on input file")
|
|
.ok();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Failed to read permissions on input file")
|
|
.ok();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
let mut buffer = BufWriter::new(out_file);
|
|
match buffer.write_all(&optimized_output) {
|
|
Ok(_) => {
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Output: {}", opts.out_file.display()).ok();
|
|
}
|
|
}
|
|
Err(_) => {
|
|
return Err(format!("Unable to write to file {}", opts.out_file.display()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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) -> Result<Vec<u8>, String> {
|
|
// Read in the file and try to decode as PNG.
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Processing from memory");
|
|
}
|
|
let original_size = data.len() as usize;
|
|
let mut png = match png::PngData::from_slice(&data, opts.fix_errors) {
|
|
Ok(x) => x,
|
|
Err(x) => return Err(x),
|
|
};
|
|
|
|
// Run the optimizer on the decoded PNG.
|
|
let optimized_output = optimize_png(&mut png, original_size, opts);
|
|
|
|
match is_fully_optimized(original_size, optimized_output.len(), opts) {
|
|
true => {
|
|
writeln!(&mut stderr(), "Image already optimized").ok();
|
|
Ok(data.to_vec())
|
|
},
|
|
false => Ok(optimized_output)
|
|
}
|
|
}
|
|
|
|
/// Perform optimization on the input PNG object using the options provided
|
|
fn optimize_png(mut png: &mut png::PngData, file_original_size: usize, opts: &Options) -> Vec<u8> {
|
|
type TrialWithData = (u8, u8, u8, u8, Vec<u8>);
|
|
|
|
// Print png info
|
|
let idat_original_size = png.idat_data.len();
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(),
|
|
" {}x{} pixels, PNG format",
|
|
png.ihdr_data.width,
|
|
png.ihdr_data.height)
|
|
.ok();
|
|
if let Some(palette) = png.palette.clone() {
|
|
writeln!(&mut stderr(),
|
|
" {} bits/pixel, {} colors in palette",
|
|
png.ihdr_data.bit_depth,
|
|
palette.len() / 3)
|
|
.ok();
|
|
} else {
|
|
writeln!(&mut stderr(),
|
|
" {}x{} bits/pixel, {:?}",
|
|
png.channels_per_pixel(),
|
|
png.ihdr_data.bit_depth,
|
|
png.ihdr_data.color_type)
|
|
.ok();
|
|
}
|
|
writeln!(&mut stderr(),
|
|
" IDAT size = {} bytes",
|
|
idat_original_size)
|
|
.ok();
|
|
writeln!(&mut stderr(),
|
|
" File size = {} bytes",
|
|
file_original_size)
|
|
.ok();
|
|
}
|
|
|
|
let mut filter = opts.filter.clone();
|
|
let compression = opts.compression.clone();
|
|
let memory = opts.memory.clone();
|
|
let mut strategies = opts.strategies.clone();
|
|
|
|
if opts.use_heuristics {
|
|
// Heuristically determine which set of options to use
|
|
if png.ihdr_data.bit_depth.as_u8() >= 8 &&
|
|
png.ihdr_data.color_type != png::ColorType::Indexed {
|
|
if filter.is_empty() {
|
|
filter.insert(5);
|
|
}
|
|
if strategies.is_empty() {
|
|
strategies.insert(1);
|
|
}
|
|
} else {
|
|
if filter.is_empty() {
|
|
filter.insert(0);
|
|
}
|
|
if strategies.is_empty() {
|
|
strategies.insert(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
let something_changed = perform_reductions(&mut png, &opts);
|
|
|
|
if opts.idat_recoding || something_changed {
|
|
let thread_count = opts.threads;
|
|
let pool = Pool::new(thread_count);
|
|
// Go through selected permutations and determine the best
|
|
let best: Arc<Mutex<Option<TrialWithData>>> = Arc::new(Mutex::new(None));
|
|
let combinations = filter.len() * compression.len() * memory.len() * strategies.len();
|
|
let mut results: Vec<(u8, u8, u8, u8)> = Vec::with_capacity(combinations);
|
|
let mut filters: HashMap<u8, Vec<u8>> = HashMap::with_capacity(filter.len());
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Trying: {} combinations", combinations).ok();
|
|
}
|
|
|
|
for f in &filter {
|
|
let filtered = png.filter_image(*f);
|
|
filters.insert(*f, filtered.clone());
|
|
for zc in &compression {
|
|
for zm in &memory {
|
|
for zs in &strategies {
|
|
results.push((*f, *zc, *zm, *zs));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pool.scoped(|scope| {
|
|
let original_len = png.idat_data.len();
|
|
let interlacing_changed = opts.interlace.is_some() &&
|
|
opts.interlace != Some(png.ihdr_data.interlaced);
|
|
for trial in &results {
|
|
let filtered = filters.get(&trial.0).unwrap();
|
|
let best = best.clone();
|
|
scope.execute(move || {
|
|
let new_idat = deflate::deflate::deflate(filtered,
|
|
trial.1,
|
|
trial.2,
|
|
trial.3,
|
|
opts.window)
|
|
.unwrap();
|
|
|
|
if opts.verbosity == Some(1) {
|
|
writeln!(&mut stderr(),
|
|
" zc = {} zm = {} zs = {} f = {} {} bytes",
|
|
trial.1,
|
|
trial.2,
|
|
trial.3,
|
|
trial.0,
|
|
new_idat.len())
|
|
.ok();
|
|
}
|
|
|
|
let mut best = best.lock().unwrap();
|
|
if (best.is_some() &&
|
|
new_idat.len() < best.as_ref().map(|x| x.4.len()).unwrap()) ||
|
|
(best.is_none() &&
|
|
(new_idat.len() < original_len || interlacing_changed || opts.force)) {
|
|
*best = Some((trial.0, trial.1, trial.2, trial.3, new_idat));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
let mut final_best = best.lock().unwrap();
|
|
if let Some(better) = final_best.take() {
|
|
png.idat_data = better.4.clone();
|
|
if opts.verbosity.is_some() {
|
|
writeln!(&mut stderr(), "Found better combination:").ok();
|
|
writeln!(&mut stderr(),
|
|
" zc = {} zm = {} zs = {} f = {} {} bytes",
|
|
better.1,
|
|
better.2,
|
|
better.3,
|
|
better.0,
|
|
png.idat_data.len())
|
|
.ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
perform_strip(&mut png, &opts);
|
|
|
|
let output = png.output();
|
|
|
|
if opts.verbosity.is_some() {
|
|
if idat_original_size >= png.idat_data.len() {
|
|
writeln!(&mut stderr(),
|
|
" IDAT size = {} bytes ({} bytes decrease)",
|
|
png.idat_data.len(),
|
|
idat_original_size - png.idat_data.len())
|
|
.ok();
|
|
} else {
|
|
writeln!(&mut stderr(),
|
|
" IDAT size = {} bytes ({} bytes increase)",
|
|
png.idat_data.len(),
|
|
png.idat_data.len() - idat_original_size)
|
|
.ok();
|
|
}
|
|
if file_original_size >= output.len() {
|
|
writeln!(&mut stderr(),
|
|
" file size = {} bytes ({} bytes = {:.2}% decrease)",
|
|
output.len(),
|
|
file_original_size - output.len(),
|
|
(file_original_size - output.len()) as f64 / file_original_size as f64 *
|
|
100f64)
|
|
.ok();
|
|
} else {
|
|
writeln!(&mut stderr(),
|
|
" file size = {} bytes ({} bytes = {:.2}% increase)",
|
|
output.len(),
|
|
output.len() - file_original_size,
|
|
(output.len() - file_original_size) as f64 / file_original_size as f64 *
|
|
100f64)
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
fn perform_reductions(png: &mut png::PngData, opts: &Options) -> bool {
|
|
let mut something_changed = false;
|
|
|
|
if opts.palette_reduction {
|
|
if png.reduce_palette() {
|
|
something_changed = true;
|
|
if opts.verbosity == Some(1) {
|
|
report_reduction(&png);
|
|
}
|
|
};
|
|
}
|
|
|
|
if opts.bit_depth_reduction {
|
|
if png.reduce_bit_depth() {
|
|
something_changed = true;
|
|
if opts.verbosity == Some(1) {
|
|
report_reduction(&png);
|
|
}
|
|
};
|
|
}
|
|
|
|
if opts.color_type_reduction {
|
|
if png.reduce_color_type() {
|
|
something_changed = true;
|
|
if opts.verbosity == Some(1) {
|
|
report_reduction(&png);
|
|
}
|
|
};
|
|
}
|
|
|
|
if something_changed && opts.verbosity.is_some() {
|
|
report_reduction(&png);
|
|
}
|
|
|
|
if let Some(interlacing) = opts.interlace {
|
|
if png.change_interlacing(interlacing) {
|
|
png.ihdr_data.interlaced = interlacing;
|
|
something_changed = true;
|
|
}
|
|
}
|
|
|
|
something_changed
|
|
}
|
|
|
|
fn report_reduction(png: &png::PngData) {
|
|
if let Some(palette) = png.palette.clone() {
|
|
writeln!(&mut stderr(),
|
|
"Reducing image to {} bits/pixel, {} colors in palette",
|
|
png.ihdr_data.bit_depth,
|
|
palette.len() / 3)
|
|
.ok();
|
|
} else {
|
|
writeln!(&mut stderr(),
|
|
"Reducing image to {}x{} bits/pixel, {}",
|
|
png.channels_per_pixel(),
|
|
png.ihdr_data.bit_depth,
|
|
png.ihdr_data.color_type)
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
fn perform_strip(png: &mut png::PngData, opts: &Options) {
|
|
match opts.strip.clone() {
|
|
// Strip headers
|
|
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 {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool {
|
|
return original_size <= optimized_size && !opts.force && opts.interlace.is_none()
|
|
}
|