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() {