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"));