From 2ad5fa1734aa5e969733d9bc33df6e5867681f2e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 15 Nov 2026 17:41:07 +0000 Subject: [PATCH] Preserve renderer source limits for streaming PNG reduction --- crates/render/src/paint.rs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 83f743b92b..8650ae7886 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -260,7 +260,7 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { let to = fitted_dims(w, h, allowance)?; // A PNG that isn't interlaced or animated (and upright in pre-pixel EXIF) is read a row at a time when the reader's buffers fit the allowance. let row_scratch = if format == image::ImageFormat::Png { - let streamed = craft_image_codecs::PngDecodeRows::new_streaming(bytes, &craft_image_codecs::Limits::default()) + let streamed = craft_image_codecs::PngDecodeRows::new_streaming(bytes, &png_streaming_limits(allowance)) .ok() .filter(|dec| dec.streamable()) .and_then(|dec| png_row_scratch(&dec.header())); @@ -276,6 +276,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { (rows || d.total_bytes() <= allowance).then_some(Reduction { size: (w, h), to, rows, scratch }) } +/// Streaming retains rows, not the declared full image. Preserve the renderer's source-pixel +/// allowance even when RGBA16's declared samples exceed the shared whole-image default budget. +/// Row scratch and the reduced pixmap are admitted separately before decoding starts. +fn png_streaming_limits(source_pixels: u64) -> craft_image_codecs::Limits { + craft_image_codecs::Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: source_pixels, max_alloc: source_pixels.saturating_mul(8) } +} + /// How many rows of an image decoded whole [`reduced_pixmap`] converts to RGBA at a time. const BAND: u32 = 16; @@ -372,7 +379,7 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result Option { - shrunk_with_png_limits(bytes, r, &craft_image_codecs::Limits::default()) + shrunk_with_png_limits(bytes, r, &png_streaming_limits(allowance())) } fn shrunk_with_png_limits(bytes: &[u8], r: &Reduction, png_limits: &craft_image_codecs::Limits) -> Option { @@ -671,6 +678,31 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + #[test] + fn streaming_header_accepts_rgba16_above_whole_image_default() { + // Header-only fixture: opening the stream checks declared size without allocating or + // decoding the 1.2 GB canvas. Padding meets the decoder's compressed-size plausibility + // bound; no rows are requested from this deliberately incomplete image. + let (w, h) = (10_000u32, 15_000u32); + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let ihdr = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[16, 6, 0, 0, 0]].concat(); + let mut idat = b"IDAT\x78\x9c".to_vec(); + idat.resize(1_200_000, 0); + for chunk in [&ihdr[..], &idat, b"IEND"] { + bytes.extend((chunk.len() as u32 - 4).to_be_bytes()); + bytes.extend(chunk); + let mut crc = flate2::Crc::new(); + crc.update(chunk); + bytes.extend(crc.sum().to_be_bytes()); + } + let default = craft_image_codecs::Limits { max_alloc: 1 << 30, max_pixels: 150_000_000, ..Default::default() }; + assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &default).is_err()); + let rows = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(150_000_000)).unwrap(); + assert_eq!((rows.header().width, rows.header().height), (w, h)); + assert!(rows.streamable()); + assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(149_999_999)).is_err()); + } + #[test] fn streaming_reduction_does_not_retain_text_after_pixels() { let mut bytes = Vec::new();