diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..037448dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,6 +1535,7 @@ dependencies = [ "designcraft-fonts", "designcraft-geom", "designcraft-images", + "flate2", "hayro-cmap", "hayro-interpret", "hayro-syntax", diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 117017502..dfe4166b7 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -327,12 +327,17 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { /// The largest decoded raster, in RGBA bytes: 256 Mpx, a 300 ppi scan of an A0 sheet fits. const MAX_RGBA_BYTES: u64 = 1 << 30; +/// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. +pub fn too_large_to_decode(bytes: &[u8]) -> bool { + // `image` limits only its own output, which can be a quarter of the RGBA copy or less. + pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) +} + /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is /// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; - // `image` limits only its own output, which can be a quarter of the RGBA copy or less. - if pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) { + if too_large_to_decode(bytes) { return None; } if is_psd(bytes) { diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index 54a173cd1..9ca9fe14f 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -435,7 +435,9 @@ fn rasters_past_the_rgba_budget_are_refused_before_decoding() { assert!(png.len() < 1 << 20, "{} bytes", png.len()); assert_eq!(pixel_size(&png), Some((16_385, 16_385))); assert_eq!(natural_size(&png), Some((16_385.0, 16_385.0))); + assert!(too_large_to_decode(&png)); assert!(decode_rgba(&png).is_none()); + assert!(!too_large_to_decode(&blank_png(16_384))); let small = decode_rgba(&blank_png(9)).expect("decodes"); assert_eq!(small.get_pixel(8, 8).0, [0, 0, 0, 255]); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 2b4275da3..3df877c5c 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -31,4 +31,5 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em hayro-cmap = "0.1" kurbo = { workspace = true } tiff = { workspace = true } +flate2 = "1" designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a164a6948..8a2b803cf 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,9 +161,12 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } -/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. +/// Opaque raster → JPEG (quality 90). `None` when the image has transparency, is too large or +/// can't be decoded. With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. pub(super) fn recompress(data: &[u8], mode: RasterMode) -> Option { + if designcraft_images::too_large_to_decode(data) { + return None; + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2accc79fa..8fcc15547 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -257,6 +257,32 @@ fn cmyk_tiff() -> (Vec, Vec) { (b, px) } +/// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. +fn blank_png(side: u32) -> Vec { + use std::io::Write; + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend((data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend(kind); + out.extend(data); + out.extend(crc.sum().to_be_bytes()); + }; + let row = vec![0u8; 1 + side.div_ceil(8) as usize]; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + for _ in 0..side { + z.write_all(&row).unwrap(); + } + let mut ihdr = [side.to_be_bytes(), side.to_be_bytes()].concat(); + ihdr.extend([1, 0, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + png +} + fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let asset = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); @@ -328,6 +354,22 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } +/// A raster past the RGBA budget is skipped undecoded, also when images are recompressed (#1586). +#[test] +fn an_image_past_the_decode_budget_is_skipped_undecoded() { + let side = 16_385; + let mut d = doc_with_text("still here"); + place(&mut d, blank_png(side), (side, side)); + for compress_images in [false, true] { + let started = std::time::Instant::now(); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(1), "compress_images {compress_images}: took {took:?}"); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "compress_images {compress_images}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "compress_images {compress_images}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name;