From fce90e3144b758b002949962924b2bd6cb660c3b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 02:18:31 +0200 Subject: [PATCH] PDF import: images in an alpha soft mask count by their alpha An alpha soft mask's vector art is whitened so its luminance is its alpha, but images kept their colour: cairo (and so Inkscape) draws a mask as an image whose alpha is in its own SMask, often black pixels, so the mask came in empty. While reading an alpha mask's art the importer now reads images white with their own alpha (under their own cache key, without the JPEG and CMYK shortcuts), and stencil masks white; a luminosity mask nested inside reads them as they are. Fixes #1550 --- crates/pdf/src/import.rs | 28 +++++++++++++---- crates/pdf/src/tests_import_fidelity.rs | 41 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 7d6745dc..1f1da133 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -26,6 +26,8 @@ use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_sh use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; +/// Mixed into an image's cache key when it is read white inside an alpha soft mask. +const WHITE_KEY: u128 = 0x7717_e000_a1fa; /// The name of the paths text imports as. const TEXT_OUTLINES: &str = ""; /// A page whose overprint (ISO 32000-1 ยง8.6.7) is left out rather than given to the wrong objects. @@ -788,6 +790,10 @@ struct Builder<'p> { /// A transparency group was just pushed: a form's (whose flags come next) or an image's. pending: bool, nested: u32, + /// Reading an alpha soft mask's art: images are read white with their own alpha, so the + /// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape, + /// rasterizes masks into such images). + white_images: bool, /// The last filled path: its node and its outline (a stroke of the same outline joins it). last_fill: Option<(NodeId, BezPath)>, /// What each line of type made tells beyond its text object ([`crate::import_lines`]). @@ -930,6 +936,7 @@ impl<'p> Builder<'p> { marked: vec![], pending: false, nested: 0, + white_images: false, last_fill: None, lines: HashMap::new(), } @@ -1299,7 +1306,10 @@ impl<'p> Builder<'p> { return s.clone(); } let page = self.page; - let spec = match self.nested(page, |b| m.interpret(b)) { + let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha); + let art = self.nested(page, |b| m.interpret(b)); + self.white_images = saved; + let spec = match art { Some(art) => { let [r, g, b, _] = m.background_color().to_rgba().components(); let backdrop = luminance(&Color::rgb(r, g, b)); @@ -1834,12 +1844,14 @@ impl<'a> Device<'a> for Builder<'_> { self.pending = false; match image { Image::Raster(r) => { - let key = hayro_interpret::CacheKey::cache_key(&r); + let white = self.white_images; + // Inside an alpha mask the same image is read again, white (its own cache key). + let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 }; let st = r.stream(); let (w, h) = (r.width(), r.height()); // CMYK images keep their samples (read once per image). - let known = self.cmyk_keys.contains(&key); - let cmyk = if known { + let known = !white && self.cmyk_keys.contains(&key); + let cmyk = if known || white { None } else { crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| { @@ -1858,7 +1870,8 @@ impl<'a> Device<'a> for Builder<'_> { let dict = st.dict(); let filters = st.filters(); let cs_ok = dict.get::>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray")); - let jpeg = filters.len() == 1 + let jpeg = !white + && filters.len() == 1 && matches!(filters[0], hayro_syntax::Filter::DctDecode) && cs_ok && !dict.contains_key(b"SMask") @@ -1875,6 +1888,7 @@ impl<'a> Device<'a> for Builder<'_> { let (w, h, sf) = (img.width(), img.height(), img.scale_factors()); let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]); let rgba: Vec = match img { + _ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(), ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(), ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(), }; @@ -1890,11 +1904,13 @@ impl<'a> Device<'a> for Builder<'_> { self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); } Image::Stencil(s) => { - let key = hayro_interpret::CacheKey::cache_key(&s); + let white = self.white_images; + let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 }; let mut decoded: Option = None; s.with_stencil( |luma, paint| { let rgb = match paint { + _ if white => [255, 255, 255, 255], hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(), _ => [0, 0, 0, 255], }; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 05f1bb58..62e60f43 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -78,6 +78,47 @@ fn the_mask(d: &Document) -> (Node, OpacityMask) { (n, m) } +/// An alpha soft mask whose art is an image with its own alpha (cairo, and so Inkscape, rasterizes +/// masks so): the image is read white with that alpha, so the mask's luminance is the alpha, not +/// the image's colour. +#[test] +fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { + let g = first_extra(1); + let group = stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>", + g + 1 + ), + "q 100 0 0 100 0 0 cm /Im0 Do Q", + ); + // Black pixels, opaque on the left and clear on the right. + let image = stream( + &format!( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R", + g + 2 + ), + "000000000000>", + ); + let smask = + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"); + let bytes = one_page( + "/GS0 gs 1 0 0 rg 0 0 100 100 re f", + &format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"), + &[&group, &image, &smask], + ); + let d = open(&bytes); + let (_, m) = the_mask(&d); + let mut keys = vec![]; + m.art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + keys.push(im.key.clone()); + } + }); + let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") }; + let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(1, 0).0), ([255, 255, 255, 255], [255, 255, 255, 0])); +} + #[test] fn a_luminosity_soft_mask_becomes_an_opacity_mask() { let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));