From fce90e3144b758b002949962924b2bd6cb660c3b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 02:18:31 +0200 Subject: [PATCH 1/2] 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 7d6745dc9..1f1da133b 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 05f1bb58d..62e60f43f 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")); From 44d170f059d079dddbef7db6684722a651c8506c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 07:35:32 +0700 Subject: [PATCH 2/2] PDF import: pattern swatches read inside an alpha mask keep their images; a luminosity mask test --- crates/pdf/src/import.rs | 7 ++++++- crates/pdf/src/tests_import_fidelity.rs | 27 +++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 1f1da133b..bac31b723 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1340,10 +1340,15 @@ impl<'p> Builder<'p> { { return Some(Paint::Pattern { pattern: name.clone(), xf }); } + // A swatch is shared with uses outside an alpha mask, and pattern paints keep their + // colours there: its images are read as they are. + let white = std::mem::take(&mut self.white_images); let art = self.nested(Rect::ZERO, |b| { // A cell whose content can't be decoded is empty. let _ = t.interpret(b, Affine::IDENTITY, stroke); - })?; + }); + self.white_images = white; + let art = art?; let sig = paints(&art); let entry = self.pattern_keys.entry(key).or_default(); entry.0 += 1; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 62e60f43f..4a9a1acf4 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -78,11 +78,9 @@ 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() { +/// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear +/// on the right (its own `/SMask`). +fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] { let g = first_extra(1); let group = stream( &format!( @@ -91,7 +89,6 @@ fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { ), "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", @@ -103,7 +100,7 @@ fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { 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 >> >> >>"), + &format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"), &[&group, &image, &smask], ); let d = open(&bytes); @@ -116,7 +113,21 @@ fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { }); 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])); + [px.get_pixel(0, 0).0, px.get_pixel(1, 0).0] +} + +/// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks +/// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the +/// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour). +#[test] +fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() { + assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]); +} + +/// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black. +#[test] +fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() { + assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]); } #[test]