diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 7d729846e..10a39e75d 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1087,8 +1087,13 @@ impl Exporter<'_> { } pushes += 1; } - if let Some(m) = n.extras().mask.as_deref() - && !m.disabled + let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); + let alpha = match (&n.kind, mask) { + (NodeKind::Image(im), Some(m)) => self.masked_image(im, m), + _ => None, + }; + if let Some(m) = mask + && alpha.is_none() { let mask = self.soft_mask(s, m, page); s.push_mask(mask); @@ -1129,7 +1134,7 @@ impl Exporter<'_> { self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => self.image(s, im), + NodeKind::Image(im) => self.image(s, im, alpha), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) { let mut art = (*sym.art).clone(); @@ -1708,7 +1713,7 @@ impl Exporter<'_> { self.rgb_in_cmyk.images.insert(key.to_string()); } let r = if cmyk_image { - crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels()) + crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) }; @@ -1724,15 +1729,44 @@ impl Exporter<'_> { img } - fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) { + /// CMYK image `im` with the alpha of its opacity mask `m`, when the mask is a greyscale image + /// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the + /// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it, + /// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other + /// images and masks; cached like [`Self::load_image`]. + fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option { + let art = &m.art; + let NodeKind::Image(mi) = &art.kind else { return None }; + let plain = + art.opacity >= 1.0 && art.blend == BlendMode::Normal && art.extras().mask.is_none() && !art.appearance.effects.iter().any(|e| e.visible); + if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { + return None; + } + let size = placed_size(im); + let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + if let Some(i) = self.images.get(&cache) { + return i.clone(); + } + let doc = self.doc; + let img = doc.images.get(&im.key).filter(|b| b.is_cmyk()).and_then(|blob| { + let alpha = crate::images::mask_alpha(doc.images.get(&mi.key)?, im.width, im.height, m.clip)?; + let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); + if let (Some(w), Some(_)) = (r.warning, &r.image) { + self.warn(w); + } + r.image + }); + self.images.insert(cache, img.clone()); + img + } + + /// Draw image `im`: `img` when given (its pixels already written), else as it is loaded. + fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option) { // An image placed or sized beyond [`MAX_COORD`] is left out. if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { return; } - // The size it is placed at: its pixel grid's sides through its transform. - let [a, b, c, d, ..] = im.xf.as_coeffs(); - let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)); - let Some(img) = self.load_image(&im.key, size) else { return }; + let Some(img) = img.or_else(|| self.load_image(&im.key, placed_size(im))) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); @@ -1740,6 +1774,12 @@ impl Exporter<'_> { } } +/// The size image `im` is placed at: its pixel grid's sides through its transform. +fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) { + let [a, b, c, d, ..] = im.xf.as_coeffs(); + (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)) +} + /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats decoded. fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option { diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4fb72fc2d..6251f1158 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -87,9 +87,10 @@ fn neutral(rgba: &[u8]) -> bool { rgba.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]) } -/// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) written losslessly. -fn lossless(samples: Vec, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { - Image::from_custom(Raw { color: Arc::new(samples), alpha: None, width, height, space }, interpolate).ok() +/// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha` +/// channel, written losslessly. +fn lossless(samples: Vec, alpha: Option>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { + Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok() } /// [`from_rgba`] of pixels converted by `pixels` (when given). @@ -278,11 +279,20 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in /// [`recode`] of CMYK image `blob` ([`ImageBlob::cmyk`]): a JPEG already as asked is kept as it /// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled, -/// converted by `convert` and compressed again. -pub(crate) fn recode_cmyk(blob: &ImageBlob, size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&CmykPixels>) -> Recoded { +/// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see +/// [`mask_alpha`]) the image is lossless and carries it as its soft mask; `None` then when its +/// inks can't be read. +pub(crate) fn recode_cmyk( + blob: &ImageBlob, + size: (f64, f64), + c: &CompressionSettings, + interpolate: bool, + convert: Option<&CmykPixels>, + alpha: Option>, +) -> Recoded { let rule = rule(c, Kind::Color); let jpeg = jpeg_components(&blob.bytes).is_some(); - if jpeg && convert.is_none() { + if jpeg && convert.is_none() && alpha.is_none() { let dims = ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); if dims.is_some_and(|d| kept(&rule, d, size, true)) { return Recoded { image: None, warning: rule.instead }; @@ -293,6 +303,11 @@ pub(crate) fn recode_cmyk(blob: &ImageBlob, size: (f64, f64), c: &CompressionSet }; let dims = (inks.width, inks.height); let (width, height) = sized(&rule, dims, size); + let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) { + Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()), + Some(None) => return Recoded { image: None, warning: None }, + None => None, + }; // The four inks resample as the four channels of an RGBA image do. let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) { Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(), @@ -302,11 +317,28 @@ pub(crate) fn recode_cmyk(blob: &ImageBlob, size: (f64, f64), c: &CompressionSet Some(p) => p.convert(&inks), None => (inks, ImageColorspace::Cmyk), }; - let image = jpeg_quality(&rule, jpeg).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); - let image = image.or_else(|| lossless(samples, space, width, height, interpolate)); + // JPEG needs opaque pixels. + let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); + let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate)); Recoded { image, warning: rule.instead } } +/// The alpha an opacity mask whose art is greyscale image `blob` alone gives the image of +/// `width` × `height` pixels it covers pixel for pixel: the mask's luminance, over black where the +/// mask image is transparent when it clips (`clip`), else over white. `None` when `blob` isn't +/// that many pixels or isn't grey (the luminance of colours is the soft mask's to work out). +pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool) -> Option> { + if blob.is_cmyk() { + return None; + } + let img = image::load_from_memory(&blob.bytes).ok()?.into_rgba8(); + if img.dimensions() != (width, height) || !neutral(img.as_raw()) { + return None; + } + let back = if clip { 0 } else { 255 }; + Some(img.pixels().map(|p| ((u32::from(p[0]) * u32::from(p[3]) + back * (255 - u32::from(p[3])) + 127) / 255) as u8).collect()) +} + /// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at /// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array /// undoes. diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 3d68dc044..65f93d3dc 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -367,3 +367,65 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ assert_eq!(w.space, "DeviceCMYK"); assert_eq!(blob.cmyk().unwrap().data, flat(ink, 16)); } + +/// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid +/// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). +fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { + let ink = [0u8, 102, 255, 0]; + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat(ink, 4)).unwrap()).unwrap()); + d.images.insert("alpha".into(), ImageBlob::new("image/png", mask)); + let image = |key: &str| { + NodeKind::Image(Box::new(ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::scale(20.0), + link: None, + placement: Default::default(), + })) + }; + let mut mask = vectorcraft_doc::OpacityMask::new(Node::new(d.alloc_id(), image("alpha")), clip); + mask.invert = invert; + let n = Node::new(d.alloc_id(), image("img")).with_mask(Some(Box::new(mask))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let press = builtin_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings; + let out = export_with_report(&d, &PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }).unwrap(); + let file = Pdf::new(Arc::new(out.bytes.clone())).unwrap(); + let smasks = file + .objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"ColorSpace").is_some_and(|n| n.as_ref() == b"DeviceCMYK") => Some(s), + _ => None, + }) + .map(|s| s.dict().get::>(b"SMask").map(|m| m.decoded().unwrap().into_owned())) + .collect(); + (out.bytes, smasks) +} + +fn png(img: impl Into) -> Vec { + let mut out = Vec::new(); + img.into().write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A CMYK image whose opacity mask is a grey image on its own pixel grid (an image's soft mask +/// as it opens) is written with the mask as its `/SMask`, as an RGBA image is: CoreGraphics draws +/// CMYK images through luminosity soft masks on black boxes (#2159). +#[test] +fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { + let (bytes, smasks) = export_masked(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, false); + assert_eq!(smasks, [Some(vec![255, 128, 0, 64])], "the CMYK image, its alpha the mask's greys"); + assert!(!String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "no luminosity soft mask"); + assert!(written(&bytes).iter().any(|w| w.space == "DeviceCMYK"), "the inks stay CMYK"); + // A mask that doesn't clip shows white where its image is transparent: lum · a + 255 · (1 − a). + let la = image::GrayAlphaImage::from_raw(2, 2, vec![200, 128, 0, 0, 100, 255, 255, 64]).unwrap(); + let (_, smasks) = export_masked(png(la), false, false); + assert_eq!(smasks, [Some(vec![227, 255, 100, 255])]); + // An inverted mask stays a luminosity soft mask. + let (bytes, smasks) = export_masked(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, true); + assert_eq!(smasks, [None]); + assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); +}