diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 8a2b803cf..ec7c76fd6 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -161,12 +161,22 @@ pub(super) fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } +#[cfg(test)] +thread_local! { + /// Rasters `recompress` has decoded on this thread. + static RECOMPRESS_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// 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; } + #[cfg(test)] + { + RECOMPRESS_DECODES.with(|n| n.set(n.get() + 1)); + } let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -539,3 +549,20 @@ mod eps_tests { assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); } } + +#[cfg(test)] +mod recompress_tests { + use super::*; + + /// A raster past the RGBA budget is refused before `recompress` decodes it (#1586). + #[test] + fn a_raster_past_the_budget_is_not_decoded() { + let mode = RasterMode { interpolate: false, tag_rgb: false }; + let decodes = || RECOMPRESS_DECODES.with(std::cell::Cell::get); + let start = decodes(); + assert!(recompress(&crate::tests::blank_png(16_385), mode).is_none()); + assert_eq!(decodes(), start); + assert!(recompress(&crate::tests::blank_png(9), mode).is_some()); + assert_eq!(decodes(), start + 1); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8fcc15547..160fa38b2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -258,7 +258,7 @@ fn cmyk_tiff() -> (Vec, Vec) { } /// A black `side`² 1-bit grey PNG: well under 1 MB, it decodes to `side`² bytes. -fn blank_png(side: u32) -> Vec { +pub(crate) 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()); @@ -354,17 +354,14 @@ fn a_cut_off_image_is_skipped_not_fatal() { } } -/// A raster past the RGBA budget is skipped undecoded, also when images are recompressed (#1586). +/// A raster past the RGBA budget is skipped with a warning, also when images are recompressed (#1586). #[test] -fn an_image_past_the_decode_budget_is_skipped_undecoded() { +fn an_image_past_the_decode_budget_is_skipped_with_a_warning() { 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}"); }