fix(create,optimize): a mask of other proportions doesn't take the process down

Scaling goes down first, through a buffer of the source's width by the target's
height at 16 bytes a pixel, and neither image's size limit bounds that product.
A soft mask of 60,000 × 1 on an image of 1 × 60,000, about a kilobyte of PDF,
asked for 57 GB and aborted the process, in image export, Office export, Copy
Image and Optimize at its default settings. The same shapes in smaller sizes
held gigabytes.

Such a picture is scaled across first, which holds no more pixels than one of
the two has, so the size limits bound it after all. Proportions that aren't
far apart scale as they did, to the byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-11-01 02:28:29 +01:00
co-authored by Claude Opus 5.5
parent 13655d0992
commit 640e7acb15
3 changed files with 145 additions and 4 deletions
+17 -1
View File
@@ -612,7 +612,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
return Some(px);
}
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
Some(scaled(&img, w as u32, h as u32).into_raw())
}
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any
/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process.
/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a
/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was.
pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage {
use image::imageops::{FilterType, resize};
let (mw, mh) = (u64::from(img.width()), u64::from(img.height()));
let largest = (mw * mh).max(u64::from(w) * u64::from(h));
if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle)
} else {
resize(img, w, h, FilterType::Triangle)
}
}
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
+61
View File
@@ -2209,3 +2209,64 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
/// A mask of other proportions than its image is scaled to it, however far apart they are: these
/// shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the process. The
/// value comes through, and masks of ordinary proportions are scaled as they always were.
#[test]
fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
let gray = |w: i64, h: i64, data: &[u8]| {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w));
d.set(b"Height".to_vec(), Object::Int(h));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
Stream::from_raw(d, pdfcraft_filters::encode_flate(data))
};
for (mask_w, mask_h, w, h) in [(100_000usize, 1usize, 1usize, 100_000usize), (100_000, 64, 64, 100_000), (64, 1, 1, 64), (3, 2, 6, 4)] {
let mut doc = Document::new_empty();
let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h])));
let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]);
image.dict.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(image));
let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}"));
assert_eq!(name, "png");
let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap();
let mut rgba = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut rgba).unwrap();
assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba));
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}");
}
}
/// The long way round is for proportions far apart: up to a quarter over the larger image the
/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes
/// out the same either way.
#[test]
fn proportions_up_to_the_limit_scale_as_they_did() {
let noise = |w: u32, h: u32| {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let px = (0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect();
image::GrayImage::from_raw(w, h, px).unwrap()
};
// Each exactly a quarter over the larger: the source's size, then the target's.
for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
let mask = noise(mw, mh);
let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle);
assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}");
}
// A step past it goes the long way, which is how to tell.
let mask = noise(25, 15);
let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle);
assert_ne!(crate::extract::scaled(&mask, 10, 20), direct);
}
+67 -3
View File
@@ -618,13 +618,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<
}
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
use image::imageops::{FilterType, resize};
Some(match n {
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
_ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
_ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
})
}
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than
/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the
/// process. Such a one is scaled across first, which holds no more pixels than one of the two has.
/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as
/// it was.
fn scaled<P: image::Pixel<Subpixel = u8> + 'static>(img: &image::ImageBuffer<P, Vec<u8>>, w: u32, h: u32) -> image::ImageBuffer<P, Vec<u8>> {
use image::imageops::{FilterType, resize};
let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
let largest = (iw * ih).max(u64::from(w) * u64::from(h));
if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
} else {
resize(img, w, h, FilterType::CatmullRom)
}
}
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
@@ -1244,6 +1260,54 @@ mod dimension_tests {
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
/// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
/// these shapes asked for 160 GB and more on the way, and that aborted the process. A flat
/// picture stays flat, and pictures of ordinary proportions resample as they always did.
#[test]
fn resampling_across_proportions_takes_the_memory_of_neither_product() {
for (w, h, nw, nh, n) in [(100_000u32, 1u32, 1u32, 100_000u32, 1usize), (1_000_000, 1, 4, 100_000, 1), (100_000, 1, 1, 100_000, 3)] {
let px = vec![0x60; (w * h) as usize * n];
let out = resample(&px, n, w, h, nw, nh).unwrap();
assert_eq!(out.len(), (nw * nh) as usize * n);
assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
}
let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
let px: Vec<u8> = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
}
/// The long way round is for proportions far apart: up to a quarter over the larger picture the
/// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
/// out the same either way.
#[test]
fn proportions_up_to_the_limit_resample_as_they_did() {
let noise = |w: u32, h: u32| -> Vec<u8> {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
(0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect()
};
let direct = |px: &[u8], w, h, nw, nh| {
let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
};
// Each exactly a quarter over the larger: the source's size, then the target's.
for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
let px = noise(w, h);
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
}
// A step past it goes the long way, which is how to tell.
let px = noise(25, 15);
assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
}
}
#[cfg(test)]