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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 21:09:16 +08:00
1 parent a719b1daa6
commit 58b18c8036
2 files changed
+107 -12

No files matched your search

+76
View File
@@ -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<u8>)> {
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<u8>)>);
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.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::<Vec<_>>().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;
+31 -12
View File
@@ -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::<u32>(WIDTH).is_some_and(|w| w > self.width) || d.get::<u32>(HEIGHT).is_some_and(|h| h > self.height)
};
uses_jpx(&self.stream)
|| dict.get::<Stream<'_>>(SMASK).is_some_and(|s| uses_jpx(&s))
|| dict.get::<Stream<'_>>(MASK).is_some_and(|s| uses_jpx(&s))
|| dict.get::<Stream<'_>>(SMASK).is_some_and(|s| uses_jpx(&s) || larger(&s))
|| dict.get::<Stream<'_>>(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<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)? {
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<usize> = (0..sw).map(|x| (x * tw / sw).min(tw - 1)).collect();
let cells = tw.checked_mul(th)?;
let (mut sums, mut counts): (Vec<u64>, Vec<u32>) = (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;