From d15b69c734c766aa206c73d79754fc7b579f4849 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:46:41 -0400 Subject: [PATCH] M13.12: pdf knockout/isolated transparency groups, group alpha and masks on group results Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/build.rs | 2 + crates/pdf/src/lib.rs | 2 + crates/pdf/src/page.rs | 29 ++++++- crates/pdf/src/tests_groups.rs | 99 ++++++++++++++++++++++++ crates/svg/src/lib.rs | 52 ++++++++++++- crates/svg/src/raster.rs | 134 +++++++++++++++++++++++++++++---- 6 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 crates/pdf/src/tests_groups.rs diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 4eeeb53bd..7023e931c 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -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)); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index a2f98c9fe..985a70830 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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; diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 67d9ff8b1..b4a7799d1 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -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); diff --git a/crates/pdf/src/tests_groups.rs b/crates/pdf/src/tests_groups.rs new file mode 100644 index 000000000..3e27dd7c9 --- /dev/null +++ b/crates/pdf/src/tests_groups.rs @@ -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:?}"); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8bc170b1f..2825cdfaa 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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>, + /// 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 { 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), }) } diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 975adc5aa..b1b9d93b6 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -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 { + 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] {