From 7372f740b54f1b7393e76cd2c3d84ed84a86d1a2 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 16 May 2023 21:34:01 +1200 Subject: [PATCH] Recompress iCCP --- src/headers.rs | 73 +++++++++++++++++++++++++++++++++++++- src/lib.rs | 95 +++++++++++++++++--------------------------------- 2 files changed, 104 insertions(+), 64 deletions(-) diff --git a/src/headers.rs b/src/headers.rs index 18f84efd..274173d7 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -1,9 +1,12 @@ use crate::colors::{BitDepth, ColorType}; -use crate::deflate::crc32; +use crate::deflate::{crc32, inflate}; use crate::error::PngError; use crate::interlace::Interlacing; +use crate::AtomicMin; +use crate::Deflaters; use crate::PngResult; use indexmap::IndexSet; +use log::warn; use rgb::{RGB16, RGBA8}; #[derive(Debug, Clone)] @@ -214,3 +217,71 @@ fn palette_to_rgba( fn read_be_u32(bytes: &[u8]) -> u32 { u32::from_be_bytes(bytes.try_into().unwrap()) } + +/// Extract and decompress the ICC profile from an iCCP chunk +pub fn extract_icc(iccp: &Chunk) -> Option> { + // Skip (useless) profile name + let mut data = iccp.data.as_slice(); + loop { + let (&n, rest) = data.split_first()?; + data = rest; + if n == 0 { + break; + } + } + + let (&compression_method, compressed_data) = data.split_first()?; + if compression_method != 0 { + return None; // The profile is supposed to be compressed (method 0) + } + // The decompressed size is unknown so we have to guess the required buffer size + let max_size = compressed_data.len() * 2 + 1000; + match inflate(compressed_data, max_size) { + Ok(icc) => Some(icc), + Err(e) => { + // Log the error so we can know if the buffer size needs to be adjusted + warn!("Failed to decompress icc: {}", e); + None + } + } +} + +/// Construct an iCCP chunk by compressing the ICC profile +pub fn construct_iccp(icc: &[u8], deflater: Deflaters) -> PngResult { + let mut compressed = deflater.deflate(icc, &AtomicMin::new(None))?; + let mut data = Vec::with_capacity(compressed.len() + 5); + data.extend(b"icc"); // Profile name - generally unused, can be anything + data.extend([0, 0]); // Null separator, zlib compression method + data.append(&mut compressed); + Ok(Chunk { + name: *b"iCCP", + data, + }) +} + +/// If the profile is sRGB, extracts the rendering intent value from it +pub fn srgb_rendering_intent(icc_data: &[u8]) -> Option { + let rendering_intent = *icc_data.get(67)?; + + // The known profiles are the same as in libpng's `png_sRGB_checks`. + // The Profile ID header of ICC has a fixed layout, + // and is supposed to contain MD5 of profile data at this offset + match icc_data.get(84..100)? { + b"\x29\xf8\x3d\xde\xaf\xf2\x55\xae\x78\x42\xfa\xe4\xca\x83\x39\x0d" + | b"\xc9\x5b\xd6\x37\xe9\x5d\x8a\x3b\x0d\xf3\x8f\x99\xc1\x32\x03\x89" + | b"\xfc\x66\x33\x78\x37\xe2\x88\x6b\xfd\x72\xe9\x83\x82\x28\xf1\xb8" + | b"\x34\x56\x2a\xbf\x99\x4c\xcd\x06\x6d\x2c\x57\x21\xd0\xd6\x8c\x5d" => { + Some(rendering_intent) + } + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" => { + // Known-bad profiles are identified by their CRC + match (crc32(icc_data), icc_data.len()) { + (0x5d51_29ce, 3024) | (0x182e_a552, 3144) | (0xf29e_526d, 3144) => { + Some(rendering_intent) + } + _ => None, + } + } + _ => None, + } +} diff --git a/src/lib.rs b/src/lib.rs index 11af6171..aed4cc36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,9 +25,8 @@ extern crate rayon; mod rayon; use crate::atomicmin::AtomicMin; -use crate::deflate::{crc32, inflate}; use crate::evaluate::Evaluator; -use crate::headers::{Chunk, IhdrData}; +use crate::headers::*; use crate::png::PngData; use crate::png::PngImage; use crate::reduction::*; @@ -374,13 +373,10 @@ impl RawImage { /// 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); + // Compress with fastest compression level - will be recompressed during optimization + let deflater = Deflaters::Libdeflater { compression: 1 }; + if let Ok(iccp) = construct_iccp(data, deflater) { + self.aux_chunks.push(iccp); } } @@ -844,21 +840,38 @@ fn report_format(prefix: &str, png: &PngImage) { /// Perform cleanup of certain chunks from the `PngData` object, after optimization has been completed fn postprocess_chunks(png: &mut PngData, opts: &Options, orig_ihdr: &IhdrData) { - // See if we can replace an iCCP chunk with an sRGB chunk - if opts.strip != StripChunks::None && opts.strip.keep(b"sRGB") { - if let Some(iccp_idx) = png.aux_chunks.iter().position(|c| &c.name == b"iCCP") { - if png.aux_chunks.iter().any(|c| &c.name == b"sRGB") { - // Files aren't supposed to have both chunks, so we chose to honor sRGB - trace!("Removing iCCP chunk due to conflict with sRGB chunk"); - png.aux_chunks.remove(iccp_idx); - } else if let Some(intent) = srgb_rendering_intent(&png.aux_chunks[iccp_idx].data) { - // sRGB-like profile can be safely replaced with - // an sRGB chunk with the same rendering intent + if let Some(iccp_idx) = png.aux_chunks.iter().position(|c| &c.name == b"iCCP") { + // See if we can replace an iCCP chunk with an sRGB chunk + let may_replace_iccp = opts.strip != StripChunks::None && opts.strip.keep(b"sRGB"); + if may_replace_iccp && png.aux_chunks.iter().any(|c| &c.name == b"sRGB") { + // Files aren't supposed to have both chunks, so we chose to honor sRGB + trace!("Removing iCCP chunk due to conflict with sRGB chunk"); + png.aux_chunks.remove(iccp_idx); + } else if let Some(icc) = extract_icc(&png.aux_chunks[iccp_idx]) { + let intent = if may_replace_iccp { + srgb_rendering_intent(&icc) + } else { + None + }; + // sRGB-like profile can be replaced with an sRGB chunk with the same rendering intent + // Otherwise try recompressing the profile + if let Some(intent) = intent { trace!("Replacing iCCP chunk with equivalent sRGB chunk"); png.aux_chunks[iccp_idx] = Chunk { name: *b"sRGB", data: vec![intent], }; + } else if let Ok(iccp) = construct_iccp(&icc, opts.deflate) { + let cur_len = png.aux_chunks[iccp_idx].data.len(); + let new_len = iccp.data.len(); + if new_len < cur_len { + debug!( + "Recompressed iCCP chunk: {} ({} bytes decrease)", + new_len, + cur_len - new_len + ); + png.aux_chunks[iccp_idx] = iccp; + } } } } @@ -880,50 +893,6 @@ fn postprocess_chunks(png: &mut PngData, opts: &Options, orig_ihdr: &IhdrData) { } } -/// If the profile is sRGB, extracts the rendering intent value from it -fn srgb_rendering_intent(mut iccp: &[u8]) -> Option { - // Skip (useless) profile name - loop { - let (&n, rest) = iccp.split_first()?; - iccp = rest; - if n == 0 { - break; - } - } - - let (&compression_method, compressed_data) = iccp.split_first()?; - if compression_method != 0 { - return None; // The profile is supposed to be compressed (method 0) - } - // The decompressed size is unknown so we have to guess the required buffer size - let max_size = (compressed_data.len() * 2).max(1000); - let icc_data = inflate(compressed_data, max_size).ok()?; - - let rendering_intent = *icc_data.get(67)?; - - // The known profiles are the same as in libpng's `png_sRGB_checks`. - // The Profile ID header of ICC has a fixed layout, - // and is supposed to contain MD5 of profile data at this offset - match icc_data.get(84..100)? { - b"\x29\xf8\x3d\xde\xaf\xf2\x55\xae\x78\x42\xfa\xe4\xca\x83\x39\x0d" - | b"\xc9\x5b\xd6\x37\xe9\x5d\x8a\x3b\x0d\xf3\x8f\x99\xc1\x32\x03\x89" - | b"\xfc\x66\x33\x78\x37\xe2\x88\x6b\xfd\x72\xe9\x83\x82\x28\xf1\xb8" - | b"\x34\x56\x2a\xbf\x99\x4c\xcd\x06\x6d\x2c\x57\x21\xd0\xd6\x8c\x5d" => { - Some(rendering_intent) - } - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" => { - // Known-bad profiles are identified by their CRC - match (crc32(&icc_data), icc_data.len()) { - (0x5d51_29ce, 3024) | (0x182e_a552, 3144) | (0xf29e_526d, 3144) => { - Some(rendering_intent) - } - _ => None, - } - } - _ => None, - } -} - /// Check if an image was already optimized prior to oxipng's operations fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool { original_size <= optimized_size && !opts.force