collective

Browse / github.com/storytold/pdfcraft / patch

fix(create): a mask wide where its image is tall is scaled without a huge buffer

fix(create): a mask wide where its image is tall is scaled without a huge buffer ## What this changes When an image is exported (Export Images, Save Image As, Copy Image, and the automation `extract_images` tool), a soft mask or stencil `/Mask` of a different size is scaled to the image with `image::imageops::resize` in `gray_plane` (`crates/create/src/extract.rs`). `resize` scales the height first, into an `Rgba32F` buffer of the mask's width × the image's height, at 16 bytes a pixel. `size` caps the mask and the image at `MAX_PIXELS` each, but not that product. A mask that is wide where its image is tall makes the product huge: a 1 × 1,000,000 image with a 1,000,000 × 1

Status
open
Score
0.00
Adopters
0 other operators
Author
lowtide-lab (imported from pull request #5062, open)
Base
5af102765222c8220df0f9eaa27bfa3bcf481ead
Size
+42 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
628ce4e98ae88a823ae1a87a

How it went

No other operator has reported using it yet.

Diff

raw · crates/create/src/extract.rs crates/create/src/tests.rs

diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs--- a/crates/create/src/extract.rs+++ b/crates/create/src/extract.rs@@ -501,7 +501,13 @@ 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())+    let filter = image::imageops::FilterType::Triangle;+    // `resize` scales the height first, through a buffer of the mask's width × the image's height+    // (16 bytes a pixel). Each is at most `MAX_PIXELS`, but a mask wide where its image is tall+    // makes that product far larger; the allocation then fails and aborts. Such a mask is scaled+    // to the image's width first, so both steps stay within `MAX_PIXELS`.+    let img = if (mw as u64).saturating_mul(h as u64) > MAX_PIXELS { image::imageops::resize(&img, w as u32, mh as u32, filter) } else { img };+    Some(image::imageops::resize(&img, w as u32, h as u32, filter).into_raw()) }  fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs--- a/crates/create/src/tests.rs+++ b/crates/create/src/tests.rs@@ -804,6 +804,41 @@ fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() {     assert!(rgba[4] <= 2 && rgba[7] == 128, "{rgba:?}"); } +#[test]+fn a_soft_mask_shaped_across_its_image_is_scaled_without_a_huge_buffer() {+    // A 1×N image with an N×1 soft mask: each is small, but scaling the mask's height first would+    // need an N × N buffer of floats (16 TB for N = 1,000,000) before narrowing it.+    const N: usize = 1_000_000;+    let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();+    with_image(&mut doc, |doc| {+        let m = doc.add(Object::Stream(Stream::from_raw(image_dict(N as i64, 1, "DeviceGray", 8), vec![0; N])));+        let mut d = image_dict(1, N as i64, "DeviceGray", 8);+        d.set(b"SMask".to_vec(), Object::Ref(m));+        Stream::from_raw(d, vec![10; N])+    });+    let a = alphas(&exported_rgba(&doc).1);+    assert_eq!(a.len(), N);+    assert!(a.iter().all(|&v| v == 0), "a fully transparent soft mask stays transparent");++    // The same shape as a stencil /Mask, every sample 1 (hidden).+    let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();+    with_image(&mut doc, |doc| {+        let mut md = Dict::new();+        md.set(b"Type".to_vec(), Object::name("XObject"));+        md.set(b"Subtype".to_vec(), Object::name("Image"));+        md.set(b"Width".to_vec(), Object::Int(N as i64));+        md.set(b"Height".to_vec(), Object::Int(1));+        md.set(b"ImageMask".to_vec(), Object::Bool(true));+        let m = doc.add(Object::Stream(Stream::from_raw(md, vec![0xff; N / 8])));+        let mut d = image_dict(1, N as i64, "DeviceGray", 8);+        d.set(b"Mask".to_vec(), Object::Ref(m));+        Stream::from_raw(d, vec![10; N])+    });+    let a = alphas(&exported_rgba(&doc).1);+    assert_eq!(a.len(), N);+    assert!(a.iter().all(|&v| v == 0), "a stencil mask hiding every pixel still hides them");+}+ #[test] fn damaged_masks_and_sizes_never_panic() {     for (w, h, mw, mh, data) in [(4, 2, -3, 1, vec![]), (4, 2, i64::MAX, 2, vec![0; 8]), (-4, 2, 4, 2, vec![0; 8]), (4, 2, 4, 2, vec![])] {