From cc8cdd62fd27f72e1b8a2b4534f28948f58be5c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:20:19 -0800 Subject: [PATCH] PDF: knockout masks keep symbol and brush budgets for visible art The copies of symbol instances drawn in a knockout group's soft masks counted toward the objects the export's visible symbol art may hold, so overlapping instances could use that limit up and leave later visible instances out. Mask copies now count toward a limit of their own. When a group's masks would leave out symbol art because that limit runs out, the masks are discarded and the group is written as a PDF knockout group, with a warning. The object count that decides whether a group is drawn through masks left out the art a brushed stroke expands into. A group in which a mask would draw a brushed stroke is now written as a PDF knockout group, with its own warning. The search follows what the export draws: visible members, opacity masks that are on, symbol and inline graphic art, live objects and placed documents as evaluated, and pattern tiles, and it skips strokes without paint, width or a known brush. A knockout group whose elements need no masks is written as a PDF knockout group without a warning, so the two engine PDF tests that expected the old warning now check that case and the brushed one. --- crates/engine/src/cmd/fileio/tests_pdf.rs | 24 +- crates/pdf/src/export.rs | 10 +- crates/pdf/src/export/exporter.rs | 3 + crates/pdf/src/export/nodes.rs | 292 +++++++++++++++++++--- crates/pdf/src/tests/blend.rs | 275 ++++++++++++++++++++ 5 files changed, 572 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 91fe9701e7..ebc8373c8b 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -240,11 +240,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. @@ -368,6 +379,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/pdf/src/export.rs b/crates/pdf/src/export.rs index 8cbec01de9..852dbafb3e 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::{KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, 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> { @@ -124,6 +124,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 ff94c6759f..82f1adf3f2 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -53,6 +53,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 1dd9905340..3f118ec7c8 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 @@ -106,29 +108,47 @@ impl Exporter<'_> { // 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. + // [`MAX_KNOCKOUT_MASK_OBJECTS`] objects and no brushed stroke. 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 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(); - let mask = self.knockout_mask(s, &shapes, page); - s.push_mask(mask); - self.measured_node(s, c, page, false, Some(m)); - s.pop(); + 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; } } - if isolated { - s.pop(); - } - return; + Err(warning) => self.warn(warning), } - self.warn(KNOCKOUT_SHAPES); } let group = several || isolated; if group { @@ -222,8 +242,31 @@ impl Exporter<'_> { /// 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). + /// 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()); @@ -416,8 +459,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, } } } @@ -835,29 +880,155 @@ pub(crate) const MAX_KNOCKOUT_MASK_OBJECTS: usize = 1 << 16; /// 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. `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>> { +/// 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 None; + 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)) in elements.iter().enumerate().skip(i + 1) { + 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)) { - objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS)?; + // 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(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); } - Some(out) + Ok(out) +} + +/// The art a [`BrushSearch`] looks through once: a symbol's or a pattern's, by name. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum SharedArt { + Symbol, + Pattern, +} + +/// 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 once found: false while it is being looked + /// through, so that art that uses itself ends the search. + seen: HashMap<(SharedArt, String), bool>, +} + +impl<'a> BrushSearch<'a> { + fn new(doc: &'a Document, brushes: &'a [vectorcraft_brush::Brush]) -> Self { + Self { doc, brushes, seen: HashMap::new() } + } + + /// 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. + fn node(&mut self, n: &Node, own_mask: bool, depth: usize) -> bool { + if depth > vectorcraft_doc::depth::MAX_DRAWN_DEPTH { + 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. + match self.doc.symbols.iter().find(|s| &s.name == symbol) { + Some(s) if !n.extras().symbol_overrides.is_empty() => { + self.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 { .. } => { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + 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 @@ -907,3 +1078,64 @@ fn paints_opaque(n: &Node) -> bool { _ => 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 its own + /// appearance changes ends the search for brushed strokes at the drawn depth limit, where it + /// counts as a brush, 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) }); + assert!(BrushSearch::new(&d, &[]).node(&instance(), true, 0)); + } + + /// 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/tests/blend.rs b/crates/pdf/src/tests/blend.rs index aff54fa2ac..94f800f303 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -251,6 +251,281 @@ fn knockout_masks_hold_only_the_elements_that_overlap() { 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() {