diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b12bd50ec2..0fc6297e94 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -612,7 +612,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> 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, String> { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 03c93d4a51..a408f5dfc5 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -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); +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9338061978..c21086c569 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -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> { - 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 + 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer> { + 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> { 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 = (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 { + 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)]