diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index bd52fe71c7..f57e02fc2d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -304,11 +304,22 @@ fn options_not_applied_yet_and_document_features_warn() { let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap(); assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0); - // A knockout group is approximated: its warning comes back from every PDF path. + // A knockout group is written as a PDF knockout group: nothing to report. s.execute("select.set", &json!({"ids": [frame]})).unwrap(); s.execute("object.group", &json!({})).unwrap(); s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); let knockout = |w: Vec| w.iter().any(|w| w.contains("knockout groups")); + assert!(!knockout(warnings(&export(&mut s, json!({}))))); + // One whose soft masks would draw a brushed stroke is approximated: its warning comes back + // from every PDF path. + let rect = + |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40)); + s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap(); + s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap(); + s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); assert!(knockout(warnings(&export(&mut s, json!({}))))); assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap()))); // Other formats have none. @@ -432,6 +443,17 @@ fn pdf_settings_summarise_changes_and_warnings() { s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap(); assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([])); s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); + // A knockout group is written as a PDF knockout group: nothing to report. + assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([])); + // One whose soft masks would draw a brushed stroke is approximated. + let rect = + |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40)); + s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap(); + s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap(); + s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"knockout": "on"})).unwrap(); assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([])); let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap(); assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]); 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 4ba4aa123d..4ba361d29b 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_BRUSHES, KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES}; pub(crate) use writer::{Sheet, Writer}; pub(crate) struct Exporter<'a> { @@ -127,6 +127,14 @@ pub(crate) struct Exporter<'a> { /// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less /// while one of the copies on a sheet is drawn ([`Writer::page`]). pub(crate) symbol_limit: u64, + /// Objects the symbol instances in the copies drawn in knockout masks held, together: counted + /// apart from [`Self::symbol_nodes`], within [`vectorcraft_doc::MAX_SYMBOL_NODES`] of their own. + mask_symbol_nodes: u64, + /// Whether a knockout mask is being drawn: [`Self::symbol_nodes`] then counts + /// [`Self::mask_symbol_nodes`]. + in_knockout_mask: bool, + /// Whether a knockout mask left out symbol art because the objects its copies may hold ran out. + mask_art_left_out: bool, } #[cfg(test)] diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index 22109b3e82..f102e0a0ab 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -54,6 +54,9 @@ impl<'a> Exporter<'a> { paper: Rect::ZERO, symbol_nodes: 0, symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES, + mask_symbol_nodes: 0, + in_knockout_mask: false, + mask_art_left_out: false, overprint: false, overprinted: false, invisible: false, diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 097ae52bba..78930c7500 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf}; use super::{ - Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, - Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, + Affine, AppearanceItem, BezPath, BlendMode, Document, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, + NormalizedF32, Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke, }; /// An object's visual box for leaving out what is off the page. A plain group or layer also holds @@ -57,88 +59,146 @@ 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; } - 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(); - if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) { - let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect(); - if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) { - return self.warp_pieces(s, &elements, page); + if elements.is_empty() { + return; + } + if elements.iter().all(|(c, _)| c.knockout_outline().is_some()) { + let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect(); + if isolated { + s.push_isolated(); } - for (c, m) in &elements { - self.measured_node(s, c, page, false, Some(m)); + if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) { + self.warp_pieces(s, &elements, page); + } else { + for (c, m) in &elements { + self.measured_node(s, c, page, false, Some(m)); + } + } + 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 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); + 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 and no brushed stroke. + if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) { + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + match knockout_masks(BrushSearch::new(doc, brushes), &elements) { + Ok(above) => { + // Every mask is made before anything is drawn: when the symbol objects the + // masks may hold run out, one would leave out art its element covers, and + // the group is written as a PDF knockout group instead. + let outer = std::mem::take(&mut self.mask_art_left_out); + let before = *self.mask_counter(); + let mut masks = Vec::with_capacity(above.len()); + for above in above { + let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect(); + masks.push((!shapes.is_empty()).then(|| self.knockout_mask(s, &shapes, page))); + } + let left_out = std::mem::replace(&mut self.mask_art_left_out, outer); + if left_out { + *self.mask_counter() = before; + self.warn(KNOCKOUT_SHAPES); + } else { + if isolated { + s.push_isolated(); + } + for ((c, m), mask) in elements.iter().zip(masks) { + if let Some(mask) = mask { + s.push_mask(mask); + self.measured_node(s, c, page, false, Some(m)); + s.pop(); + } else { + self.measured_node(s, c, page, false, Some(m)); + } + } + if isolated { + s.pop(); + } + return; + } + } + Err(warning) => self.warn(warning), + } } - self.measured_node(s, first, page, false, Some(measured)); - for (c, m) in rest { - s.pop(); + 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(); } } /// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a /// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels /// are resampled for ([`Self::warp_image_size`]). - fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured]) { + fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect, measured: &[Measured], isolated: bool) { let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w)); - self.measured_children(s, children, page, measured); + self.measured_children(s, children, page, measured, isolated); if let Some(outer) = outer { self.warp_size = outer; } } - /// 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 - }; - self.shapes_mask(s, &[(&shape, Some(measured))], page) - } - /// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the /// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece /// is drawn through one soft mask of where the pieces above it that reach its box don't cover /// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a /// few triangles and no mask nests in another. A piece above that overlaps it only over opaque /// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask. - fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc, Measured)], page: Rect) { + fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&Node, Measured)], page: Rect) { // Each piece's triangle and its box: a piece paints inside its triangle only. let silhouette = |c: &Node| { let b = c.knockout_outline()?.geometric_bounds()?; Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b)) }; let silhouettes: Vec> = elements.iter().map(|(c, _)| silhouette(c)).collect(); - let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect(); + let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect(); let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces); for (i, (c, measured)) in elements.iter().enumerate() { let Some((_, reach)) = &silhouettes[i] else { continue }; @@ -191,6 +251,67 @@ impl Exporter<'_> { krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } + /// 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). The symbol instances in the copies count toward + /// [`Exporter::mask_symbol_nodes`], not toward the objects the visible art may draw. A + /// knockout mask drawn inside another one counts toward the same objects as the outer one. + fn knockout_mask(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask { + if self.in_knockout_mask { + return self.knockout_mask_art(s, shapes, page); + } + self.in_knockout_mask = true; + let visible = std::mem::replace(&mut self.symbol_nodes, self.mask_symbol_nodes); + let limit = std::mem::replace(&mut self.symbol_limit, vectorcraft_doc::MAX_SYMBOL_NODES); + let mask = self.knockout_mask_art(s, shapes, page); + self.mask_symbol_nodes = std::mem::replace(&mut self.symbol_nodes, visible); + self.symbol_limit = limit; + self.in_knockout_mask = false; + mask + } + + /// The symbol objects the copies drawn in knockout masks held, together: [`Self::symbol_nodes`] + /// while a knockout mask is being drawn, [`Exporter::mask_symbol_nodes`] otherwise. + fn mask_counter(&mut self) -> &mut u64 { + if self.in_knockout_mask { &mut self.symbol_nodes } else { &mut self.mask_symbol_nodes } + } + + /// [`Self::knockout_mask`], drawn within the symbol objects counted at the time. + fn knockout_mask_art(&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); + 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) + } + /// The box of `n`, and of the members of a plain group or layer. fn measure(&self, n: &Node) -> Measured { if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { @@ -242,23 +363,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), @@ -279,7 +384,7 @@ impl Exporter<'_> { } match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); + self.group_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 @@ -366,8 +471,10 @@ impl Exporter<'_> { (art, count) }) }); - if let Some(art) = placed.flatten() { - self.node(s, &art, page, true); + match placed.flatten() { + Some(art) => self.node(s, &art, page, true), + // A knockout mask that leaves the art out no longer matches what it masks. + None => self.mask_art_left_out |= self.in_knockout_mask, } } } @@ -741,3 +848,379 @@ 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; + +/// The warning for a knockout group in which a soft mask would draw an element with a brushed +/// stroke: it is written as a PDF knockout group, where an element knocks out all of the area it +/// paints. +pub(crate) const KNOCKOUT_BRUSHES: &str = "knockout groups with brushed strokes over other translucent elements are written as PDF knockout groups: \ + their translucent elements knock out what they cover fully"; + +/// For each element of a knockout group (with its box), the indices of the elements above it whose +/// boxes overlap its own. The warning to give instead ([`KNOCKOUT_SHAPES`]) when there are more than +/// [`MAX_MASKED_ELEMENTS`] elements or the masks would draw more than +/// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects, or ([`KNOCKOUT_BRUSHES`]) when a mask would draw a +/// brushed stroke ([`BrushSearch`]), whose brush art the object count leaves out. +fn knockout_masks(mut search: BrushSearch, elements: &[(&Node, Measured)]) -> Result>, &'static str> { + if elements.len() > MAX_MASKED_ELEMENTS { + return Err(KNOCKOUT_SHAPES); + } + let counts: Vec = elements.iter().map(|(c, _)| c.count_with_masks()).collect(); + let mut checked: Vec> = vec![None; elements.len()]; + 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_node, 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)) + { + // The mask draws a copy of the element without its own opacity mask, unless the + // mask defines its knockout shape. + if *checked[j].get_or_insert_with(|| search.node(over_node, over_node.knockout_shape, 0)) { + return Err(if search.exhausted { KNOCKOUT_SHAPES } else { KNOCKOUT_BRUSHES }); + } + objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS).ok_or(KNOCKOUT_SHAPES)?; + above.push(j); + } + } + out.push(above); + } + Ok(out) +} + +/// The art a [`BrushSearch`] looks through once: a symbol's or a pattern's, by name, or a +/// symbol's with one instance's appearance changes, by the symbol's name and the changes. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum SharedArt { + Symbol, + Pattern, + Dynamic, +} + +/// A search for the brushed strokes drawing an object draws, whose brush art can hold many more +/// objects than the stroked path. +struct BrushSearch<'a> { + doc: &'a Document, + /// The brushes the export draws strokes with ([`Exporter::brush_art`]); a stroke whose brush + /// is not among them is drawn as a plain stroke. + brushes: &'a [vectorcraft_brush::Brush], + /// The answer for each symbol's and pattern's art, and each symbol's art with an instance's + /// appearance changes, once found: false while it is being looked through, so that art that + /// uses itself ends the search. + seen: HashMap<(SharedArt, String), bool>, + /// The objects the search may still visit or copy: [`MAX_KNOCKOUT_MASK_OBJECTS`] at the start. + left: usize, + /// Whether the search stopped because [`Self::left`] ran out; it then answers true. + exhausted: bool, +} + +impl<'a> BrushSearch<'a> { + fn new(doc: &'a Document, brushes: &'a [vectorcraft_brush::Brush]) -> Self { + Self { doc, brushes, seen: HashMap::new(), left: MAX_KNOCKOUT_MASK_OBJECTS, exhausted: false } + } + + /// Takes `n` objects from [`Self::left`]; false, and the search exhausted, when fewer are left. + fn spend(&mut self, n: usize) -> bool { + match self.left.checked_sub(n) { + Some(left) if !self.exhausted => { + self.left = left; + true + } + _ => { + (self.left, self.exhausted) = (0, true); + false + } + } + } + + /// Takes the objects of `n` (its members and opacity-mask art included) from [`Self::left`], + /// counting no further than what is left; false when they don't fit. + fn spend_tree(&mut self, n: &Node) -> bool { + self.spend(1) + && n.extras().mask.as_deref().is_none_or(|m| self.spend_tree(&m.art)) + && n.children().into_iter().flatten().all(|c| self.spend_tree(c)) + } + + /// Whether drawing `n` draws a brushed stroke: on `n` or its visible members (a compound + /// path's members are drawn with its own paint), in the art of the opacity masks that are on + /// (`n`'s own when `own_mask`), of symbol instances and inline graphics, of live objects as + /// evaluated, and of the patterns that fills, strokes and type paint with. `depth` counts the + /// objects and the mask, symbol and pattern art above `n`: past + /// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] the search stops and counts as a brush, and so + /// it does when the objects it may visit or copy ([`Self::left`]) run out. + fn node(&mut self, n: &Node, own_mask: bool, depth: usize) -> bool { + if depth > vectorcraft_doc::depth::MAX_DRAWN_DEPTH || !self.spend(1) { + return true; + } + let depth = depth + 1; + // A fill is drawn when it is visible, and a stroke when it also paints and has a width + // (as `Exporter::shape` draws them). + for item in n.appearance.items.iter().filter(|i| i.visible()) { + let paint = match item { + AppearanceItem::Fill(f) => &f.paint, + AppearanceItem::Stroke(st) if st.paint.is_none() || st.width <= 0.0 => continue, + AppearanceItem::Stroke(st) => { + if st.brush.as_deref().is_some_and(|name| self.brushes.iter().any(|b| b.name == name)) { + return true; + } + &st.paint + } + }; + if self.paint(paint, depth) { + return true; + } + } + if own_mask && n.extras().mask.as_deref().is_some_and(|m| !m.disabled && self.node(&m.art, true, depth)) { + return true; + } + match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => { + // A dynamic symbol's instance draws its art with its own appearance changes: the + // art is copied once for each set of changes, counted before it is copied. + let doc = self.doc; + match doc.symbols.iter().find(|s| &s.name == symbol) { + Some(s) if !n.extras().symbol_overrides.is_empty() => { + let key = format!("{symbol}\u{0}{:?}", n.extras().symbol_overrides); + self.shared(SharedArt::Dynamic, &key, |search| { + !search.spend_tree(&s.art) || search.node(&vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n), true, depth) + }) + } + _ => self.symbol(symbol, depth), + } + } + NodeKind::Text(t) => t.runs.iter().any(|r| { + let stroke = r.style.stroke_width > 0.0 && self.paint(&r.style.stroke, depth); + stroke || self.paint(&r.style.fill, depth) || r.inline.as_ref().is_some_and(|ig| self.symbol(&ig.symbol, depth)) + }), + NodeKind::Compound { .. } => false, + // Live objects are drawn as their evaluated art, as `Exporter::measured_node` draws them. + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + // Each live object is reached once in the art searched (shared art is searched + // once); its evaluated art is counted before it is searched. + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + !self.spend_tree(&g) || g.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth)) + } + _ => n.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth)), + } + } + + /// Whether the art of the symbol named `name` draws a brushed stroke. + fn symbol(&mut self, name: &str, depth: usize) -> bool { + let doc = self.doc; + let Some(s) = doc.symbols.iter().find(|s| s.name == name) else { return false }; + self.shared(SharedArt::Symbol, name, |search| search.node(&s.art, true, depth)) + } + + /// Whether `paint` is a pattern whose tile art draws a brushed stroke. + fn paint(&mut self, paint: &Paint, depth: usize) -> bool { + let Paint::Pattern { pattern, .. } = paint else { return false }; + let doc = self.doc; + let Some(def) = doc.pattern(pattern) else { return false }; + self.shared(SharedArt::Pattern, pattern, |search| def.art.iter().filter(|c| c.visible).any(|c| search.node(c, true, depth))) + } + + /// The answer of `search` for the shared art `kind` named `name`, found once. + fn shared(&mut self, kind: SharedArt, name: &str, search: impl FnOnce(&mut Self) -> bool) -> bool { + let key = (kind, name.to_string()); + if let Some(&b) = self.seen.get(&key) { + return b; + } + self.seen.insert(key.clone(), false); + let b = search(self); + self.seen.insert(key, b); + b + } +} + +/// 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, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vectorcraft_doc::{Appearance, NodeId, Symbol, SymbolOverride}; + use vectorcraft_geom::shapes; + + use super::*; + + /// An instance of a dynamic symbol whose art holds an instance of the same symbol with the same + /// appearance changes ends the search for brushed strokes where the art comes round again, + /// after a few objects, instead of recursing until the stack runs out. + #[test] + fn a_dynamic_symbol_holding_itself_ends_the_brush_search() { + let mut d = Document::new(200.0, 200.0); + let instance = || { + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((40.0, 0.0)) }); + n.extras_mut().symbol_overrides = vec![SymbolOverride { path: vec![0], opacity: Some(0.5), ..Default::default() }]; + n + }; + let square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + let art = Node::group(NodeId(0), vec![Arc::new(square), Arc::new(instance())]); + d.symbols.push(Symbol { dynamic: true, name: "Loop".into(), art: Arc::new(art) }); + let mut search = BrushSearch::new(&d, &[]); + assert!(!search.node(&instance(), true, 0)); + assert!(!search.exhausted); + assert!(MAX_KNOCKOUT_MASK_OBJECTS - search.left < 10, "{} objects", MAX_KNOCKOUT_MASK_OBJECTS - search.left); + } + + /// A group of 1,000 instances of a dynamic symbol whose art holds 100 squares, each instance + /// with appearance changes of its own: copying and looking through every instance's art would + /// take about 203,000 objects, and the search stops when [`MAX_KNOCKOUT_MASK_OBJECTS`] are + /// used, counting as a brush. With the same changes on every instance, the art is copied + /// once. + #[test] + fn the_brush_search_stops_within_its_object_limit() { + let mut d = Document::new(200.0, 200.0); + let square = + Arc::new(Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::None, Paint::None, 0.0))); + d.symbols.push(Symbol { dynamic: true, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), vec![square; 100])) }); + let group = |distinct: bool| { + let instances = (0..1000u32) + .map(|i| { + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Dots".into(), xf: Affine::IDENTITY }); + let path = if distinct { vec![0, i] } else { vec![0] }; + n.extras_mut().symbol_overrides = vec![SymbolOverride { path, opacity: Some(0.5), ..Default::default() }]; + Arc::new(n) + }) + .collect(); + Node::group(NodeId(0), instances) + }; + let mut search = BrushSearch::new(&d, &[]); + assert!(search.node(&group(true), true, 0)); + assert!(search.exhausted); + assert_eq!(search.left, 0); + let mut search = BrushSearch::new(&d, &[]); + assert!(!search.node(&group(false), true, 0)); + assert!(!search.exhausted); + // The group, the 1,000 instances, and one copy of the art counted and looked through. + assert_eq!(MAX_KNOCKOUT_MASK_OBJECTS - search.left, 1 + 1000 + 101 + 101); + } + + /// A placed file read as one square with a 3 pt brushed stroke. + fn brushed_art() -> (Document, Node, Rect) { + let mut d = Document::new(40.0, 20.0); + d.layers.clear(); + let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), 3.0); + st.brush = Some("3 pt. Round".into()); + let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + square.appearance.items.push(AppearanceItem::Stroke(st)); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + (d, art, Rect::new(0.0, 0.0, 40.0, 20.0)) + } + + /// A placed document is drawn as the art read from its file, and the brushed strokes in that + /// art count. + #[test] + fn brushed_strokes_in_a_placed_documents_art_count() { + let d = Document::new(200.0, 200.0); + let placed = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/art/placed.vectorcraft"), + key: "placed-1".into(), + bounding: false, + page_box: vectorcraft_doc::placed_document::PageBox::Crop, + width: 40.0, + height: 20.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + // The art the export reads for it before drawing (`Document::with_placed_art`). + vectorcraft_doc::placed_document::prime(&placed, Arc::new(vec![]), brushed_art()); + let n = Node::new(NodeId(0), NodeKind::PlacedDocument(Box::new(placed))); + let brushes = vectorcraft_brush::library(&d); + assert!(BrushSearch::new(&d, &brushes).node(&n, true, 0)); + } +} diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 4a979e8742..0b32054c7b 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -387,6 +387,21 @@ impl<'p> Builder<'p> { only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m)))); } Some(only) + } else if let [only] = children.as_slice() + && matches!(only.kind, NodeKind::Group { clip: false, .. }) + && only.knockout == Knockout::On + && only.opacity >= 0.999 + && only.blend == BlendMode::Normal + && !only.isolate + && only.extras().mask.is_none() + && mask.is_none() + && !knockout + { + // A knockout group drawn in a form that carries its opacity, blend mode or + // isolation: one knockout group with them. + let mut only = (**only).clone(); + (only.opacity, only.blend, only.isolate) = (opacity, blend, isolate); + Some(only) } else { let mut g = Node::group(self.id(), children); (g.opacity, g.blend, g.isolate) = (opacity, blend, isolate); 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..c55a0b7504 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -97,3 +97,466 @@ 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]); + } +} + +/// A translucent knockout group is written as a form with its opacity around the `/K` group, and +/// reads back as one knockout group with that opacity and its isolation. +#[test] +fn a_translucent_knockout_group_reads_back_as_one_group() { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + d.node_mut(g).unwrap().opacity = 0.5; + let top = d.node(g).unwrap().children().unwrap()[2].id; + d.node_mut(top).unwrap().blend = BlendMode::Multiply; + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = vec![]; + back.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. }) { + found.push(( + n.knockout == vectorcraft_doc::Knockout::On, + n.isolate, + (n.opacity * 100.0).round(), + n.children().map_or(0, |c| c.len()), + )); + } + }); + assert_eq!(found, [(true, isolate, 50.0, 3)], "isolate {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}"); +} + +/// The copies of symbol instances drawn in knockout masks don't count toward the objects the +/// visible symbol art of an export may hold (`MAX_SYMBOL_NODES`): of two overlapping 50% instances +/// whose art placed through 9-slice guides holds about 6,510,000 objects each (a gradient square +/// in all nine cells, each copy with a disabled opacity mask of 723,351 objects), both are written +/// in their nine cells, although the lower one's mask draws a copy of the upper one first. +#[test] +fn knockout_mask_copies_of_symbol_instances_leave_the_visible_art_its_object_limit() { + let d = panel_instances(850, &[20.0, 40.0]); + // Nine squares of each instance (the mask's copy of the upper one is opacity-mask art). + assert_eq!(painted_paths(&export(&d, &PdfOptions::default()).unwrap()), 18); +} + +/// A knockout group of overlapping 50% instances, at `xs`, of a symbol whose art placed through +/// 9-slice guides holds 9 × (`mask_objects`² + `mask_objects` + 3) objects (a gradient square in +/// all nine cells, each copy with a disabled opacity mask of `mask_objects`² + `mask_objects` + 1 +/// objects). +fn panel_instances(mask_objects: usize, xs: &[f64]) -> Document { + use vectorcraft_color::{Gradient, GradientPaint}; + use vectorcraft_geom::Affine; + let mut d = Document::new(300.0, 200.0); + let leaf = std::sync::Arc::new(rect(Rect::new(0.0, 0.0, 1.0, 1.0), (0.0, 0.0, 0.0))); + let inner = std::sync::Arc::new(Node::group(NodeId(0), vec![leaf; mask_objects])); + let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(0), vec![inner; mask_objects]), true); + (mask.linked, mask.disabled) = (false, true); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + square.extras_mut().mask = Some(Box::new(mask)); + let art = Node::group(NodeId(0), vec![std::sync::Arc::new(square)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: std::sync::Arc::new(art) }); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let instances = xs + .iter() + .map(|&x| { + let xf = Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5); + let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Panel".into(), xf }); + (n.id, n.opacity, n.knockout_shape) = (d.alloc_id(), 0.5, true); + std::sync::Arc::new(n) + }) + .collect(); + let mut g = Node::group(NodeId(0), instances); + g.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, g); + d +} + +/// The paths of `pdf` read back, the art of opacity masks left out. +fn painted_paths(pdf: &[u8]) -> usize { + let mut paths = 0; + import(pdf).unwrap().walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + paths +} + +/// When the copies the knockout masks draw would hold more symbol objects than they may +/// (`MAX_SYMBOL_NODES` of their own), the group is written as a PDF knockout group, with a +/// warning, instead of masks that leave out art their elements cover: four overlapping instances +/// of about 3,870,000 objects each fit the visible art's limit, and the six copies in their masks +/// do not. +#[test] +fn knockout_masks_whose_symbol_copies_run_out_give_way_to_a_pdf_knockout_group() { + let d = panel_instances(655, &[20.0, 40.0, 60.0, 80.0]); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{:?}", r.warnings); + assert_eq!(painted_paths(&r.bytes), 36); +} + +/// A brushed stroke draws brush art that the objects counted for the knockout masks leave out: +/// a group in which a mask would draw an element with a brushed stroke is written as a PDF +/// knockout group, with a warning. A brushed element that no mask draws (the lowest one) leaves +/// the masks as they are. +#[test] +fn a_knockout_group_whose_masks_would_draw_a_brushed_stroke_is_a_pdf_knockout_group() { + for (brushed, expected) in [(1, true), (0, false)] { + let mut d = Document::new(200.0, 200.0); + let squares = (0..2) + .map(|i| { + let x = f64::from(i) * 40.0; + let mut s = rect(Rect::new(20.0 + x, 50.0, 120.0 + x, 150.0), (0.0, 0.0, 1.0)); + (s.id, s.opacity, s.knockout_shape) = (d.alloc_id(), 0.5, true); + if i == brushed { + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.brush = Some("3 pt. Round".into()); + s.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st)); + } + 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 r = export_with_report(&d, &PdfOptions::default()).unwrap(); + assert_eq!(r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES), expected, "{brushed}: {:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_SHAPES), "{brushed}: {:?}", r.warnings); + assert_eq!(text(&d).matches("/Ktrue").count(), usize::from(expected), "{brushed}"); + } +} + +/// The knockout group of `upper` at 50%, whose opacity defines its knockout shape when +/// `knockout_shape`, between two 50% squares whose opacity defines theirs (so that the group is +/// drawn through masks): whether the export gives the brushed-stroke warning. +fn warns_of_brushes(mut d: Document, mut upper: Node, knockout_shape: bool) -> bool { + (upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, knockout_shape); + let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0)); + (lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true); + let mut top = rect(Rect::new(100.0, 50.0, 180.0, 150.0), (0.0, 0.8, 0.0)); + (top.id, top.opacity, top.knockout_shape) = (d.alloc_id(), 0.5, true); + let mut g = Node::group(NodeId(0), [lower, upper, top].map(std::sync::Arc::new).to_vec()); + g.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, g); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES) +} + +/// A square at (60, 50) with a 3 pt brushed stroke. +fn brushed_square() -> Node { + let mut line = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0)); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.brush = Some("3 pt. Round".into()); + line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st)); + line +} + +/// Only brushed strokes a mask draws count: a hidden member's is not drawn, nor is one without +/// paint or width, nor the one in the opacity mask of an element whose mask does not define its +/// knockout shape (its copy in a mask leaves the opacity mask out). +#[test] +fn brushed_strokes_that_a_mask_does_not_draw_leave_the_masks_as_they_are() { + let d = Document::new(200.0, 200.0); + let mut hidden = brushed_square(); + hidden.visible = false; + let plain = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0)); + let group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone()), std::sync::Arc::new(hidden)]); + assert!(!warns_of_brushes(d.clone(), group, true)); + // A brushed stroke without paint or width draws nothing. + for (paint, width) in [(Paint::None, 3.0), (Paint::solid(Color::BLACK), 0.0)] { + let mut idle = brushed_square(); + if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = idle.appearance.items.last_mut() { + (st.paint, st.width) = (paint, width); + } + assert!(!warns_of_brushes(d.clone(), idle, true), "width {width}"); + } + assert!(warns_of_brushes(d.clone(), brushed_square(), true)); + // A group's own brushed stroke is drawn too: the export bakes it into a path among the + // group's members. + let mut stroked_group = Node::group(NodeId(0), vec![std::sync::Arc::new(plain.clone())]); + stroked_group.appearance = brushed_square().appearance; + assert!(warns_of_brushes(d.clone(), stroked_group, true)); + for (knockout_shape, expected) in [(false, false), (true, true)] { + let mut masked = plain.clone(); + let mut mask = vectorcraft_doc::OpacityMask::new(brushed_square(), true); + mask.linked = false; + masked.extras_mut().mask = Some(Box::new(mask)); + assert_eq!(warns_of_brushes(d.clone(), masked, knockout_shape), expected, "knockout shape {knockout_shape}"); + } +} + +/// A compound path's members are drawn with its own paint, so a brushed stroke on a member draws +/// nothing; one on the compound path does. +#[test] +fn a_brushed_stroke_counts_on_a_compound_path_and_not_on_its_members() { + let d = Document::new(200.0, 200.0); + let compound = |members: Vec| { + let children = members.into_iter().map(std::sync::Arc::new).collect(); + let mut c = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Compound { children, rule: Default::default() }); + c.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into(); + c + }; + assert!(!warns_of_brushes(d.clone(), compound(vec![brushed_square()]), true)); + let mut stroked = compound(vec![rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0))]); + stroked.appearance = brushed_square().appearance; + assert!(warns_of_brushes(d, stroked, true)); +} + +/// A pattern's tiles draw the brushed strokes in its art: an element filled with such a pattern +/// counts, and one filled with a pattern without them does not. +#[test] +fn a_brushed_stroke_in_a_pattern_counts() { + for (brushed_tile, expected) in [(true, true), (false, false)] { + let mut d = Document::new(200.0, 200.0); + let tile = if brushed_tile { brushed_square() } else { rect(Rect::new(60.0, 50.0, 160.0, 150.0), (1.0, 1.0, 1.0)) }; + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(tile)])); + let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let filled = Node::path(NodeId(0), shapes::rectangle(Rect::new(60.0, 50.0, 160.0, 150.0)), Appearance::basic(pattern, Paint::None, 0.0)); + assert_eq!(warns_of_brushes(d, filled, true), expected, "brushed tile {brushed_tile}"); + } +} + +/// A stroke whose brush the export does not know is drawn as a plain stroke, and a stroke without +/// width draws nothing, not even its pattern's brushed strokes. +#[test] +fn strokes_that_draw_no_brush_art_leave_the_masks_as_they_are() { + let mut unknown = brushed_square(); + if let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = unknown.appearance.items.last_mut() { + st.brush = Some("No Such Brush".into()); + } + assert!(!warns_of_brushes(Document::new(200.0, 200.0), unknown, true)); + let mut d = Document::new(200.0, 200.0); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())])); + let pattern = Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + let mut thin = rect(Rect::new(60.0, 50.0, 160.0, 150.0), (0.0, 0.0, 1.0)); + thin.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(pattern, 0.0))); + assert!(!warns_of_brushes(d, thin, true)); +} + +/// Type draws the brushed strokes in the patterns its characters are filled with and in the art +/// of its inline graphics. +#[test] +fn brushed_strokes_in_type_patterns_and_inline_graphics_count() { + let text = |d: &mut Document, fill: Paint, inline: bool| { + let style = vectorcraft_doc::CharStyle { size: 60.0, fill, ..Default::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style.clone()); + if inline { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Brushed".into(), art: std::sync::Arc::new(brushed_square()) }); + let mut run = vectorcraft_doc::TextRun::new("\u{FFFC}", style); + run.inline = Some(vectorcraft_doc::InlineArt::new("Brushed")); + t.runs.push(run); + } + Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(t))) + }; + let black = Paint::solid(Color::BLACK); + let mut d = Document::new(200.0, 200.0); + let plain = text(&mut d, black.clone(), false); + assert!(!warns_of_brushes(d.clone(), plain, true)); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Tile", vec![std::sync::Arc::new(brushed_square())])); + let patterned = text(&mut d, Paint::Pattern { pattern: "Tile".into(), xf: vectorcraft_geom::Affine::IDENTITY }, false); + assert!(warns_of_brushes(d, patterned, true)); + let mut d = Document::new(200.0, 200.0); + let inline = text(&mut d, black, true); + assert!(warns_of_brushes(d, inline, true)); +} + +/// Type draws the brushed strokes of its appearance as brush art too. +#[test] +fn a_brushed_stroke_on_type_counts() { + let style = vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(60.0, 120.0), "Type", style); + let mut n = Node::new(NodeId(0), vectorcraft_doc::NodeKind::Text(Box::new(text))); + n.appearance = brushed_square().appearance; + assert!(warns_of_brushes(Document::new(200.0, 200.0), n, true)); +} + +/// The brushed strokes a mask would draw are found in the opacity-mask art of a symbol's art too, +/// and a disabled opacity mask, which is not drawn, holds none. +#[test] +fn brushed_strokes_in_mask_art_count_only_where_the_mask_is_on() { + for (in_symbol, disabled, expected) in [(true, false, true), (false, true, false), (false, false, true)] { + let mut d = Document::new(200.0, 200.0); + let mut line = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (1.0, 1.0, 1.0)); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.brush = Some("3 pt. Round".into()); + line.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(st)); + let mut mask = vectorcraft_doc::OpacityMask::new(line, true); + (mask.linked, mask.disabled) = (false, disabled); + let mut masked = rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.0, 0.0, 1.0)); + masked.extras_mut().mask = Some(Box::new(mask)); + let mut upper = if in_symbol { + let art = Node::group(NodeId(0), vec![std::sync::Arc::new(masked)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Masked".into(), art: std::sync::Arc::new(art) }); + let xf = vectorcraft_geom::Affine::translate((60.0, 50.0)); + Node::new(NodeId(0), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Masked".into(), xf }) + } else { + masked.transform(vectorcraft_geom::Affine::translate((60.0, 50.0)), false); + masked + }; + (upper.id, upper.opacity, upper.knockout_shape) = (d.alloc_id(), 0.5, true); + let mut lower = rect(Rect::new(20.0, 50.0, 120.0, 150.0), (1.0, 0.0, 0.0)); + (lower.id, lower.opacity, lower.knockout_shape) = (d.alloc_id(), 0.5, true); + let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(lower), std::sync::Arc::new(upper)]); + g.knockout = vectorcraft_doc::Knockout::On; + add(&mut d, g); + let r = export_with_report(&d, &PdfOptions::default()).unwrap(); + let warned = r.warnings.iter().any(|w| w == crate::export::KNOCKOUT_BRUSHES); + assert_eq!(warned, expected, "symbol {in_symbol}, disabled {disabled}: {:?}", r.warnings); + } +} + +/// 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 a4cdf26271..8716af7367 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -163,6 +163,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. @@ -450,20 +462,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(); @@ -572,8 +603,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); @@ -603,36 +636,124 @@ 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(""); } } + /// 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 => { + let f = self.fresh_id("knockout-shape"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + self.knockout_filters.insert(region.to_string(), f.clone()); + f + } + }; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + for id in ids { + self.def(3, &format!("")); + } + self.def(2, ""); + self.def(1, ""); + mid + } + /// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a /// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn /// through one mask of where the pieces above it that reach it don't cover @@ -683,42 +804,6 @@ impl Writer<'_> { } } - /// 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 { - let filter = match self.knockout_filters.get(region) { - Some(f) => f.clone(), - None => { - let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); - self.def(2, ""); - self.def(1, ""); - self.knockout_filters.insert(region.to_string(), f.clone()); - 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); - self.def(2, ""); - self.def(1, ""); - mid - } - /// An object, inside `` when it links to a URL (Attributes panel). pub(super) fn node(&mut self, n: &Node) { // Ruler guides are not written, not even as a link. 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("