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, 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 /// the boxes of the members it is measured through ([`Node::editing_children`], in order), so that /// the walk measures them once. struct Measured { bounds: Option, members: Vec, } #[cfg(test)] std::thread_local! { /// The objects this thread has measured for the walk and for the links (tests). pub(crate) static MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option { let mut sb = s.stream_builder(); let mut ms = sb.surface(); // An opaque backdrop when it matters: white outside the art (no clip), or black for the // inverting Difference pass below to turn white. if !m.clip || m.invert { cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0); } // Mask art is a picture of its own: it takes no part in a knockout group around the object, // and has no inks to overprint. let knockout = std::mem::take(&mut self.knockout); let overprint = std::mem::take(&mut self.overprint); let image_left_out = self.image_left_out.replace(false); self.node(&mut ms, &m.art, page, true); let incomplete = self.image_left_out.unwrap_or(false); self.image_left_out = image_left_out.map(|outer| outer || incomplete); (self.knockout, self.overprint) = (knockout, overprint); if incomplete { return None; } if m.invert { ms.push_blend_mode(krilla::blend::BlendMode::Difference); cover(&mut ms, page, 255, 1.0); ms.pop(); } ms.finish(); Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)) } /// 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::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]) { if !self.knockout { 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))); } return; } // The PDF writer has no knockout groups: each element is drawn through a soft mask of // where the elements above it don't paint (the same look for Normal blending). The masks // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); let elements: Vec<_> = Node::knockout_elements(children) .into_iter() .map(|c| (c, self.measure(c))) .filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) .collect(); let Some(((first, measured), rest)) = elements.split_first() else { return }; for (c, m) in rest.iter().rev() { let mask = self.knockout_mask(s, c, page, m); s.push_mask(mask); } self.measured_node(s, first, page, false, Some(measured)); for (c, m) in rest { s.pop(); self.measured_node(s, c, page, false, Some(m)); } } /// 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 }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); cover(&mut ms, page, 255, 1.0); let alpha = { let mut ab = ms.stream_builder(); let mut als = ab.surface(); let overprint = std::mem::take(&mut self.overprint); self.measured_node(&mut als, &shape, page, false, Some(measured)); self.overprint = overprint; 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.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, .. }) { #[cfg(test)] MEASURES.with(|m| m.set(m.get() + 1)); return Measured { bounds: n.visual_bounds_with(&self.symbols), members: vec![] }; } let members: Vec<_> = n.editing_children().iter().map(|c| self.measure(c)).collect(); Measured { bounds: n.visual_of_members(members.iter().map(|m| m.bounds)), members } } pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { self.measured_node(s, n, page, force, None); } /// [`Self::node`] with the box of `n` in `measured`, when its parent measured it. fn measured_node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool, measured: Option<&Measured>) { if self.layers && self.in_layer != address(n) && let NodeKind::Layer { printable, .. } = n.kind && (printable || self.non_printing) && let Some(&i) = self.layer_index.get(&address(n)) { // A PDF layer: hidden layers and sublayers are written too (their group is off). let enclosing = std::mem::replace(&mut self.in_layer, address(n)); let paper = self.paper; crate::forms::layer_form(s, i, paper, |s| self.node(s, n, page, true)); self.in_layer = enclosing; return; } if !force && !n.visible { return; } if let NodeKind::Layer { template, printable, .. } = n.kind && (template || !(printable || self.non_printing)) { return; } // A container written whatever its box is has no use for one. let own = (measured.is_none() && !(force && n.is_container())).then(|| self.measure(n)); let measured = measured.or(own.as_ref()); match measured.map(|m| m.bounds) { Some(Some(b)) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return, Some(None) if !n.is_container() => return, _ => {} } self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase())); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); // 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 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), _ => None, }; if let Some(m) = mask && alpha.is_none() { let Some(mask) = self.soft_mask(s, m, page) else { self.knockout = enclosing; for _ in 0..pushes { s.pop(); } return; }; s.push_mask(mask); pushes += 1; } match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); } 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 // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. if let Some((clip, rest)) = children.split_first() && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r))) { let paint = clip.clip_paint(); s.push_clip_path(&p, &rule(r)); if let Some(fill) = &paint.fill { self.node(s, fill, page, false); } self.children(s, rest, page); s.pop(); if let Some(stroke) = &paint.stroke { self.node(s, stroke, page, false); } } } NodeKind::Path { path, rule, guide, .. } => { if !*guide { self.shape(s, n, &path.to_bezpath(), *rule, page); } } NodeKind::Compound { children, rule } => { let mut bp = BezPath::new(); for c in children { if let Some(p) = c.path_data() { bp.extend(p.to_bezpath()); } } self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), NodeKind::Image(im) => match n.extras().colorize.as_deref() { Some(p @ Paint::Solid { .. }) if self.doc.image_blob(im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { if writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); } else { self.image_left_out = self.image_left_out.map(|_| true); self.warn(UNDECODED); } } else if self.image_left_out.is_some() { self.image_left_out = Some(true); self.warn(super::exporter::UNWRITABLE_IMAGE); } } _ => self.image(s, im, alpha, n.extras().pixelated), }, NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested // instances multiply. // Placing through 9-slice guides needs room for the most objects it can make // (`NineSliceMap::most_placed`), and the objects it makes count. // A dynamic symbol's instance shows its own appearance changes. let doc = self.doc; if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) { let changed; let art: &Node = if n.extras().symbol_overrides.is_empty() { &sym.art } else { changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); &changed }; let count = art.count() as u64; let map = doc.symbol_slice_map(symbol, *xf); let most = map.map_or(count, |m| m.most_placed(art, doc)); let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { let art = match map { Some(m) => m.place(art, doc), None => { let mut a = art.clone(); a.transform(*xf, false); a } }; // As `most_placed`, placed art counts its opacity-mask art. let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; (art, count) }); if let Some(art) = placed { self.node(s, &art, page, true); } } } // A gradient mesh is a smooth patch shading where it can be written as one. NodeKind::Mesh(m) if self.mesh(s, m) => {} // Live blends/envelopes/meshes export their evaluated (expanded) form. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(s, c, page, false); } } } self.knockout = enclosing; for _ in 0..pushes { s.pop(); } } /// Gradient mesh `m` as one PDF Type 6 (Coons patch) shading filling its outline (ISO 32000-1, /// 8.7.4.5.7): each patch blends its corner colours bilinearly, as the canvas draws it, and /// the viewer or RIP does so smoothly at its own resolution. Cutting the mesh into flat quads /// instead banded, left seams between them, and made files with many meshes very large. /// Corner colours are written as [`Self::page_color`] writes any colour; RGB among other /// models goes through RGB, as the writer needs one colour space a shading (ink-based ones: /// CMYK and grey, merge). `false` when it isn't written so: the caller draws it expanded. /// That is a mesh with see-through points (their opacities would be a soft mask of another /// patch shading, which a common viewer, Poppler, draws with gaps and too opaque; the quads /// carry their own opacity), an invalid mesh, or one beyond the coordinates a file holds. fn mesh(&mut self, s: &mut Surface, m: &vectorcraft_doc::live::GradientMesh) -> bool { if !m.is_valid() || !m.points.iter().all(|p| p.opacity >= 1.0) { return false; } let Some(bounds) = m.bounds() else { return false }; if !writable(s, Affine::IDENTITY, bounds) { return false; } let Some(path) = to_path(s, &m.outline().to_bezpath()) else { return false }; let page: Vec = m.points.iter().map(|p| self.page_color(&p.color)).collect(); let mixed = page.windows(2).any(|w| w[0].model() != w[1].model()); let rgb = page.iter().any(|c| matches!(c, vectorcraft_color::Color::Rgb { .. } | vectorcraft_color::Color::Lab { .. })); let colors: Vec = page.iter().map(|c| if mixed && rgb { color(&c.in_model(vectorcraft_color::cms::Model::Rgb)) } else { color(c) }).collect(); let pt = |p: vectorcraft_geom::Point| (p.x as f32, p.y as f32); let mut patches = Vec::with_capacity(m.rows as usize * m.cols as usize); for r in 0..m.rows as usize { for c in 0..m.cols as usize { // The corners in the order a Type 6 shading lists them: down the left edge (v), // along the bottom (u), back up the right edge and back along the top. let corners = [m.idx(r, c), m.idx(r + 1, c), m.idx(r + 1, c + 1), m.idx(r, c + 1)]; let (left, bottom, right, top) = (m.edge_v(r, c), m.edge_u(r + 1, c), m.edge_v(r, c + 1), m.edge_u(r, c)); let points = [left.p0, left.p1, left.p2, left.p3, bottom.p1, bottom.p2, bottom.p3, right.p2, right.p1, right.p0, top.p2, top.p1].map(pt); let Some(colors) = corners.iter().map(|&i| colors.get(i).cloned()).collect::>>().and_then(|v| <[_; 4]>::try_from(v).ok()) else { return false; }; patches.push(krilla::paint::MeshPatch { points, colors, opacities: [NormalizedF32::ONE; 4] }); } } let mesh = krilla::paint::MeshGradient { patches, transform: xf(Affine::IDENTITY), anti_alias: false }; s.set_stroke(None); s.set_fill(Some(Fill { paint: mesh.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); s.set_fill(None); true } fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) { let Some(path) = to_path(s, bp) else { return }; let bounds = bp.bounding_box(); for item in &n.appearance.items { match item { AppearanceItem::Fill(fl) => { if !fl.visible || fl.paint.is_none() { continue; } self.warn_raster(&fl.effects, || "fills".into()); self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) { return; } let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; let bl = fl.blend != BlendMode::Normal; if bl { s.push_blend_mode(blend(fl.blend)); } s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) })); s.draw_path(&path); if bl { s.pop(); } }); } AppearanceItem::Stroke(st) => { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } self.warn_raster(&st.effects, || "strokes".into()); self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds)); } } } s.set_fill(None); s.set_stroke(None); } /// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it /// `overprints`, paints and overprints are kept (but not a white one with Discard White /// Overprint). pub(super) fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) { let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint); if overprints && self.overprint && !paint.is_none() && !white() { self.overprinted = true; crate::forms::form(s, Mark::Overprint, |s| draw(self, s)); } else { draw(self, s); } } /// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot /// inks ([`Self::perceptual_spot`])? pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), Paint::Gradient(g) => g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill), _ => false, } } /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the /// canvas), clipped to it. False (nothing drawn) for other paints. #[allow(clippy::too_many_arguments)] pub(super) fn area( &mut self, s: &mut Surface, clip: &Path, rule: krilla::paint::FillRule, p: &Paint, transparency: (f32, BlendMode), bounds: Rect, area: Rect, fill: bool, ) -> bool { if !self.area_paint(p, fill) { return false; } let (opacity, mode) = transparency; let tiling = match p { Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf), _ => None, }; let mut pushes = 0; if mode != BlendMode::Normal { s.push_blend_mode(blend(mode)); pushes += 1; } if let Some(paint) = tiling { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule })); s.draw_path(clip); s.set_fill(None); } else { s.push_clip_path(clip, &rule); pushes += 1; if opacity < 1.0 { s.push_opacity(norm(opacity)); pushes += 1; } let doc = self.doc; match p { Paint::Pattern { pattern, xf } => { if let Some(def) = doc.pattern(pattern) { self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles"); for inst in def.instances_in(*xf, area) { self.node(s, &inst, area, true); } } } Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), _ => {} } } for _ in 0..pushes { s.pop(); } true } /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its /// unplaced gradients. #[allow(clippy::too_many_arguments)] pub(super) fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } if self.area_paint(&st.paint, false) && st.path_gradient().is_none() { // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as // Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a // gradient along or across the stroke is written as slices below). let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); if let Some(clip) = to_path(s, ®ion) { let area = region.bounding_box(); self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false); } return; } let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); // A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out. if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) { return; } let mut pushes = 0; if st.blend != BlendMode::Normal { s.push_blend_mode(blend(st.blend)); pushes += 1; } match w.side { Some(StrokeAlign::Inside) => { s.push_clip_path(path, &rule(r)); pushes += 1; } Some(StrokeAlign::Outside) => { // Clip to everything outside the path: a frame around all the stroke reaches // (miter spikes included) plus the path, even-odd. let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1); outside.extend(bp.iter()); if let Some(p) = to_path(s, &outside) { s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); pushes += 1; } } _ => {} } match &w.shape { WrittenShape::Stroke { width } => { let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| { let mut pat: Vec = d.pattern.iter().map(|v| *v as f32).collect(); if pat.len() % 2 == 1 { pat.extend(pat.clone()); } // An offset beyond [`MAX_COORD`] is written as its remainder after whole // periods of the pattern. let period: f64 = pat.iter().copied().map(f64::from).sum(); let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) }; StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } } }); s.set_fill(None); s.set_stroke(Some(Stroke { paint, width: *width as f32, miter_limit: st.miter_limit.max(1.0) as f32, line_cap: match st.cap { LineCap::Butt => krilla::paint::LineCap::Butt, LineCap::Round => krilla::paint::LineCap::Round, LineCap::Square => krilla::paint::LineCap::Square, }, line_join: match st.join { LineJoin::Miter => krilla::paint::LineJoin::Miter, LineJoin::Round => krilla::paint::LineJoin::Round, LineJoin::Bevel => krilla::paint::LineJoin::Bevel, }, opacity: norm(st.opacity), dash, })); s.draw_path(path); s.set_stroke(None); } WrittenShape::Fill(outlines) if st.path_gradient().is_some() => { // A gradient along or across the stroke: slices clipped to its outlines, under // the stroke's opacity as a group. if let Some(ws) = stroke::written_slices(bp, r, st, outlines) && let Some(clip) = to_path(s, &ws.clip) { self.warn("gradients along or across strokes are exported as slices of linear gradients"); if st.opacity < 1.0 { s.push_opacity(norm(st.opacity)); pushes += 1; } s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); s.set_stroke(None); for (shape, paint) in &ws.slices { let b = shape.bounding_box(); if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) { s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&p); } } s.set_fill(None); s.pop(); } } WrittenShape::Fill(outlines) => { // The line and its arrowheads overlap: they take the opacity once, as a group. let grouped = outlines.len() > 1 && st.opacity < 1.0; if grouped { s.push_opacity(norm(st.opacity)); pushes += 1; } let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); for outline in outlines { if let Some(p) = to_path(s, outline) { s.draw_path(&p); } } s.set_fill(None); } } for _ in 0..pushes { s.pop(); } } /// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode /// over all of it). False when it has no known brush. fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); let mut pushes = 0; if st.blend != BlendMode::Normal { s.push_blend_mode(blend(st.blend)); pushes += 1; } if st.opacity < 1.0 { s.push_opacity(norm(st.opacity)); pushes += 1; } for piece in &art { self.node(s, piece, page, false); } for _ in 0..pushes { s.pop(); } true } }