diff --git a/crates/engine/src/tests/knockout.rs b/crates/engine/src/tests/knockout.rs index 723ef66dd2..9f2139fd47 100644 --- a/crates/engine/src/tests/knockout.rs +++ b/crates/engine/src/tests/knockout.rs @@ -138,7 +138,7 @@ fn knockout_settings_round_trip_and_old_bools_load() { } #[test] -fn pdf_and_svg_write_knockout_groups_as_masks() { +fn pdf_writes_knockout_groups_and_svg_writes_them_as_masks() { let mut s = session(); let g = knockout_group(&mut s); let plain = s.doc().unwrap().doc.clone(); @@ -149,10 +149,11 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { let r = pdf(&plain); assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty()); let r = pdf(&ko); - let text = String::from_utf8_lossy(&r.bytes); - // The lower rectangle is drawn through a luminosity mask made of the upper one's alpha. - assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks"); - assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings); + let text = String::from_utf8_lossy(&r.bytes).replace(' ', ""); + // The rectangles knock out the area they paint: a PDF knockout group, without soft masks. + assert_eq!(text.matches("/Ktrue").count(), 1, "a knockout group"); + assert!(!text.contains("/Luminosity"), "no soft masks"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default()); assert!(!svg(&plain).contains("knockout")); @@ -173,5 +174,5 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { assert!(svg(&page).contains("isolation")); page.page_knockout = true; assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out"); - assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity")); + assert!(String::from_utf8_lossy(&pdf(&page).bytes).replace(' ', "").contains("/Ktrue")); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f4d65398e0..8cbec01de9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -46,7 +46,7 @@ use exporter::{MAX_COORD, writable, xf}; #[cfg(test)] use images::{png_8_bit, png_16_bit}; #[cfg(test)] -pub(crate) use nodes::MEASURES; +pub(crate) use nodes::{KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES}; pub(crate) use writer::{Sheet, Writer}; pub(crate) struct Exporter<'a> { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index f8fb792ec2..2f3eed1e13 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -57,70 +57,119 @@ impl Exporter<'_> { /// The children of a group, as the elements of a knockout group when it is one. pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { - self.measured_children(s, children, page, &[]); + self.measured_children(s, children, page, &[], false); } - /// [`Self::children`] with the boxes of the last members in `measured`. - fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured]) { + /// [`Self::children`] with the boxes of the last members in `measured`, in an isolated group + /// when `isolated`. + fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured], isolated: bool) { if !self.knockout { + if isolated { + s.push_isolated(); + } let unmeasured = children.len().saturating_sub(measured.len()); for (i, c) in children.iter().enumerate() { self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i))); } + if isolated { + s.pop(); + } return; } - // The PDF writer has no knockout groups: each element is drawn through a soft mask of - // where the elements above it don't paint (the same look for Normal blending). The masks - // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. - self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); - let elements: Vec<_> = Node::knockout_elements(children) + let elements: Vec<(&Node, Measured)> = Node::knockout_elements(children) .into_iter() - .map(|c| (c, self.measure(c))) + .map(|c| (&**c, self.measure(c))) .filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) .collect(); - let Some(((first, measured), rest)) = elements.split_first() else { return }; - for (c, m) in rest.iter().rev() { - let mask = self.knockout_mask(s, c, page, m); - s.push_mask(mask); + if elements.is_empty() { + return; } - self.measured_node(s, first, page, false, Some(measured)); - for (c, m) in rest { - s.pop(); + let several = elements.len() > 1; + // A PDF knockout group (`/K`) knocks out by the area each element paints. Where an element + // above another knocks out by less (its opacity or mask defines its knockout shape, or it + // paints translucently), each element is drawn through one soft mask of where the + // elements above it that overlap it paint, while those masks hold no more than + // [`MAX_KNOCKOUT_MASK_OBJECTS`] objects. + if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) { + if let Some(above) = knockout_masks(&elements) { + if isolated { + s.push_isolated(); + } + for ((c, m), above) in elements.iter().zip(above) { + if above.is_empty() { + self.measured_node(s, c, page, false, Some(m)); + } else { + let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect(); + let mask = self.knockout_mask(s, &shapes, page); + s.push_mask(mask); + self.measured_node(s, c, page, false, Some(m)); + s.pop(); + } + } + if isolated { + s.pop(); + } + return; + } + self.warn(KNOCKOUT_SHAPES); + } + let group = several || isolated; + if group { + s.push_group(krilla::blend::GroupOptions { isolated, knockout: several }); + } + for (c, m) in &elements { + // In a PDF knockout group each painting operation knocks out the ones before it. An + // element drawn with several is one non-isolated group (drawn through a full alpha + // mask), so that its own fills and strokes don't knock each other out; its opacity + // or opacity mask makes it one already. + let unit = several && !one_paint(c) && c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled); + if unit { + let all = constant_mask(s, page, 1.0); + s.push_mask(all); + } self.measured_node(s, c, page, false, Some(m)); + if unit { + s.pop(); + } + } + if group { + s.pop(); } } - /// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white, - /// then black through an alpha mask of `c` (at full object opacity without its own mask, unless - /// those define its shape). - fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask { - let shape = if c.knockout_shape { - c.clone() - } else { - let mut s = c.clone().with_mask(None); - s.opacity = 1.0; - s - }; + /// A luminosity mask that is 1 − the knockout shapes of `shapes` (each with its box): white, + /// then black through an alpha mask of each (at full object opacity without its own mask, + /// unless those define its shape). + fn knockout_mask(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask { let transform = s.group_transform(); let mut sb = s.stream_builder(); let mut ms = Surface::stream(sb.surface()); ms.push_transform(&transform); cover(&mut ms, page, 255, 1.0); - let alpha = { - let transform = ms.group_transform(); - let mut ab = ms.stream_builder(); - let mut als = Surface::stream(ab.surface()); - als.push_transform(&transform); - let overprint = std::mem::take(&mut self.overprint); - self.measured_node(&mut als, &shape, page, false, Some(measured)); - self.overprint = overprint; - als.pop(); - als.finish(); - krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) - }; - ms.push_mask(alpha); - cover(&mut ms, page, 0, 1.0); - ms.pop(); + for (c, measured) in shapes.iter().map(|e| (e.0, &e.1)) { + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let alpha = { + let transform = ms.group_transform(); + let mut ab = ms.stream_builder(); + let mut als = Surface::stream(ab.surface()); + als.push_transform(&transform); + let overprint = std::mem::take(&mut self.overprint); + self.measured_node(&mut als, &shape, page, false, Some(measured)); + self.overprint = overprint; + als.pop(); + als.finish(); + krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) + }; + ms.push_mask(alpha); + cover(&mut ms, page, 0, 1.0); + ms.pop(); + } ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) @@ -177,23 +226,7 @@ impl Exporter<'_> { // Whether this container's children knock each other out (a knockout group is written as a group). let knockout = n.knocks_out(self.knockout); let enclosing = std::mem::replace(&mut self.knockout, knockout); - let mut pushes = 0; - if n.blend != BlendMode::Normal { - s.push_blend_mode(blend(n.blend)); - pushes += 1; - } - if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) { - if !n.isolate && n.blends_through() { - // Not isolated: blending inside reaches the art below the group. - let alpha = constant_mask(s, page, n.opacity); - s.push_mask(alpha); - } else if n.opacity < 1.0 { - s.push_opacity(norm(n.opacity)); - } else { - s.push_isolated(); - } - pushes += 1; - } + let (mut pushes, isolated_children) = transparency(s, n, page, container, knockout); 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, !n.extras().pixelated), @@ -214,7 +247,7 @@ impl Exporter<'_> { } match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); + self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()), isolated_children); } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // The region every output clips to; nothing to clip by hides the clipped art. The @@ -675,3 +708,119 @@ impl Exporter<'_> { true } } + +/// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a +/// group or layer whose children `knockout` each other). Returns how many were opened, and +/// whether the children are to be drawn in an isolated group of their own: the knockout group of +/// an unclipped group's elements is its isolated group too. +fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) { + let mut pushes = 0; + if n.blend != BlendMode::Normal { + s.push_blend_mode(blend(n.blend)); + pushes += 1; + } + let mut isolated_children = false; + if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) { + if !n.isolate && n.blends_through() { + // Not isolated: blending inside reaches the art below the group. + let alpha = constant_mask(s, page, n.opacity); + s.push_mask(alpha); + pushes += 1; + } else if n.opacity < 1.0 { + s.push_opacity(norm(n.opacity)); + pushes += 1; + } else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { + isolated_children = true; + } else { + s.push_isolated(); + pushes += 1; + } + } + (pushes, isolated_children) +} + +/// The warning for a knockout group whose elements are too many to draw through soft masks: it is +/// written as a PDF knockout group, where an element knocks out all of the area it paints. +pub(crate) const KNOCKOUT_SHAPES: &str = "knockout groups with too many overlapping translucent elements are written as PDF knockout groups: \ + their translucent elements knock out what they cover fully"; + +/// The most objects the soft masks of one knockout group's elements draw between them: each mask +/// draws the elements above its own element that overlap it. +pub(crate) const MAX_KNOCKOUT_MASK_OBJECTS: usize = 1 << 16; + +/// The most elements of one knockout group drawn through soft masks: finding the elements that +/// overlap compares every pair. +pub(crate) const MAX_MASKED_ELEMENTS: usize = 2048; + +/// For each element of a knockout group (with its box), the indices of the elements above it whose +/// boxes overlap its own. `None` when there are more than [`MAX_MASKED_ELEMENTS`] elements or the +/// masks would draw more than [`MAX_KNOCKOUT_MASK_OBJECTS`] objects. +fn knockout_masks(elements: &[(&Node, Measured)]) -> Option>> { + if elements.len() > MAX_MASKED_ELEMENTS { + return None; + } + let counts: Vec = elements.iter().map(|(c, _)| c.count_with_masks()).collect(); + let mut objects = 0usize; + let mut out = Vec::with_capacity(elements.len()); + for (i, (_, below)) in elements.iter().enumerate() { + let mut above = vec![]; + for (j, (_, over)) in elements.iter().enumerate().skip(i + 1) { + if let (Some(a), Some(b)) = (below.bounds, over.bounds) + && rects_overlap(a.inflate(1.0, 1.0), b.inflate(1.0, 1.0)) + { + objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS)?; + above.push(j); + } + } + out.push(above); + } + Some(out) +} + +/// Whether `n` is drawn with one painting operation at most: a path or compound path with at most +/// one visible fill or stroke that paints, in a solid color, without effects, a brush or +/// arrowheads. +fn one_paint(n: &Node) -> bool { + let mut items = n.appearance.items.iter().filter(|i| i.visible() && !matches!(i.paint(), Paint::None)); + let simple = items.next().is_none_or(|i| { + let plain = match i { + AppearanceItem::Fill(_) => true, + AppearanceItem::Stroke(st) => st.brush.is_none() && st.start_arrow.is_none() && st.end_arrow.is_none(), + }; + plain && i.effects().is_empty() && matches!(i.paint(), Paint::Solid { .. }) + }); + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.effects.is_empty() && simple && items.next().is_none() +} + +/// Whether element `c` of a knockout group knocks out all of the area it paints, as an element of +/// a PDF knockout group does: its opacity and opacity mask are full or don't define its knockout +/// shape, and every part of it paints opaquely. +fn shape_is_coverage(c: &Node) -> bool { + let own = !c.knockout_shape || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled)); + own && paints_opaque(c) +} + +/// Whether what `n` paints is opaque wherever it paints, at its own opacity and mask: its visible +/// fills and strokes are solid colors or gradients with opaque stops, without effects or brushes, +/// and so are the members of a group or compound path, at full opacity without an opacity mask. +fn paints_opaque(n: &Node) -> bool { + let paint = |p: &Paint| match p { + Paint::None | Paint::Solid { .. } => true, + Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), + _ => false, + }; + let items = n.appearance.items.iter().all(|i| { + let brush = matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some()); + !i.visible() || (i.opacity() >= 1.0 && i.effects().is_empty() && !brush && paint(i.paint())) + }); + let member = + |c: &std::sync::Arc| !c.visible || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled) && paints_opaque(c)); + n.appearance.effects.is_empty() + && items + && match &n.kind { + NodeKind::Path { .. } => true, + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(member), + NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), + _ => false, + } +} diff --git a/crates/pdf/src/surface.rs b/crates/pdf/src/surface.rs index 4229781cdd..b20b9667ab 100644 --- a/crates/pdf/src/surface.rs +++ b/crates/pdf/src/surface.rs @@ -81,6 +81,11 @@ impl<'a> Surface<'a> { self.group(|s| s.push_isolated()); } + /// A transparency group with `options` (isolated, knockout), as [`Self::push_isolated`]. + pub fn push_group(&mut self, options: krilla::blend::GroupOptions) { + self.group(|s| s.push_group(options)); + } + /// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art. pub fn push_mask(&mut self, mask: Mask) { self.group(|s| s.push_mask(mask)); diff --git a/crates/pdf/src/tests/blend.rs b/crates/pdf/src/tests/blend.rs index 346b4f34d9..aff54fa2ac 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -97,3 +97,165 @@ fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() { assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}"); } } + +/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an +/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape. +fn knockout_squares(n: usize, knockout_shape: bool, isolate: bool) -> Document { + let mut d = Document::new(200.0, 200.0); + add(&mut d, rect(Rect::new(0.0, 0.0, 200.0, 200.0), (0.9, 0.8, 0.2))); + let colors = [(1.0, 0.0, 0.0), (0.0, 0.8, 0.0), (0.0, 0.0, 1.0)]; + let squares = (0..n) + .map(|i| { + let x = (i % 20) as f64 * 2.0; + let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]); + (s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape); + std::sync::Arc::new(s) + }) + .collect(); + let mut g = Node::group(NodeId(0), squares); + (g.knockout, g.isolate) = (vectorcraft_doc::Knockout::On, isolate); + add(&mut d, g); + d +} + +/// How many objects each knockout group of `d` holds. +fn knockout_groups(d: &Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if n.knockout == vectorcraft_doc::Knockout::On { + out.push(n.children().map_or(0, |c| c.len())); + } + }); + out +} + +/// A knockout group whose elements knock out the area they paint is written as one PDF knockout +/// group (`/K true`), whatever the number of elements, and reads back as a knockout group that +/// draws as the original does. +#[test] +fn a_knockout_group_is_written_as_one_pdf_knockout_group() { + for n in [0, 1, 2, 49, 50, 200] { + let d = knockout_squares(n, false, false); + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), usize::from(n > 1), "{n}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{n}: {e}")); + assert_eq!(knockout_groups(&back), if n > 1 { vec![n] } else { vec![] }, "{n}"); + if n == 200 { + vectorcraft_testkit::raster::assert_similar( + &vectorcraft_testkit::raster::render_artboard(&back), + &vectorcraft_testkit::raster::render_artboard(&d), + 4.0, + 0.01, + ); + } + } +} + +/// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the +/// group reads back as isolated and knocking out. (Each translucent element is a group of its own.) +#[test] +fn an_isolated_knockout_group_is_one_isolated_pdf_knockout_group() { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + // Blending inside, which isolation keeps from the backdrop. + let g = d.layers[0].children().unwrap()[1].id; + let top = d.node(g).unwrap().children().unwrap()[2].id; + d.node_mut(top).unwrap().blend = BlendMode::Multiply; + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}"); + assert_eq!(t.contains("/Itrue/Ktrue"), isolate, "isolate {isolate}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = vec![]; + back.walk(|n| { + if n.knockout == vectorcraft_doc::Knockout::On { + found.push(n.isolate); + } + }); + assert_eq!(found, [isolate]); + } +} + +/// Elements whose opacity defines their knockout shape knock out less than a PDF knockout group +/// knocks out: each is drawn through one soft mask of the elements above it, so that 200 of them +/// still read back, every one of them. +#[test] +fn elements_whose_opacity_defines_their_knockout_shape_are_drawn_through_one_mask_each() { + let d = knockout_squares(200, true, false); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings); + assert!(!text(&d).contains("/Ktrue")); + let back = import(&r.bytes).unwrap(); + let mut paths = 0; + back.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + // The backdrop and the 200 squares (the masks' copies of them are opacity-mask art). + assert_eq!(paths, 201); +} + +/// When the masks would draw more than [`crate::export::MAX_KNOCKOUT_MASK_OBJECTS`] objects, the +/// group is written as a PDF knockout group, with a warning, and reads back as a knockout group. +#[test] +fn a_knockout_group_too_large_to_mask_is_a_pdf_knockout_group_with_a_warning() { + // 400 elements that all overlap: 79,800 objects in their masks. + const { assert!(400 * 399 / 2 > crate::export::MAX_KNOCKOUT_MASK_OBJECTS) }; + let d = knockout_squares(400, true, false); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings); + assert_eq!(text(&d).matches("/Ktrue").count(), 1); + assert_eq!(knockout_groups(&import(&r.bytes).unwrap()), [400]); +} + +/// In a PDF knockout group each painting operation knocks out the ones before it: an element with +/// two fills, the second in Multiply, is drawn as one group (through a full alpha mask), so that +/// its second fill blends with its first. An element with one fill needs no group. +#[test] +fn an_element_with_several_paints_is_one_group_in_a_knockout_group() { + for (fills, groups) in [(1, 0), (2, 1)] { + let mut d = Document::new(200.0, 200.0); + let below = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0)); + let mut top = rect(Rect::new(50.0, 50.0, 150.0, 150.0), (1.0, 0.0, 0.0)); + if fills == 2 { + let mut multiply = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + multiply.blend = BlendMode::Multiply; + top.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(multiply)); + } + top.id = d.alloc_id(); + let below = Node { id: d.alloc_id(), ..below }; + let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(below), std::sync::Arc::new(top)]); + g.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, g); + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "{fills}"); + let Some(start) = t.find("/Ktrue") else { panic!("no knockout group") }; + let group = &t[start..]; + let Some(end) = group.find("endstream") else { panic!("no knockout stream") }; + assert_eq!(group[..end].matches("Do").count(), groups, "{fills}: {t}"); + } +} + +/// Elements that don't overlap need no masks of each other. +#[test] +fn knockout_masks_hold_only_the_elements_that_overlap() { + let mut d = Document::new(200.0, 100.0); + let squares = (0..3) + .map(|i| { + let x = f64::from(i) * 60.0; + let mut s = rect(Rect::new(x, 0.0, x + 40.0, 40.0), (0.0, 0.0, 1.0)); + (s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true); + std::sync::Arc::new(s) + }) + .collect(); + let mut g = Node::group(NodeId(0), squares); + g.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, g); + let t = text(&d); + assert!(!t.contains("/Luminosity") && !t.contains("/Ktrue"), "{t}"); +} + +/// PDF/A-2 allows transparency: a knockout group is written as one there too. +#[test] +fn a_pdf_a_2b_file_keeps_a_knockout_group() { + let mut o = PdfOptions::default(); + (o.settings.standard, o.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version()); + let bytes = export(&knockout_squares(3, false, false), &o).unwrap(); + assert_eq!(knockout_groups(&import(&bytes).unwrap()), [3]); +} diff --git a/crates/pdf/tests/gradient_forms.rs b/crates/pdf/tests/gradient_forms.rs index 9f06c9b844..7373ec8494 100644 --- a/crates/pdf/tests/gradient_forms.rs +++ b/crates/pdf/tests/gradient_forms.rs @@ -91,9 +91,7 @@ fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (D /// Round trips preserve gradient placement for the form types the importer supports. #[test] fn gradients_in_forms_keep_their_position_when_read_back() { - // The importer has a pre-existing mismatch on the knockout's synthesized nested masks; - // Quartz below checks its actual PDF output, along with the other forms. - for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) { + for form in FORMS { for kind in [GradientKind::Linear, GradientKind::Radial] { for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] { let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin); diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 73700eb8e3..6d22dff22c 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -156,6 +156,18 @@ fn pin_to_page(n: &mut Node, m: Affine) { /// What SVG Tiny 1.2 does without masks. const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups"; + +/// What a knockout group too large to mask is written as. +const KNOCKOUT_SIZE: &str = "knockout groups with too many overlapping elements to mask are written as plain groups"; + +/// The most knockout shapes the masks of one knockout group's elements use between them: each +/// mask uses the shapes of the elements above its own element that overlap it. +const MAX_KNOCKOUT_MASK_SHAPES: usize = 1 << 16; + +/// The most elements of one knockout group drawn through masks: finding the elements that +/// overlap compares every pair. +const MAX_MASKED_ELEMENTS: usize = 2048; + pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; /// The region of a mask or filter over art that has no box to measure: one covering any artwork. @@ -443,20 +455,39 @@ impl Writer<'_> { region } + /// The box of what each of `nodes` paints, as [`Self::paint_box`] measures it. + fn paint_boxes(&mut self, nodes: &[&Node]) -> Vec>> { + let symbols = self.region_boxes.take().unwrap_or_else(|| self.doc.symbol_boxes()); + let mut art_reach = std::mem::take(&mut self.art_reach); + let boxes = nodes.iter().map(|n| self.paint_box(&symbols, &mut art_reach, n)).collect(); + self.region_boxes = Some(symbols); + self.art_reach = art_reach; + boxes + } + + /// The box of what `n` paints in the current user space, as [`Self::region_attrs`] measures + /// it: `Some(None)` when it has no box, `None` when the box can't be told (type, or nesting + /// deeper than [`MAX_REACH_DEPTH`]). + fn paint_box(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap, n: &Node) -> Option> { + let Some(v) = n.visual_bounds_with(symbols) else { return Some(None) }; + let r = self.paint_reach(symbols, art_reach, n, v, 0)?; + let g = n.geometric_bounds_with(symbols).unwrap_or(v); + let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); + let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); + let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); + Some(Some(scaled.union(unscaled))) + } + /// [`Self::region_attrs`] with the document's symbol boxes and the symbols' art reach so far. fn region_of(&self, symbols: &vectorcraft_doc::SymbolBoxes, art_reach: &mut HashMap, nodes: &[&Node]) -> String { let mut painted: Option = None; for n in nodes { - let Some(v) = n.visual_bounds_with(symbols) else { continue }; - let Some(r) = self.paint_reach(symbols, art_reach, n, v, 0) else { + let Some(b) = self.paint_box(symbols, art_reach, n) else { return BIG.to_string(); }; - let g = n.geometric_bounds_with(symbols).unwrap_or(v); - let stroke = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1].into_iter().fold(0.0, f64::max); - let scaled = self.xf.transform_rect_bbox(v.inflate(r, r)); - let unscaled = self.xf.transform_rect_bbox(g).inflate(stroke + r, stroke + r); - let both = scaled.union(unscaled); - painted = Some(painted.map_or(both, |p| p.union(both))); + if let Some(both) = b { + painted = Some(painted.map_or(both, |p| p.union(both))); + } } let Some(b) = painted.map(|b| b.inflate(2.0, 2.0)).filter(|b| b.is_finite()) else { return BIG.to_string(); @@ -565,8 +596,10 @@ impl Writer<'_> { } /// Children of the group being written. SVG has no knockout groups: in one, each element is - /// drawn through a mask of where the elements above it don't paint (the same look for Normal - /// blending). The masks nest, so element i sits inside the masks of elements i+1…n. + /// drawn through one mask of where the elements above it that overlap it don't paint (the + /// same look for Normal blending). The masks share one copy of each element's knockout shape, + /// and the elements stay siblings however many there are. A group whose masks would hold more + /// than [`MAX_KNOCKOUT_MASK_SHAPES`] shapes is written as a plain group, with a warning. pub(super) fn children(&mut self, children: &[std::sync::Arc]) { if self.knockout && self.tiny() { self.warn(TINY_MASKS); @@ -586,41 +619,101 @@ impl Writer<'_> { } return; } - let Some((first, rest)) = elements.split_first() else { return }; - // The elements are written first, in paint order, so that each mask copies an element + let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let Some(above) = self.knockout_masks(&all) else { + self.warn(KNOCKOUT_SIZE); + for c in elements { + self.node(c); + } + return; + }; + // The elements are written first, in paint order, so that each shape copies an element // with the symbol art count the element itself was written with - // (`Self::symbol_nodes`): an instance left out of an element is left out of its mask. - let (depth, nest) = (self.depth, rest.len()); - let first = self.detached(depth + nest, |w| w.node(first)); - let mut written = Vec::with_capacity(nest); - for (i, c) in rest.iter().enumerate() { + // (`Self::symbol_nodes`): an instance left out of an element is left out of its shape. + let depth = self.depth; + let mut written = Vec::with_capacity(elements.len()); + for (c, above) in elements.iter().zip(&above) { let before = self.symbol_nodes; - written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); + written.push((before, self.detached(depth + usize::from(!above.is_empty()), |w| w.node(c)))); } let after = self.symbol_nodes; // Each mask covers what the group's elements paint. - let all: Vec<&Node> = elements.iter().map(|c| &***c).collect(); let region = self.region_attrs(&all); - for (c, (before, _)) in rest.iter().zip(&written).rev() { - self.symbol_nodes = *before; - let mid = self.knockout_mask(c, ®ion); - self.line(&format!("")); - self.depth += 1; + let mut shapes: Vec> = vec![None; elements.len()]; + for &j in above.iter().flatten() { + if shapes[j].is_none() { + self.symbol_nodes = written[j].0; + shapes[j] = Some(self.knockout_shape(elements[j])); + } } self.symbol_nodes = after; - self.body.push_str(&first); - for (_, body) in written { - self.depth -= 1; - self.line(""); + for (above, (_, body)) in above.iter().zip(written) { + if above.is_empty() { + self.body.push_str(&body); + continue; + } + let ids: Vec<&str> = above.iter().filter_map(|&j| shapes[j].as_deref()).collect(); + let mid = self.knockout_mask(&ids, ®ion); + self.line(&format!("")); self.body.push_str(&body); + self.line(""); } } - /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). `region` is the attributes of - /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. - /// Returns the mask id. - fn knockout_mask(&mut self, c: &Node, region: &str) -> String { + /// For each element of a knockout group, the indices of the elements above it whose painted + /// boxes overlap its own (every one above it when a box can't be told). `None` when there are + /// more than [`MAX_MASKED_ELEMENTS`] elements or the masks would hold more than + /// [`MAX_KNOCKOUT_MASK_SHAPES`] shapes. + fn knockout_masks(&mut self, elements: &[&Node]) -> Option>> { + if elements.len() > MAX_MASKED_ELEMENTS { + return None; + } + let boxes = self.paint_boxes(elements); + let overlap = |a: Option>, b: Option>| match (a, b) { + (Some(None), _) | (_, Some(None)) => false, + (Some(Some(a)), Some(Some(b))) => a.x0 <= b.x1 + 2.0 && b.x0 <= a.x1 + 2.0 && a.y0 <= b.y1 + 2.0 && b.y0 <= a.y1 + 2.0, + _ => true, + }; + let mut shapes = 0usize; + let mut out = Vec::with_capacity(elements.len()); + for (i, &below) in boxes.iter().enumerate() { + let mut above = vec![]; + for (j, &over) in boxes.iter().enumerate().skip(i + 1) { + if overlap(below, over) { + shapes = shapes.checked_add(1).filter(|&s| s <= MAX_KNOCKOUT_MASK_SHAPES)?; + above.push(j); + } + } + out.push(above); + } + Some(out) + } + + /// One copy of the knockout shape of `c` in the definitions: `c` at full object opacity + /// without its own mask, unless those define its shape. Returns its id. + fn knockout_shape(&mut self, c: &Node) -> String { + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art = self.detached(2, |w| w.node(&shape)); + self.anonymous = anonymous; + let id = self.fresh_id("knockout-art"); + self.def(1, &format!("")); + self.defs.push_str(&art); + self.def(1, ""); + id + } + + /// A mask that is 1 − the knockout shapes `ids` ([`Self::knockout_shape`]): white, then the + /// shapes painted black. `region` is the attributes of the mask's region + /// ([`Self::region_attrs`]), which the filter that paints the shapes black shares. Returns + /// the mask id. + fn knockout_mask(&mut self, ids: &[&str], region: &str) -> String { let filter = match self.knockout_filters.get(region) { Some(f) => f.clone(), None => { @@ -632,21 +725,13 @@ impl Writer<'_> { f } }; - let shape = if c.knockout_shape { - c.clone() - } else { - let mut s = c.clone().with_mask(None); - s.opacity = 1.0; - s - }; - let anonymous = std::mem::replace(&mut self.anonymous, true); - let art = self.detached(3, |w| w.node(&shape)); - self.anonymous = anonymous; let mid = self.fresh_id("knockout"); self.def(1, &format!("")); self.def(2, &format!("")); self.def(2, &format!("")); - self.defs.push_str(&art); + for id in ids { + self.def(3, &format!("")); + } self.def(2, ""); self.def(1, ""); mid diff --git a/crates/svg/tests/it/blend_export.rs b/crates/svg/tests/it/blend_export.rs index 3804d30ed2..dd86f318f5 100644 --- a/crates/svg/tests/it/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -262,3 +262,93 @@ fn a_mask_region_covers_a_blend_as_written() { let [x, y, w, h] = ["x", "y", "width", "height"].map(|a| mask.attribute(a).and_then(|v| v.parse::().ok()).unwrap_or(f64::NAN)); assert!(x <= 0.0 && y <= 0.0 && x + w >= 95.0 && y + h >= 35.0 && w < 200.0 && h < 200.0, "{x} {y} {w} {h} in {svg}"); } + +/// A knockout group of `n` red, green and blue squares at 50% opacity that all overlap, over an +/// opaque backdrop. `knockout_shape`: their opacity defines their knockout shape. +fn knockout_squares(n: usize, knockout_shape: bool) -> Document { + let mut d = Document::new(200.0, 200.0); + let backdrop = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), [230, 200, 50]); + add(&mut d, backdrop); + let colors = [[255, 0, 0], [0, 200, 0], [0, 0, 255]]; + let squares = (0..n) + .map(|i| { + let x = (i % 20) as f64 * 2.0; + let mut s = rect(&mut d, Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), colors[i % 3]); + (s.opacity, s.knockout_shape) = (0.5, knockout_shape); + std::sync::Arc::new(s) + }) + .collect(); + let mut group = Node::group(d.alloc_id(), squares); + group.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, group); + d +} + +/// The deepest an element of `svg` nests, counting the root element as 1. +fn element_depth(svg: &str) -> usize { + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap(); + xml.descendants().filter(|n| n.is_element()).map(|n| n.ancestors().filter(|a| a.is_element()).count()).max().unwrap_or(0) +} + +/// The elements of a knockout group are siblings, each in one mask of the overlapping elements +/// above it, however many there are: 200 translucent elements stay well within the nesting the +/// web build reads (34 levels), and the file reads back with nothing merged. Where the elements' +/// shapes are the area they paint, the file draws as the canvas does. +#[test] +fn a_knockout_group_of_many_translucent_elements_stays_shallow() { + for knockout_shape in [false, true] { + let d = knockout_squares(200, knockout_shape); + let (svg, warnings) = vectorcraft_svg::export_with_report(&d, &ExportOptions::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(svg.matches("