mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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:
1 parent
a719b1daa6
commit
58b18c8036
2 files changed
+107
-12
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user