diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index be232d80eb..68a823ca70 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -8193,6 +8193,79 @@ fn decoded_images_serve_every_size_they_are_drawn_at() { assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); } +/// A soft mask far larger than where its image is drawn is averaged down to that size and cached +/// there (vendored hayro-interpret patch 77): a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 +/// times on one slide (pdf.js `issue16263.pdf`), was decoded and drawn at full size each time and +/// the page took minutes. Drawn ten times at one size, the mask is now decoded once and handed +/// over at that size; a mask no larger than twice its drawn size keeps its own. +#[test] +fn a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and keeps + /// the size of each alpha channel it was handed. + #[derive(Default)] + struct Draws(Vec<(u32, u32)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.push(alpha.map_or((0, 0), |a| (a.width, a.height))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + // `side` × `side` mask samples, drawn ten times at 20 × 20 points (device pixels at 72 dpi). + let draws = |side: u32| { + let alpha = flate(&(0..side * side).map(|i| (i % 256) as u8).collect::>()); + let mask = format!( + "<< /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + alpha.len() + ); + let content = (0..10).map(|i| format!("q 20 0 0 20 {} 0 cm /A Do Q", i * 22)).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 230.0, 30.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) + }; + let (alphas, decodes) = draws(1000); + assert_eq!(alphas, vec![(20, 20); 10], "averaged down to the size it is drawn at"); + // One decode for the image and one for its averaged-down mask, shared by all ten draws. + assert_eq!(decodes, 2, "decoded once"); + let (alphas, _) = draws(40); + assert_eq!(alphas, vec![(40, 40); 10], "no more than twice the drawn size: kept as it is"); +} + // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index bbc26e163f..2e0e64122d 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -108,6 +108,9 @@ pub(crate) struct DecodedImageKey { pub(crate) default_space: Option, pub(crate) target: Option<(u32, u32)>, pub(crate) preview: bool, + /// PdfCraft patch (77): the size a soft mask far larger than where it is drawn was averaged + /// down to (`ImageXObject::decoded_soft_mask`), `None` for a mask kept at its own size. + pub(crate) shrunk_to: Option<(u32, u32)>, } #[derive(Default)] diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 6fcb34a5d7..8d8c652474 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -634,9 +634,39 @@ impl<'a> ImageXObject<'a> { None }, preview: cache.preview, + shrunk_to: None, }) } + /// PdfCraft patch (77): this soft mask (or `/Mask` image) for an image drawn at + /// `target_dimension` device pixels. A mask more than [`SOFT_MASK_SHRINK_FACTOR`] times larger + /// than that on a side is averaged down to it, and the result is cached at that size: drawn + /// through a form many times (a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 times on + /// one slide, pdf.js `issue16263.pdf`), it was decoded, copied and drawn at full size each + /// time, about 3.6 s apiece, and the page took minutes. Any other mask decodes as before. + pub(crate) fn decoded_soft_mask(&self, target_dimension: Option<(u32, u32)>) -> Option { + let shrink_to = target_dimension.filter(|&(tw, th)| { + tw > 0 + && th > 0 + && (u64::from(self.width) > u64::from(tw) * SOFT_MASK_SHRINK_FACTOR + || u64::from(self.height) > u64::from(th) * SOFT_MASK_SHRINK_FACTOR) + }); + let Some((tw, th)) = shrink_to else { + return self.decoded_mask(target_dimension); + }; + // Averaged down only on the sides that are larger than where they are drawn. + let to = (tw.min(self.width).max(1), th.min(self.height).max(1)); + let shrink = || { + let full = decode_mask(self, target_dimension)?; + Some(DecodedMask { luma: shrink_luma(full.luma, to)? }) + }; + let Some(cache) = &self.decoded_cache else { + return shrink(); + }; + let key = self.decode_key(cache, true, target_dimension).map(|key| DecodedImageKey { shrunk_to: Some(to), ..key }); + cache.get_or_insert_with(key, MAX_DECODED_IMAGE_BYTES, decoded_mask_size, shrink) + } + /// PdfCraft patch (32): whether decoding this image depends on the size it is drawn at. /// Only the JPX filter decodes at a reduced resolution for a target size, in the image /// itself or in the soft mask or mask decoded with it; every other filter decodes the whole @@ -672,6 +702,38 @@ impl<'a> ImageXObject<'a> { /// image pixels shared across tiles and scales. Past it the cache starts a fresh epoch. const MAX_DECODED_IMAGE_BYTES: usize = 64 << 20; +/// PdfCraft patch (77): how many times larger than where it is drawn, on a side, a soft mask must be +/// before it is averaged down to that size (`ImageXObject::decoded_soft_mask`). Below it the mask +/// keeps its own resolution, which the renderer filters as it draws. +const SOFT_MASK_SHRINK_FACTOR: u64 = 2; + +/// PdfCraft patch (77): `luma` averaged down to `to` (no larger than it on either side): each +/// target sample is the mean of the source samples whose centres fall in it. +fn shrink_luma(luma: LumaData, to: (u32, u32)) -> Option { + let (sw, sh) = (luma.width as usize, luma.height as usize); + let (tw, th) = (to.0 as usize, to.1 as usize); + if sw == 0 || sh == 0 || tw == 0 || th == 0 || luma.data.len() != sw.checked_mul(sh)? { + return None; + } + let mut sums = vec![0u64; tw.checked_mul(th)?]; + let mut counts = vec![0u32; tw.checked_mul(th)?]; + // Which target column each source column falls in, worked out once. + let column: Vec = (0..sw).map(|x| (x * tw / sw).min(tw - 1)).collect(); + for (y, row) in luma.data.chunks_exact(sw).enumerate() { + let ty = (y * th / sh).min(th - 1); + let base = ty * tw; + for (&v, &tx) in row.iter().zip(&column) { + let i = base + tx; + if let (Some(sum), Some(count)) = (sums.get_mut(i), counts.get_mut(i)) { + *sum += u64::from(v); + *count += 1; + } + } + } + let data = sums.iter().zip(&counts).map(|(&sum, &count)| if count == 0 { 0 } else { (sum / u64::from(count)) as u8 }).collect(); + Some(LumaData { data, width: to.0, height: to.1, ..luma }) +} + /// PdfCraft patch: pixel bytes held by a decoded mask. fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize @@ -1127,7 +1189,8 @@ fn resolve_alpha( { let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; - decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) + // PdfCraft patch (77): averaged down when far larger than where it is drawn, and cached. + obj.decoded_soft_mask(target_dimension).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { let mut mask_data = vec![];