diff --git a/src/colors.rs b/src/colors.rs index 59a0c360..22f24f19 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -56,7 +56,7 @@ impl ColorType { } #[inline] - pub fn channels_per_pixel(&self) -> u8 { + pub(crate) fn channels_per_pixel(&self) -> u8 { match self { ColorType::Grayscale { .. } | ColorType::Indexed { .. } => 1, ColorType::GrayscaleAlpha => 2, @@ -66,12 +66,12 @@ impl ColorType { } #[inline] - pub fn is_rgb(&self) -> bool { + pub(crate) fn is_rgb(&self) -> bool { matches!(self, ColorType::RGB { .. } | ColorType::RGBA) } #[inline] - pub fn has_alpha(&self) -> bool { + pub(crate) fn has_alpha(&self) -> bool { matches!(self, ColorType::GrayscaleAlpha | ColorType::RGBA) } diff --git a/src/error.rs b/src/error.rs index 0f099d5d..550f0316 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use crate::colors::{BitDepth, ColorType}; use std::error::Error; use std::fmt; @@ -11,6 +12,8 @@ pub enum PngError { InvalidData, TruncatedData, ChunkMissing(&'static str), + InvalidDepthForType(BitDepth, ColorType), + IncorrectDataLength(usize, usize), Other(Box), } @@ -30,6 +33,14 @@ impl fmt::Display for PngError { } PngError::APNGNotSupported => f.write_str("APNG files are not (yet) supported"), PngError::ChunkMissing(s) => write!(f, "Chunk {} missing or empty", s), + PngError::InvalidDepthForType(d, ref c) => { + write!(f, "Invalid bit depth {} for color type {}", d, c) + } + PngError::IncorrectDataLength(l1, l2) => write!( + f, + "Data length {} does not match the expected length {}", + l1, l2 + ), PngError::Other(ref s) => f.write_str(s), } } diff --git a/src/evaluate.rs b/src/evaluate.rs index 833f41c6..15763a2c 100644 --- a/src/evaluate.rs +++ b/src/evaluate.rs @@ -81,7 +81,7 @@ impl Evaluator { } /// Wait for all evaluations to finish and return smallest reduction - /// Or `None` if all reductions were worse than baseline. + /// Or `None` if the queue is empty. #[cfg(feature = "parallel")] pub fn get_best_candidate(self) -> Option { let (eval_send, eval_recv) = self.eval_channel; diff --git a/src/filters.rs b/src/filters.rs index 1fec39e6..a2136139 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -53,10 +53,11 @@ impl Display for RowFilter { impl RowFilter { pub const LAST: u8 = Self::Brute as u8; - pub const STANDARD: [Self; 5] = [Self::None, Self::Sub, Self::Up, Self::Average, Self::Paeth]; - pub const SINGLE_LINE: [Self; 2] = [Self::None, Self::Sub]; + pub(crate) const STANDARD: [Self; 5] = + [Self::None, Self::Sub, Self::Up, Self::Average, Self::Paeth]; + pub(crate) const SINGLE_LINE: [Self; 2] = [Self::None, Self::Sub]; - pub fn filter_line( + pub(crate) fn filter_line( self, bpp: usize, data: &mut [u8], @@ -176,7 +177,7 @@ impl RowFilter { } } - pub fn unfilter_line( + pub(crate) fn unfilter_line( self, bpp: usize, data: &[u8], diff --git a/src/headers.rs b/src/headers.rs index a3e2b4f9..6511f604 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -19,10 +19,6 @@ pub struct IhdrData { pub color_type: ColorType, /// The bit depth of the image pub bit_depth: BitDepth, - /// The compression method used for this image (0 for DEFLATE) - pub compression: u8, - /// The filter mode used for this image (currently only 0 is valid) - pub filter: u8, /// The interlacing mode of the image pub interlaced: Interlacing, } @@ -176,8 +172,6 @@ pub fn parse_ihdr_header( bit_depth: byte_data[8].try_into()?, width: read_be_u32(&mut rdr).map_err(|_| PngError::TruncatedData)?, height: read_be_u32(&mut rdr).map_err(|_| PngError::TruncatedData)?, - compression: byte_data[10], - filter: byte_data[11], interlaced: interlaced.try_into()?, }) } diff --git a/src/lib.rs b/src/lib.rs index 2b27c3c0..c3927d45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ mod rayon; use crate::atomicmin::AtomicMin; use crate::deflate::{crc32, inflate}; use crate::evaluate::Evaluator; +use crate::headers::IhdrData; use crate::png::PngData; use crate::png::PngImage; use crate::reduction::*; @@ -40,12 +41,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; +pub use crate::colors::{BitDepth, ColorType}; pub use crate::deflate::Deflaters; pub use crate::error::PngError; pub use crate::filters::RowFilter; pub use crate::headers::Headers; pub use crate::interlace::Interlacing; pub use indexmap::{indexset, IndexMap, IndexSet}; +pub use rgb::{RGB16, RGBA8}; mod atomicmin; mod colors; @@ -57,6 +60,8 @@ mod headers; mod interlace; mod png; mod reduction; +#[cfg(feature = "sanity-checks")] +mod sanity_checks; /// Private to oxipng; don't use outside tests and benches #[doc(hidden)] @@ -67,6 +72,8 @@ pub mod internal_tests { pub use crate::headers::*; pub use crate::png::*; pub use crate::reduction::*; + #[cfg(feature = "sanity-checks")] + pub use crate::sanity_checks::*; } #[derive(Clone, Debug)] @@ -307,6 +314,88 @@ impl Default for Options { } } +#[derive(Debug)] +/// A raw image definition which can be used to create an optimized png +pub struct RawImage { + png: Arc, +} + +impl RawImage { + /// Construct a new raw image definition + /// + /// * `width` - The width of the image in pixels + /// * `height` - The height of the image in pixels + /// * `color_type` - The color type of the image + /// * `bit_depth` - The bit depth of the image + /// * `data` - The raw pixel data of the image + pub fn new( + width: u32, + height: u32, + color_type: ColorType, + bit_depth: BitDepth, + data: Vec, + ) -> Result { + // Validate bit depth + let valid_depth = match color_type { + ColorType::Grayscale { .. } => true, + ColorType::Indexed { .. } => (bit_depth as u8) <= 8, + _ => (bit_depth as u8) >= 8, + }; + if !valid_depth { + return Err(PngError::InvalidDepthForType(bit_depth, color_type)); + } + + // Validate data length + let bpp = bit_depth as usize * color_type.channels_per_pixel() as usize; + let row_bytes = (bpp * width as usize + 7) / 8; + let expected_len = row_bytes * height as usize; + if data.len() != expected_len { + return Err(PngError::IncorrectDataLength(data.len(), expected_len)); + } + + Ok(Self { + png: Arc::new(PngImage { + ihdr: IhdrData { + width, + height, + color_type, + bit_depth, + interlaced: Interlacing::None, + }, + data, + aux_headers: IndexMap::new(), + }), + }) + } + + /// Add a png chunk, such as "iTXt", to be included in the output + pub fn add_png_chunk(&mut self, chunk_type: [u8; 4], data: Vec) { + // We can guarantee this will succeed - failure indicates a bug + let png = Arc::get_mut(&mut self.png).unwrap(); + png.aux_headers.insert(chunk_type, data); + } + + /// Add an ICC profile for the image + pub fn add_icc_profile(&mut self, data: &[u8]) { + // Compress with default compression level + if let Ok(mut compressed) = deflate::deflate(data, 11, &AtomicMin::new(None)) { + let mut iccp = Vec::with_capacity(compressed.len() + 13); + iccp.extend(b"icc"); // Profile name - generally unused, can be anything + iccp.extend([0, 0]); // Null separator, zlib compression method + iccp.append(&mut compressed); + self.add_png_chunk(*b"iCCP", iccp); + } + } + + /// Create an optimized png from the raw image data using the options provided + pub fn create_optimized_png(&self, opts: &Options) -> PngResult> { + let deadline = Arc::new(Deadline::new(opts.timeout)); + let png = optimize_raw(Arc::clone(&self.png), opts, deadline, None) + .ok_or_else(|| PngError::new("Failed to optimize input data"))?; + Ok(png.output()) + } +} + /// Perform optimization on the input file using the options provided pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> PngResult<()> { // Read in the file and try to decode as PNG. @@ -474,141 +563,10 @@ fn optimize_png( // Do this first so that reductions can ignore certain chunks such as bKGD perform_strip(png, opts); - let stripped_png = png.clone(); - // Must use normal (lazy) compression, as faster ones (greedy) are not representative - let eval_compression = 5; - // None and Bigrams work well together, especially for alpha reductions - let eval_filters = indexset! {RowFilter::None, RowFilter::Bigrams}; - // This will collect all versions of images and pick one that compresses best - let eval = Evaluator::new( - deadline.clone(), - eval_filters.clone(), - eval_compression, - false, - ); - let (baseline, mut reduction_occurred) = - perform_reductions(png.raw.clone(), opts, &deadline, &eval); - png.raw = baseline; - let mut eval_filter = if let Some(result) = eval.get_best_candidate() { - *png = result.image; - if result.is_reduction { - reduction_occurred = true; - } - Some(result.filter) - } else { - None - }; - - if reduction_occurred { - report_format("Reducing image to ", &png.raw); - } - - if opts.idat_recoding || reduction_occurred { - let mut filters = opts.filter.clone(); - let fast_eval = opts.fast_evaluation && (filters.len() > 1 || eval_filter.is_some()); - let best: Option = if fast_eval { - // Perform a fast evaluation of selected filters followed by a single main compression trial - - if eval_filter.is_some() { - // Some filters have already been evaluated, we don't need to try them again - filters = filters.difference(&eval_filters).cloned().collect(); - } - - if !filters.is_empty() { - trace!("Evaluating: {} filters", filters.len()); - let eval = Evaluator::new(deadline, filters, eval_compression, opts.optimize_alpha); - if eval_filter.is_some() { - eval.set_best_size(png.idat_data.len()); - } - eval.try_image(png.raw.clone()); - if let Some(result) = eval.get_best_candidate() { - *png = result.image; - eval_filter = Some(result.filter); - } - } - - let trial = TrialOptions { - filter: eval_filter.unwrap(), - compression: match opts.deflate { - Deflaters::Libdeflater { compression } => compression, - _ => 0, - }, - }; - if trial.compression > 0 && trial.compression <= eval_compression { - // No further compression required - if png.idat_data.len() < idat_original_size || opts.force { - Some((trial, png.idat_data.clone())) - } else { - None - } - } else { - debug!("Trying: {}", trial.filter); - let original_len = idat_original_size; - let best_size = AtomicMin::new(if opts.force { None } else { Some(original_len) }); - perform_trial(&png.filtered, opts, trial, &best_size) - } - } else { - // Perform full compression trials of selected filters and determine the best - - if filters.is_empty() { - // Pick a filter automatically - if png.raw.ihdr.bit_depth as u8 >= 8 { - // Bigrams is the best all-rounder when there's at least one byte per pixel - filters.insert(RowFilter::Bigrams); - } else { - // Otherwise delta filters generally don't work well, so just stick with None - filters.insert(RowFilter::None); - } - } - - let mut results: Vec = Vec::with_capacity(filters.len()); - - for f in &filters { - results.push(TrialOptions { - filter: *f, - compression: match opts.deflate { - Deflaters::Libdeflater { compression } => compression, - _ => 0, - }, - }); - } - - debug!("Trying: {} filters", results.len()); - - let original_len = idat_original_size; - let best_size = AtomicMin::new(if opts.force { None } else { Some(original_len) }); - let results_iter = results.into_par_iter().with_max_len(1); - let best = results_iter.filter_map(|trial| { - if deadline.passed() { - return None; - } - let filtered = &png.raw.filter_image(trial.filter, opts.optimize_alpha); - perform_trial(filtered, opts, trial, &best_size) - }); - best.reduce_with(|i, j| { - if i.1.len() < j.1.len() || (i.1.len() == j.1.len() && i.0 < j.0) { - i - } else { - j - } - }) - }; - - if let Some((opts, idat_data)) = best { - png.idat_data = idat_data; - debug!("Found better combination:"); - debug!( - " zc = {} f = {:8} {} bytes", - opts.compression, - opts.filter, - png.idat_data.len() - ); - } else { - *png = stripped_png; - } - } else if png.idat_data.len() >= idat_original_size { - *png = stripped_png; + if let Some(new_png) = optimize_raw(png.raw.clone(), opts, deadline, Some(idat_original_size)) { + png.raw = new_png.raw; + png.idat_data = new_png.idat_data; } let output = png.output(); @@ -643,11 +601,169 @@ fn optimize_png( } #[cfg(feature = "sanity-checks")] - debug_assert!(sanity_checks::validate_output(&output, original_data)); + assert!(sanity_checks::validate_output(&output, original_data)); Ok(output) } +/// Perform optimization on the input image data using the options provided +fn optimize_raw( + mut png: Arc, + opts: &Options, + deadline: Arc, + max_idat_size: Option, +) -> Option { + // Must use normal (lazy) compression, as faster ones (greedy) are not representative + let eval_compression = 5; + // None and Bigrams work well together, especially for alpha reductions + let eval_filters = indexset! {RowFilter::None, RowFilter::Bigrams}; + // This will collect all versions of images and pick one that compresses best + let eval = Evaluator::new( + deadline.clone(), + eval_filters.clone(), + eval_compression, + false, + ); + let (baseline, mut reduction_occurred) = + perform_reductions(png.clone(), opts, &deadline, &eval); + png = baseline; + let mut eval_result = eval.get_best_candidate(); + if let Some(ref result) = eval_result { + if result.is_reduction { + png = Arc::clone(&result.image.raw); + reduction_occurred = true; + } + } + + if reduction_occurred { + report_format("Reducing image to ", &png); + } + + if opts.idat_recoding || reduction_occurred { + let mut filters = opts.filter.clone(); + let fast_eval = opts.fast_evaluation && (filters.len() > 1 || eval_result.is_some()); + let best: Option = if fast_eval { + // Perform a fast evaluation of selected filters followed by a single main compression trial + + if eval_result.is_some() { + // Some filters have already been evaluated, we don't need to try them again + filters = filters.difference(&eval_filters).cloned().collect(); + } + + if !filters.is_empty() { + trace!("Evaluating: {} filters", filters.len()); + let eval = Evaluator::new(deadline, filters, eval_compression, opts.optimize_alpha); + if let Some(ref result) = eval_result { + eval.set_best_size(result.image.idat_data.len()); + } + eval.try_image(png.clone()); + if let Some(result) = eval.get_best_candidate() { + eval_result = Some(result); + } + } + // We should have a result here - fail if not (e.g. deadline passed) + let eval_result = eval_result?; + + let trial = TrialOptions { + filter: eval_result.filter, + compression: match opts.deflate { + Deflaters::Libdeflater { compression } => compression, + _ => 0, + }, + }; + if trial.compression > 0 && trial.compression <= eval_compression { + // No further compression required + let idat_data = eval_result.image.idat_data; + if opts.force || idat_data.len() < max_idat_size.unwrap_or(usize::MAX) { + Some((trial, idat_data)) + } else { + None + } + } else { + debug!("Trying: {}", trial.filter); + let best_size = AtomicMin::new(if opts.force { None } else { max_idat_size }); + perform_trial(&eval_result.image.filtered, opts, trial, &best_size) + } + } else { + // Perform full compression trials of selected filters and determine the best + + if filters.is_empty() { + // Pick a filter automatically + if png.ihdr.bit_depth as u8 >= 8 { + // Bigrams is the best all-rounder when there's at least one byte per pixel + filters.insert(RowFilter::Bigrams); + } else { + // Otherwise delta filters generally don't work well, so just stick with None + filters.insert(RowFilter::None); + } + } + + let mut results: Vec = Vec::with_capacity(filters.len()); + + for f in &filters { + results.push(TrialOptions { + filter: *f, + compression: match opts.deflate { + Deflaters::Libdeflater { compression } => compression, + _ => 0, + }, + }); + } + + debug!("Trying: {} filters", results.len()); + + let best_size = AtomicMin::new(if opts.force { None } else { max_idat_size }); + let results_iter = results.into_par_iter().with_max_len(1); + let best = results_iter.filter_map(|trial| { + if deadline.passed() { + return None; + } + let filtered = &png.filter_image(trial.filter, opts.optimize_alpha); + perform_trial(filtered, opts, trial, &best_size) + }); + best.reduce_with(|i, j| { + if i.1.len() < j.1.len() || (i.1.len() == j.1.len() && i.0 < j.0) { + i + } else { + j + } + }) + }; + + if let Some((opts, idat_data)) = best { + debug!("Found better combination:"); + debug!( + " zc = {} f = {:8} {} bytes", + opts.compression, + opts.filter, + idat_data.len() + ); + return Some(PngData { + raw: png, + // The filtered data has not been retained here, but we don't need to return it + filtered: vec![], + idat_data, + }); + } + } else if let Some(result) = eval_result { + // If idat_recoding is off and reductions were attempted but ended up choosing the baseline, + // we should still check if the evaluator compressed the baseline smaller than the original. + let idat_data = &result.image.idat_data; + if idat_data.len() < max_idat_size.unwrap_or(usize::MAX) { + debug!("Found better combination:"); + debug!( + " zc = {} f = {:8} {} bytes", + eval_compression, + result.filter, + idat_data.len() + ); + return Some(result.image); + } + } + + None +} + /// Execute a compression trial fn perform_trial( filtered: &[u8], @@ -958,55 +1074,3 @@ fn copy_times(input_path_meta: &Metadata, out_path: &Path) -> PngResult<()> { )) }) } - -#[cfg(feature = "sanity-checks")] -mod sanity_checks { - use super::*; - use image::{DynamicImage, GenericImageView, ImageFormat, Pixel}; - use log::error; - use std::io::Cursor; - - /// Validate that the output png data still matches the original image - pub(super) fn validate_output(output: &[u8], original_data: &[u8]) -> bool { - let (old_png, new_png) = rayon::join( - || load_png_image_from_memory(original_data), - || load_png_image_from_memory(output), - ); - - match (new_png, old_png) { - (Err(new_err), _) => { - error!("Failed to read output image for validation: {}", new_err); - false - } - (_, Err(old_err)) => { - // The original image might be invalid if, for example, there is a CRC error, - // and we set fix_errors to true. In that case, all we can do is check that the - // new image is decodable. - warn!("Failed to read input image for validation: {}", old_err); - true - } - (Ok(new_png), Ok(old_png)) => images_equal(&old_png, &new_png), - } - } - - /// Loads a PNG image from memory to a [DynamicImage] - fn load_png_image_from_memory(png_data: &[u8]) -> Result { - let mut reader = image::io::Reader::new(Cursor::new(png_data)); - reader.set_format(ImageFormat::Png); - reader.no_limits(); - reader.decode() - } - - /// Compares images pixel by pixel for equivalent content - fn images_equal(old_png: &DynamicImage, new_png: &DynamicImage) -> bool { - let a = old_png.pixels().filter(|x| { - let p = x.2.channels(); - !(p.len() == 4 && p[3] == 0) - }); - let b = new_png.pixels().filter(|x| { - let p = x.2.channels(); - !(p.len() == 4 && p[3] == 0) - }); - a.eq(b) - } -} diff --git a/src/sanity_checks.rs b/src/sanity_checks.rs new file mode 100644 index 00000000..496b5dfd --- /dev/null +++ b/src/sanity_checks.rs @@ -0,0 +1,47 @@ +use image::{DynamicImage, GenericImageView, ImageFormat, Pixel}; +use log::{error, warn}; +use std::io::Cursor; + +/// Validate that the output png data still matches the original image +pub fn validate_output(output: &[u8], original_data: &[u8]) -> bool { + let (old_png, new_png) = rayon::join( + || load_png_image_from_memory(original_data), + || load_png_image_from_memory(output), + ); + + match (new_png, old_png) { + (Err(new_err), _) => { + error!("Failed to read output image for validation: {}", new_err); + false + } + (_, Err(old_err)) => { + // The original image might be invalid if, for example, there is a CRC error, + // and we set fix_errors to true. In that case, all we can do is check that the + // new image is decodable. + warn!("Failed to read input image for validation: {}", old_err); + true + } + (Ok(new_png), Ok(old_png)) => images_equal(&old_png, &new_png), + } +} + +/// Loads a PNG image from memory to a [DynamicImage] +fn load_png_image_from_memory(png_data: &[u8]) -> Result { + let mut reader = image::io::Reader::new(Cursor::new(png_data)); + reader.set_format(ImageFormat::Png); + reader.no_limits(); + reader.decode() +} + +/// Compares images pixel by pixel for equivalent content +fn images_equal(old_png: &DynamicImage, new_png: &DynamicImage) -> bool { + let a = old_png.pixels().filter(|x| { + let p = x.2.channels(); + !(p.len() == 4 && p[3] == 0) + }); + let b = new_png.pixels().filter(|x| { + let p = x.2.channels(); + !(p.len() == 4 && p[3] == 0) + }); + a.eq(b) +} diff --git a/tests/files/raw_api.png b/tests/files/raw_api.png new file mode 100644 index 00000000..3f9bdb1a Binary files /dev/null and b/tests/files/raw_api.png differ diff --git a/tests/raw.rs b/tests/raw.rs new file mode 100644 index 00000000..03802444 --- /dev/null +++ b/tests/raw.rs @@ -0,0 +1,97 @@ +use oxipng::internal_tests::*; +use oxipng::*; +use std::path::PathBuf; +use std::sync::Arc; + +fn get_opts() -> Options { + Options { + force: true, + filter: indexset! { RowFilter::None }, + ..Default::default() + } +} + +fn test_it_converts(input: &str) { + let input = PathBuf::from(input); + let opts = get_opts(); + + let original_data = PngData::read_file(&PathBuf::from(input)).unwrap(); + let png = PngData::from_slice(&original_data, opts.fix_errors).unwrap(); + let png = Arc::try_unwrap(png.raw).unwrap(); + + let num_headers = png.aux_headers.len(); + assert!(num_headers > 0); + + let mut raw = RawImage::new( + png.ihdr.width, + png.ihdr.height, + png.ihdr.color_type, + png.ihdr.bit_depth, + png.data, + ) + .unwrap(); + + for (chunk_type, data) in png.aux_headers { + raw.add_png_chunk(chunk_type, data); + } + + let output = raw.create_optimized_png(&opts).unwrap(); + + let new = PngData::from_slice(&output, opts.fix_errors).unwrap(); + assert!(new.raw.aux_headers.len() == num_headers); + + #[cfg(feature = "sanity-checks")] + assert!(validate_output(&output, &original_data)); +} + +#[test] +fn from_file() { + test_it_converts("tests/files/raw_api.png"); +} + +#[test] +fn custom_indexed() { + let opts = get_opts(); + + let raw = RawImage::new( + 4, + 4, + ColorType::Indexed { + palette: vec![ + RGBA8::new(255, 255, 255, 255), + RGBA8::new(255, 0, 0, 255), + RGBA8::new(0, 255, 0, 255), + RGBA8::new(0, 0, 255, 255), + ], + }, + BitDepth::Eight, + vec![0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3], + ) + .unwrap(); + + raw.create_optimized_png(&opts).unwrap(); +} + +#[test] +fn invalid_depth() { + RawImage::new( + 2, + 2, + ColorType::RGBA, + BitDepth::Four, + vec![0, 0, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 2, 2, 3, 3], + ) + .expect_err("Expected invalid depth for color type"); +} + +#[test] +fn incorrect_length() { + RawImage::new( + 2, + 2, + ColorType::RGBA, + BitDepth::Eight, + vec![0, 0, 1, 1, 0, 0, 1, 1], + ) + .expect_err("Expected incorrect data length"); +}