mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b0b8d4c01a
commit
d15b69c734
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user