Add max_uncompressed_size (#732)
Adds an option to skip files when the uncompressed IDAT (or the file size itself) exceeds a certain size. This provides an effective way to limit resource usage in constrained environments. Closes #411.
This commit is contained in:
parent
14532e8bf5
commit
7cc91b1308
12 changed files with 83 additions and 9 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -418,12 +418,19 @@ dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libdeflater",
|
"libdeflater",
|
||||||
"log",
|
"log",
|
||||||
|
"parse-size",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rgb",
|
"rgb",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parse-size"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ optional = true
|
||||||
version = "4.5.48"
|
version = "4.5.48"
|
||||||
features = ["wrap_help"]
|
features = ["wrap_help"]
|
||||||
|
|
||||||
|
[dependencies.parse-size]
|
||||||
|
optional = true
|
||||||
|
version = "1.1.0"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies.glob]
|
[target.'cfg(windows)'.dependencies.glob]
|
||||||
optional = true
|
optional = true
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
|
|
@ -80,7 +84,7 @@ features = ["png"]
|
||||||
version = "0.25.8"
|
version = "0.25.8"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
binary = ["dep:clap", "dep:glob", "dep:env_logger"]
|
binary = ["dep:clap", "dep:glob", "dep:env_logger", "dep:parse-size"]
|
||||||
default = ["binary", "parallel", "zopfli", "filetime"]
|
default = ["binary", "parallel", "zopfli", "filetime"]
|
||||||
parallel = ["dep:rayon", "indexmap/rayon", "dep:crossbeam-channel"]
|
parallel = ["dep:rayon", "indexmap/rayon", "dep:crossbeam-channel"]
|
||||||
freestanding = ["libdeflater/freestanding"]
|
freestanding = ["libdeflater/freestanding"]
|
||||||
|
|
|
||||||
16
src/cli.rs
16
src/cli.rs
|
|
@ -1,6 +1,7 @@
|
||||||
use std::{num::NonZeroU64, path::PathBuf};
|
use std::{num::NonZeroU64, path::PathBuf};
|
||||||
|
|
||||||
use clap::{Arg, ArgAction, Command, builder::ArgPredicate, value_parser};
|
use clap::{Arg, ArgAction, Command, builder::ArgPredicate, value_parser};
|
||||||
|
use parse_size::parse_size;
|
||||||
|
|
||||||
include!("display_chunks.rs");
|
include!("display_chunks.rs");
|
||||||
|
|
||||||
|
|
@ -365,6 +366,21 @@ high compression levels.")
|
||||||
.value_name("secs")
|
.value_name("secs")
|
||||||
.value_parser(value_parser!(u64)),
|
.value_parser(value_parser!(u64)),
|
||||||
)
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("max-size")
|
||||||
|
.help("Skip image if the decompressed size exceeds this limit")
|
||||||
|
.long_help("\
|
||||||
|
Maximum size to allow for the input image. If the raw, decompressed image data (or the \
|
||||||
|
file size) of the image exceeds this size, it will be skipped. This is useful for limiting \
|
||||||
|
memory usage or avoiding long processing times on large images. The value may be specified \
|
||||||
|
with a unit suffix such as k, KB, m, MB, etc.
|
||||||
|
|
||||||
|
The decompressed size of an image is roughly equal to width * height * bit-depth / 8. E.g. \
|
||||||
|
a 1920x1080 image with 24-bit color depth would be roughly 6MB.")
|
||||||
|
.long("max-raw-size")
|
||||||
|
.value_name("bytes")
|
||||||
|
.value_parser(|s: &str| parse_size(s)),
|
||||||
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("threads")
|
Arg::new("threads")
|
||||||
.help("Number of threads to use [default: num logical CPUs]")
|
.help("Number of threads to use [default: num logical CPUs]")
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,10 @@ impl fmt::Display for PngError {
|
||||||
f,
|
f,
|
||||||
"Data length {l1} does not match the expected length {l2}"
|
"Data length {l1} does not match the expected length {l2}"
|
||||||
),
|
),
|
||||||
PngError::InflatedDataTooLong(_) => f.write_str("Inflated data too long"),
|
PngError::InflatedDataTooLong(max) => write!(
|
||||||
|
f,
|
||||||
|
"Inflated data would exceed the maximum size ({max} bytes)"
|
||||||
|
),
|
||||||
PngError::InvalidData => f.write_str("Invalid data found; unable to read PNG file"),
|
PngError::InvalidData => f.write_str("Invalid data found; unable to read PNG file"),
|
||||||
PngError::InvalidDepthForType(d, ref c) => {
|
PngError::InvalidDepthForType(d, ref c) => {
|
||||||
write!(f, "Invalid bit depth {d} for color type {c}")
|
write!(f, "Invalid bit depth {d} for color type {c}")
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ pub fn read_be_u32(bytes: &[u8]) -> u32 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract and decompress the ICC profile from an iCCP chunk
|
/// Extract and decompress the ICC profile from an iCCP chunk
|
||||||
pub fn extract_icc(iccp: &Chunk) -> Option<Vec<u8>> {
|
pub fn extract_icc(iccp: &Chunk, max_size: Option<usize>) -> Option<Vec<u8>> {
|
||||||
// Skip (useless) profile name
|
// Skip (useless) profile name
|
||||||
let mut data = iccp.data.as_slice();
|
let mut data = iccp.data.as_slice();
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -264,8 +264,11 @@ pub fn extract_icc(iccp: &Chunk) -> Option<Vec<u8>> {
|
||||||
return None; // The profile is supposed to be compressed (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
|
// The decompressed size is unknown so we have to guess the required buffer size
|
||||||
let max_size = compressed_data.len() * 2 + 1000;
|
let mut out_size = compressed_data.len() * 2 + 1000;
|
||||||
match inflate(compressed_data, max_size) {
|
if let Some(max) = max_size {
|
||||||
|
out_size = out_size.min(max);
|
||||||
|
}
|
||||||
|
match inflate(compressed_data, out_size) {
|
||||||
Ok(icc) => Some(icc),
|
Ok(icc) => Some(icc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Log the error so we can know if the buffer size needs to be adjusted
|
// Log the error so we can know if the buffer size needs to be adjusted
|
||||||
|
|
@ -331,7 +334,7 @@ pub fn preprocess_chunks(aux_chunks: &mut Vec<Chunk>, opts: &mut Options) {
|
||||||
trace!("Removing iCCP chunk due to conflict with sRGB chunk");
|
trace!("Removing iCCP chunk due to conflict with sRGB chunk");
|
||||||
aux_chunks.remove(iccp_idx);
|
aux_chunks.remove(iccp_idx);
|
||||||
allow_grayscale = true;
|
allow_grayscale = true;
|
||||||
} else if let Some(icc) = extract_icc(&aux_chunks[iccp_idx]) {
|
} else if let Some(icc) = extract_icc(&aux_chunks[iccp_idx], opts.max_decompressed_size) {
|
||||||
let intent = if may_replace_iccp {
|
let intent = if may_replace_iccp {
|
||||||
srgb_rendering_intent(&icc)
|
srgb_rendering_intent(&icc)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
13
src/main.rs
13
src/main.rs
|
|
@ -259,6 +259,8 @@ fn parse_opts_into_struct(
|
||||||
|
|
||||||
opts.fix_errors = matches.get_flag("fix");
|
opts.fix_errors = matches.get_flag("fix");
|
||||||
|
|
||||||
|
opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize);
|
||||||
|
|
||||||
opts.bit_depth_reduction = !matches.get_flag("no-bit-reduction");
|
opts.bit_depth_reduction = !matches.get_flag("no-bit-reduction");
|
||||||
|
|
||||||
opts.color_type_reduction = !matches.get_flag("no-color-reduction");
|
opts.color_type_reduction = !matches.get_flag("no-color-reduction");
|
||||||
|
|
@ -425,6 +427,13 @@ fn parse_numeric_range_opts(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
|
fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
|
||||||
|
if let (Some(max_size), InFile::Path(path)) = (opts.max_decompressed_size, input) {
|
||||||
|
if path.metadata().is_ok_and(|m| m.len() > max_size as u64) {
|
||||||
|
warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)");
|
||||||
|
return OptimizationResult::Skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match oxipng::optimize(input, output, opts) {
|
match oxipng::optimize(input, output, opts) {
|
||||||
// For optimizing single files, this will return the correct exit code always.
|
// For optimizing single files, this will return the correct exit code always.
|
||||||
// For recursive optimization, the correct choice is a bit subjective.
|
// For recursive optimization, the correct choice is a bit subjective.
|
||||||
|
|
@ -434,8 +443,8 @@ fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
|
||||||
// PNG files, and return an error for them.
|
// PNG files, and return an error for them.
|
||||||
// We don't really want to return an error code for those files.
|
// We don't really want to return an error code for those files.
|
||||||
Ok(_) => OptimizationResult::Ok,
|
Ok(_) => OptimizationResult::Ok,
|
||||||
Err(e @ PngError::C2PAMetadataPreventsChanges) => {
|
Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => {
|
||||||
warn!("{input}: {e}");
|
warn!("{input}: Skipped: {e}");
|
||||||
OptimizationResult::Skipped
|
OptimizationResult::Skipped
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,11 @@ pub struct Options {
|
||||||
///
|
///
|
||||||
/// Default: `None`
|
/// Default: `None`
|
||||||
pub timeout: Option<Duration>,
|
pub timeout: Option<Duration>,
|
||||||
|
/// Maximum decompressed size of the input IDAT.
|
||||||
|
/// If decompression would exceed this size, it will be rejected.
|
||||||
|
///
|
||||||
|
/// Default: `None`
|
||||||
|
pub max_decompressed_size: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Options {
|
impl Options {
|
||||||
|
|
@ -263,6 +268,7 @@ impl Default for Options {
|
||||||
deflater: Deflater::Libdeflater { compression: 11 },
|
deflater: Deflater::Libdeflater { compression: 11 },
|
||||||
fast_evaluation: true,
|
fast_evaluation: true,
|
||||||
timeout: None,
|
timeout: None,
|
||||||
|
max_decompressed_size: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,11 @@ impl PngData {
|
||||||
key_chunks.remove(b"PLTE"),
|
key_chunks.remove(b"PLTE"),
|
||||||
key_chunks.remove(b"tRNS"),
|
key_chunks.remove(b"tRNS"),
|
||||||
)?;
|
)?;
|
||||||
|
if let Some(max) = opts.max_decompressed_size {
|
||||||
|
if ihdr.raw_data_size() > max {
|
||||||
|
return Err(PngError::InflatedDataTooLong(max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let raw = PngImage::new(ihdr, &idat_data)?;
|
let raw = PngImage::new(ihdr, &idat_data)?;
|
||||||
|
|
||||||
|
|
|
||||||
BIN
tests/files/max_size.png
Normal file
BIN
tests/files/max_size.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -634,6 +634,19 @@ fn scale_16() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_size() {
|
||||||
|
let result = oxipng::optimize(
|
||||||
|
&"tests/files/max_size.png".into(),
|
||||||
|
&OutFile::None,
|
||||||
|
&Options {
|
||||||
|
max_decompressed_size: Some(150_000),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(PngError::InflatedDataTooLong(_))));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(feature = "zopfli")]
|
#[cfg(feature = "zopfli")]
|
||||||
fn zopfli_mode() {
|
fn zopfli_mode() {
|
||||||
|
|
|
||||||
9
xtask/Cargo.lock
generated
9
xtask/Cargo.lock
generated
|
|
@ -1,6 +1,6 @@
|
||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
|
|
@ -100,6 +100,12 @@ version = "1.70.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parse-size"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roff"
|
name = "roff"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -197,4 +203,5 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap_mangen",
|
"clap_mangen",
|
||||||
|
"parse-size",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ publish = false
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = "4.5.21"
|
clap = "4.5.21"
|
||||||
clap_mangen = "0.2.24"
|
clap_mangen = "0.2.24"
|
||||||
|
parse-size = "1.1.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue