From 88b587baa69d3d03cdc286d2f6ecbf2a490a8922 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:21:39 -0800 Subject: [PATCH 01/31] PDF/SVG export: knockout groups nest no deeper with more elements A knockout group was written as a chain of soft masks, one inside the next for each element, so a group of about 200 elements made a PDF that VectorCraft's own PDF import refused (form nesting past 50) and an SVG that the web build refused as too deep. - PDF writes a knockout group as one transparency group with /K true, with /I true when the group isolates, and PDF import reads it back as a knockout group. An element with several paints is drawn as one group inside it, so its own fills and strokes do not knock each other out. - Where an element knocks out less than the area it paints (its opacity or mask defines its knockout shape, or it paints translucently), each element is drawn through one soft mask of the overlapping elements above it. Past 65,536 objects in a group's masks, or 2,048 elements, the group is a PDF knockout group, with a warning. - SVG draws each element through one mask of the overlapping elements above it, built from one shared copy of each element's shape, so the elements stay siblings. Past 65,536 shapes in a group's masks, or 2,048 elements, the group is written as a plain group, with a warning. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/tests/knockout.rs | 13 +- crates/pdf/src/export.rs | 2 +- crates/pdf/src/export/nodes.rs | 269 +++++++++++++++++++++------- crates/pdf/src/surface.rs | 5 + crates/pdf/src/tests/blend.rs | 162 +++++++++++++++++ crates/pdf/tests/gradient_forms.rs | 4 +- crates/svg/src/export/nodes.rs | 173 +++++++++++++----- crates/svg/tests/it/blend_export.rs | 90 ++++++++++ docs/mcp.md | 8 +- docs/target-app-parity.md | 1 + 10 files changed, 611 insertions(+), 116 deletions(-) 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("