From 58b18c803696d4512341b48696c4c49f2ef13ad8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 21:09:16 +0800 Subject: [PATCH] M2: an averaged-down soft mask follows its samples' centres and decoded size, and is keyed by the size it is drawn at Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/render/src/raster/tests.rs | 76 ++++++++++++++++++++++++++ vendor/hayro-interpret/src/x_object.rs | 43 +++++++++++---- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 40a2410ba3..cc348f34c6 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -8269,6 +8269,82 @@ fn a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once() { assert_eq!(alphas, vec![(40, 40); 10], "no more than twice the drawn size: kept as it is"); } +/// The soft masks a small image is handed, drawn at each of `sizes` (width × height points at 72 +/// dpi): its `mask` (`width`, `height`, 8-bit samples) is larger than the 2 × 1 image, so it may +/// be averaged down (vendored hayro-interpret patch 77). Each mask is given as its size and +/// samples. +fn soft_masks_handed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)]) -> Vec<(u32, u32, Vec)> { + 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; + + #[derive(Default)] + struct Draws(Vec<(u32, u32, Vec)>); + 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.extend(alpha.map(|a| (a.width, a.height, a.data))), 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 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + let alpha = flate(mask); + let mask_dict = format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + alpha.len() + ); + let content = sizes.iter().map(|(w, h)| format!("q {w} 0 0 {h} 0 0 cm /A Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask_dict, &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, 400.0, 400.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + device.0 +} + +/// An averaged-down soft mask (vendored hayro-interpret patch 77): +/// - takes each source sample into the target sample its centre falls in: seven columns +/// `0 0 255 255 255 255 255` drawn three wide give `0 255 255` (by left edges, `85 255 255`); +/// - is never sized past the samples a truncated stream decodes: a mask that says it is 100 rows +/// but holds 10, drawn 20 high, is handed no rows that no sample covers; +/// - is cached per drawn size: the same image drawn 20 and then 200 points wide is handed its +/// mask at 20 and then at its own 100, not the first draw's 20 again. +#[test] +fn an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size() { + let handed = soft_masks_handed(7, 1, &[0, 0, 255, 255, 255, 255, 255], &[(3, 1)]); + assert_eq!(handed, vec![(3, 1, vec![0, 255, 255])], "centres, not left edges"); + let handed = soft_masks_handed(100, 100, &[255; 100 * 10], &[(20, 20)]); + assert!(!handed.is_empty() && handed.iter().all(|(_, _, data)| data.iter().all(|&a| a == 255)), "no uncovered rows: {handed:?}"); + let sizes: Vec<(u32, u32)> = soft_masks_handed(100, 100, &[255; 100 * 100], &[(20, 20), (200, 200)]).iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(sizes, vec![(20, 20), (100, 100)], "each draw at its own size"); +} + // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 62ea971dff..4542dbfacc 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -663,6 +663,9 @@ impl<'a> ImageXObject<'a> { let to = (tw.min(self.width).max(1), th.min(self.height).max(1)); let shrink = || { let full = decode_mask(self, target_dimension)?; + // A truncated stream decodes fewer rows than the dictionary says: never more target + // samples than decoded ones on a side. + let to = (to.0.min(full.luma.width).max(1), to.1.min(full.luma.height).max(1)); Some(DecodedMask { luma: shrink_luma(full.luma, to)? }) }; let Some(cache) = &self.decoded_cache else { @@ -679,9 +682,16 @@ impl<'a> ImageXObject<'a> { /// step and kept once per zoom level; without it, one decode serves them all. fn decode_reads_target(&self) -> bool { let dict = self.stream.dict(); + // PdfCraft patch (77): a soft mask (or `/Mask` image) larger than the image may be + // averaged down to the size the image is drawn at (`decoded_soft_mask`), so the decoded + // raster, which holds it as its alpha, depends on that size too. + let larger = |s: &Stream<'_>| { + let d = s.dict(); + d.get::(WIDTH).is_some_and(|w| w > self.width) || d.get::(HEIGHT).is_some_and(|h| h > self.height) + }; uses_jpx(&self.stream) - || dict.get::>(SMASK).is_some_and(|s| uses_jpx(&s)) - || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) + || dict.get::>(SMASK).is_some_and(|s| uses_jpx(&s) || larger(&s)) + || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s) || larger(&s)) } pub(crate) fn width(&self) -> u32 { @@ -713,22 +723,31 @@ const MAX_DECODED_IMAGE_BYTES: usize = 64 << 20; 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. +/// target sample is the mean of the source samples whose centres fall in it. Source sample `x` +/// of `sw`, centred at `x + ½`, falls in target sample `⌊(2x + 1)·tw / 2sw⌋`. The only buffers are +/// the target's sums and counts, reserved fallibly; nothing is sized by the source's width. 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)? { + if sw == 0 || sh == 0 || tw == 0 || th == 0 || tw > sw || th > sh || 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(); + let cells = tw.checked_mul(th)?; + let (mut sums, mut counts): (Vec, Vec) = (Vec::new(), Vec::new()); + sums.try_reserve_exact(cells).ok()?; + counts.try_reserve_exact(cells).ok()?; + sums.resize(cells, 0); + counts.resize(cells, 0); + // The target sample a source sample's centre falls in, along an axis of `src` samples + // shrunk to `dst`. `2·i + 1 < 2·src` and `dst ≤ src ≤ 2^32`, so the product fits a u64. + let centre = |i: usize, src: usize, dst: usize| -> usize { + let at = (2 * i as u64 + 1) * dst as u64 / (2 * src as u64); + (at as usize).min(dst - 1) + }; 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; + let base = centre(y, sh, th) * tw; + for (x, &v) in row.iter().enumerate() { + let i = base + centre(x, sw, tw); if let (Some(sum), Some(count)) = (sums.get_mut(i), counts.get_mut(i)) { *sum += u64::from(v); *count += 1;