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
This commit is contained in:
Gert Pellin authored and bflatastic committed 2026-10-13 08:11:27 +07:00
1 parent 3752b3ec31
commit fce90e3144
2 files changed
+63 -6

No files matched your search

+22 -6
View File
@@ -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 = "<Text Outlines>";
/// 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::<Name<'_>>(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<u8> = 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<Decoded> = 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],
};
+41
View File
@@ -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"));