M13.12: pdf knockout/isolated transparency groups, group alpha and masks on group results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 08:46:41 -04:00
co-authored by Claude Opus 5.5
parent b0b8d4c01a
commit d15b69c734
6 changed files with 296 additions and 22 deletions
+2
View File
@@ -62,6 +62,8 @@ impl Builder {
pub fn clip(&mut self, path: BezPath, rule: FillRule) {
let mut g = group("Clip Group");
g.clip.push((path, rule));
// Clipping is not a transparency group: blend modes inside reach the backdrop.
g.isolated = false;
self.stack.push((g, Kind::Clip));
}
+2
View File
@@ -194,4 +194,6 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_groups;
#[cfg(test)]
mod tests_shading;
+25 -4
View File
@@ -922,12 +922,33 @@ impl<'a> Interp<'a> {
let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY);
let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone());
self.saved.push((self.gs.clone(), self.b.depth()));
self.gs.ctm *= m;
// A transparency group XObject (§11.6.6) composites as one object: the current alpha
// (and the blend-mode and soft-mask groups already open around it) apply to its
// result, and its contents start from the initial alpha.
let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default();
if bb.len() == 4 {
let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]);
self.b.clip(self.gs.ctm * r.to_path(0.1), FillRule::NonZero);
let bbox_path = |ctm: Affine| (bb.len() == 4).then(|| ctm * m * kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]).to_path(0.1));
let mut clipped = false;
if let Some(tg) = f.get_dict(d, "Group").filter(|g| f.get(g, "S").and_then(Obj::name) == Some("Transparency")) {
let mut grp = Group::new("Transparency Group");
// The bounding box clips the group itself (its children stay its direct children,
// as knockout needs).
if let Some(p) = bbox_path(self.gs.ctm) {
grp.clip.push((p, FillRule::NonZero));
clipped = true;
}
grp.opacity = self.gs.fill_alpha;
grp.isolated = matches!(f.get(tg, "I"), Some(Obj::Bool(true)));
grp.knockout = matches!(f.get(tg, "K"), Some(Obj::Bool(true)));
self.b.open_group(grp);
self.gs.fill_alpha = 1.0;
self.gs.stroke_alpha = 1.0;
self.gs.blend_depth = None;
self.gs.mask_depth = None;
}
if !clipped && let Some(p) = bbox_path(self.gs.ctm) {
self.b.clip(p, FillRule::NonZero);
}
self.gs.ctm *= m;
let base = self.gs.ctm;
let path = std::mem::take(&mut self.path);
let (tm, tlm) = (self.tm, self.tlm);
+99
View File
@@ -0,0 +1,99 @@
//! M13.12: transparency groups (isolated, knockout, group alpha) and soft masks on a group's
//! combined result.
use super::*;
use crate::tests::{page_pdf, render};
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() < 0.02)
}
/// A page painting form `Fm` with graphics state `GS` set; the form has `group` as its
/// `/Group` entry body and `content`.
fn group_page(page: &str, gs: &str, group: &str, content: &str) -> Doc {
let bytes = page_pdf(
page,
&format!("/ExtGState << /GS << {gs} >> /M << /BM /Multiply >> /H << /ca 0.5 >> >> /XObject << /Fm 10 0 R >>"),
vec![(
10,
format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] {group} /Resources << /ExtGState << /M << /BM /Multiply >> /H << /ca 0.5 >> >> >> >>"
),
Some(content.as_bytes().to_vec()),
)],
);
let doc = parse(&bytes).unwrap();
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
doc
}
#[test]
fn group_alpha_applies_to_the_combined_result() {
let content = "1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f";
// A transparency group: the overlap is blue at half opacity (no red shows through).
let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content);
let img = render(&doc);
assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "{:?}", img.get(75, 50));
assert!(near(img.get(25, 50), [0.5, 0.0, 0.0, 0.5]), "{:?}", img.get(25, 50));
// A plain form: each rectangle at half opacity.
let doc = group_page("/GS gs /Fm Do", "/ca 0.5", "", content);
let img = render(&doc);
assert!(near(img.get(75, 50), [0.25, 0.0, 0.5, 0.75]), "{:?}", img.get(75, 50));
}
#[test]
fn knockout_groups_composite_with_the_initial_backdrop() {
// Red, then half-opaque blue over it: knocked out, the overlap shows only the blue.
let content = "1 0 0 rg 0 0 100 100 re f /H gs 0 0 1 rg 50 0 100 100 re f";
let ko = group_page("/Fm Do", "", "/Group << /S /Transparency /I true /K true >>", content);
let img = render(&ko);
assert!(near(img.get(75, 50), [0.0, 0.0, 0.5, 0.5]), "knockout {:?}", img.get(75, 50));
assert!(near(img.get(25, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", img.get(25, 50));
let plain = group_page("/Fm Do", "", "/Group << /S /Transparency /I true >>", content);
let img = render(&plain);
assert!(near(img.get(75, 50), [0.5, 0.0, 0.5, 1.0]), "plain {:?}", img.get(75, 50));
// Non-isolated knockout over a white page: the overlap is half blue over the page.
let ko_bd = group_page("1 g 0 0 200 100 re f /Fm Do", "", "/Group << /S /Transparency /K true >>", content);
let img = render(&ko_bd);
assert!(near(img.get(75, 50), [0.5, 0.5, 1.0, 1.0]), "non-isolated knockout {:?}", img.get(75, 50));
}
#[test]
fn isolated_and_non_isolated_groups_with_blend_modes() {
// Yellow page; the group multiplies cyan over it at half group opacity.
let content = "/M gs 0 1 1 rg 0 0 200 100 re f";
let non = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency >>", content);
let img = render(&non);
// yellow × cyan = green, half over yellow.
assert!(near(img.get(100, 50), [0.5, 1.0, 0.0, 1.0]), "non-isolated {:?}", img.get(100, 50));
let iso = group_page("1 1 0 rg 0 0 200 100 re f /GS gs /Fm Do", "/ca 0.5", "/Group << /S /Transparency /I true >>", content);
let img = render(&iso);
// Isolated: multiply against nothing = cyan, half over yellow.
assert!(near(img.get(100, 50), [0.5, 1.0, 0.5, 1.0]), "isolated {:?}", img.get(100, 50));
}
#[test]
fn blend_modes_inside_clips_reach_the_backdrop() {
let bytes = page_pdf("1 1 0 rg 0 0 200 100 re f q 0 0 100 100 re W n /M gs 0 1 1 rg 0 0 200 100 re f Q", "/ExtGState << /M << /BM /Multiply >> >>", vec![]);
let img = render(&parse(&bytes).unwrap());
assert!(near(img.get(50, 50), [0.0, 1.0, 0.0, 1.0]), "{:?}", img.get(50, 50));
assert!(near(img.get(150, 50), [1.0, 1.0, 0.0, 1.0]), "{:?}", img.get(150, 50));
}
#[test]
fn soft_mask_applies_to_the_masked_group_result() {
// Two overlapping opaque rectangles under a 50 % grey luminosity mask: the overlap is
// the top rectangle at half opacity (the mask applies to their composite).
let bytes = page_pdf(
"q /GS1 gs 1 0 0 rg 0 0 100 100 re f 0 0 1 rg 50 0 100 100 re f Q",
"/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>",
vec![(
10,
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(),
Some(b"0.5 g 0 0 200 100 re f".to_vec()),
)],
);
let img = render(&parse(&bytes).unwrap());
let p = img.get(75, 50);
assert!((p[3] - 0.5).abs() < 0.02 && p[0] < 0.02 && (p[2] - 0.5).abs() < 0.02, "{p:?}");
}
+48 -4
View File
@@ -179,12 +179,30 @@ pub struct Group {
/// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's
/// luminosity (or alpha) is high.
pub mask: Option<Box<SoftMask>>,
/// Composited on its own (a transparent backdrop, as SVG groups and PDF isolated
/// transparency groups are) rather than over what is below it (PDF non-isolated groups and
/// clipping groups: blend modes inside reach the backdrop). Matters only when the group is
/// drawn offscreen (clip, mask, blend mode, opacity or knockout).
pub isolated: bool,
/// A PDF knockout group: each child composites with the group's initial backdrop instead
/// of with the children below it.
pub knockout: bool,
}
impl Group {
/// An empty, opaque, unclipped group.
pub fn new(name: &str) -> Group {
Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None }
Group {
name: name.to_string(),
transform: Affine::IDENTITY,
opacity: 1.0,
children: vec![],
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}
}
}
@@ -753,7 +771,17 @@ impl<'a, 'i> Parser<'a, 'i> {
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }));
out.push(Node::Group(Group {
name: Self::element_name(n),
transform,
opacity,
children,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}));
}
"svg" => {
// Nested viewport.
@@ -780,6 +808,8 @@ impl<'a, 'i> Parser<'a, 'i> {
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}));
}
"use" => {
@@ -809,11 +839,23 @@ impl<'a, 'i> Parser<'a, 'i> {
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}));
} else {
self.node(target, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }));
out.push(Node::Group(Group {
name: Self::element_name(n),
transform,
opacity,
children,
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}));
}
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
if !st.visible {
@@ -861,6 +903,8 @@ impl<'a, 'i> Parser<'a, 'i> {
clip: vec![],
blend: BlendMode::Normal,
mask: None,
isolated: true,
knockout: false,
}));
}
}
@@ -1056,7 +1100,7 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
Ok(Doc {
width: dw,
height: dh,
root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None },
root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None, isolated: true, knockout: false },
skipped: std::mem::take(&mut p.skipped),
})
}
+120 -14
View File
@@ -31,26 +31,62 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
if o <= 0.0 {
return;
}
let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1);
if !isolated {
let offscreen = !g.clip.is_empty()
|| g.mask.is_some()
|| g.blend != BlendMode::Normal
|| (g.opacity < 1.0 && !matches!(g.children.as_slice(), [Node::Shape(_) | Node::Image(_)]))
|| g.knockout;
if !offscreen {
for c in &g.children {
draw_node(c, m, o, dst);
}
return;
}
// Draw the children on their own, keep what every clip path covers and the soft mask
// lets through, then composite with the group's opacity and blend mode.
let mut tmp = Image::new(dst.width, dst.height);
for c in &g.children {
draw_node(c, m, 1.0, &mut tmp);
let only_clip = g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout;
// Draw the children on their own (isolated) or over a copy of the backdrop, keep what
// every clip path covers and the soft mask lets through, then composite with the group's
// opacity and blend mode.
let backdrop = if g.isolated { None } else { Some(dst.clone()) };
let mut tmp = match &backdrop {
Some(b) => b.clone(),
None => Image::new(dst.width, dst.height),
};
draw_children(g, m, &mut tmp, backdrop.as_ref());
if let Some(b) = &backdrop {
if only_clip {
// A non-isolated clipping group: the backdrop where the clip excludes the children.
let cov = clip_coverage(g, m, dst.width, dst.height);
dst.data.par_iter_mut().zip(tmp.data.par_iter()).zip(cov.par_iter()).for_each(|((d, t), c)| {
for i in 0..4 {
d[i] = d[i] * (1.0 - c) + t[i] * c;
}
});
return;
}
// Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own
// alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0).
let mut solo = Image::new(dst.width, dst.height);
draw_children(g, m, &mut solo, None);
tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| {
let ag = s[3];
if ag <= 0.0 {
*t = [0.0; 4];
return;
}
let un = |c: &[f32; 4]| if c[3] > 0.0 { [c[0] / c[3], c[1] / c[3], c[2] / c[3]] } else { [0.0; 3] };
let (cn, c0, a0) = (un(t), un(b0), b0[3]);
let k = a0 / ag - a0;
let mut out = [0.0; 4];
for i in 0..3 {
out[i] = (cn[i] + (cn[i] - c0[i]) * k).clamp(0.0, 1.0) * ag;
}
out[3] = ag;
*t = out;
});
}
for (path, rule) in &g.clip {
let rule = match rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule);
tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| {
if !g.clip.is_empty() {
let cov = clip_coverage(g, m, dst.width, dst.height);
tmp.data.par_iter_mut().zip(cov.par_iter()).for_each(|(p, c)| {
for v in p.iter_mut() {
*v *= c;
}
@@ -89,6 +125,76 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
}
}
/// The product of a group's clip-path coverages.
fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec<f32> {
let mut out = vec![1.0f32; w as usize * h as usize];
for (path, rule) in &g.clip {
let rule = match rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), w, h, rule);
out.par_iter_mut().zip(cov.data.par_iter()).for_each(|(o, c)| *o *= c);
}
out
}
/// Draw a group's children into `dst`. Knockout groups composite each child with the
/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below:
/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`.
fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) {
if !g.knockout {
for c in &g.children {
draw_node(c, m, 1.0, dst);
}
return;
}
for c in &g.children {
let mut ci = Image::new(dst.width, dst.height);
draw_node(c, m, 1.0, &mut ci);
// Shape: the child drawn fully opaque (constant opacities to 1).
let mut si = Image::new(dst.width, dst.height);
draw_node(&opaque(c), m, 1.0, &mut si);
let zero = [0.0f32; 4];
dst.data.par_iter_mut().enumerate().for_each(|(i, d)| {
let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3]));
if f <= 0.0 {
return;
}
let b = backdrop.map(|b| b.data[i]).unwrap_or(zero);
for k in 0..4 {
d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]);
}
});
}
}
/// A node with its constant opacities set to 1 (its shape, for knockout groups).
fn opaque(n: &Node) -> Node {
match n {
Node::Group(g) => {
let mut g = g.clone();
g.opacity = 1.0;
g.children = g.children.iter().map(opaque).collect();
Node::Group(g)
}
Node::Shape(s) => {
let mut s = s.clone();
s.opacity = 1.0;
s.fill_opacity = 1.0;
if let Some(st) = &mut s.stroke {
st.opacity = 1.0;
}
Node::Shape(s)
}
Node::Image(i) => {
let mut i = i.clone();
i.opacity = 1.0;
Node::Image(i)
}
}
}
/// `src` composited onto `dst` with a blend mode (both premultiplied RGBA):
/// `co = cs·(1 − αb) + cb·(1 − αs) + αs·αb·B(cb, cs)` (ISO 32000-1 §11.3.6).
pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4]) -> [f32; 4] {