PDF export: a CMYK image's own soft mask is written as its /SMask, not a luminosity mask (fixes #2159)

This commit is contained in:
Marco Arnulfo
2026-10-17 14:43:14 +07:00
committed by bflatastic
parent 8e69146160
commit 025ce255ae
3 changed files with 151 additions and 17 deletions
+49 -9
View File
@@ -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<Image> {
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<Image>) {
// 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<Image> {
+40 -8
View File
@@ -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<u8>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option<Image> {
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<u8>, alpha: Option<Vec<u8>>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option<Image> {
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<Vec<u8>>,
) -> 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<Vec<u8>> {
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.
+62
View File
@@ -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<u8>, clip: bool, invert: bool) -> (Vec<u8>, Vec<Option<Vec<u8>>>) {
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::<Name<'_>>(b"ColorSpace").is_some_and(|n| n.as_ref() == b"DeviceCMYK") => Some(s),
_ => None,
})
.map(|s| s.dict().get::<Stream<'_>>(b"SMask").map(|m| m.decoded().unwrap().into_owned()))
.collect();
(out.bytes, smasks)
}
fn png(img: impl Into<image::DynamicImage>) -> Vec<u8> {
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");
}