M2: a soft mask far larger than where its image is drawn is averaged down to that size once (hayro-interpret patch 77)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 20:31:08 +08:00
1 parent 863af864a7
commit be5a9209cc
3 files changed
+140 -1

No files matched your search

+73
View File
@@ -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<SoftMask<'a>>) {}
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<SoftMask<'a>>, _: 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::<Vec<_>>());
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::<Vec<_>>().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;
+3
View File
@@ -108,6 +108,9 @@ pub(crate) struct DecodedImageKey {
pub(crate) default_space: Option<u128>,
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)]
+64 -1
View File
@@ -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<DecodedMask> {
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<LumaData> {
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<usize> = (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::<SmallVec<[u16; 4]>>(MASK) {
let mut mask_data = vec![];