From 0efcaeb5587fd96c1c0e8ac2210159d3646ef477 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:41:03 +0700 Subject: [PATCH] Importers: a brush that draws itself by name no longer crashes A scatter brush whose art is an instance of a symbol stroked with that brush, or any brush whose art is painted with a pattern whose tile is stroked with it, drew itself without end: a stack overflow in a hand-written native file. Brush pieces already strip the brushes of their own strokes; now what they draw by name that would draw a brushed stroke (a symbol instance, or a pattern paint, also through the symbols and patterns those use) is left out too, in one place (`stroke_pieces`, which every renderer and exporter calls with the document). Probed the rest of item 2 of the depth follow-up with hand-written hostile inputs, kept as tests: graph designs of deep art and of a symbol holding itself, Affinity groups nested to and past both of its reader's caps, DXF blocks inserting themselves, EMF/WMF saving states without end, and plug-in manifests and output nested 30,000 to 100,000 deep. None of them overflowed. --- crates/brush/src/lib.rs | 94 ++++++++++++++- crates/brush/src/tests.rs | 128 ++++++++++++++------ crates/cad/src/scene.rs | 2 +- crates/doc/src/swatches.rs | 4 +- crates/engine/src/cmd/pathops.rs | 2 +- crates/engine/tests/import_fuzz.rs | 182 +++++++++++++++++++++++++++++ crates/eps/src/scene.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export.rs | 2 +- crates/render/src/brush_fx.rs | 2 +- crates/svg/src/export.rs | 2 +- 11 files changed, 370 insertions(+), 52 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 339d0ec5..91523163 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -16,6 +16,7 @@ mod defaults; pub mod track; mod warp; +use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 { (0.02 * scale).clamp(0.01, 0.25) } -/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their -/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the -/// caller's job (`StrokeLayer::opacity`). -pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec { +/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id +/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush, +/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]). +/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`). +pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec { let w = if stroke.width > 0.0 { stroke.width } else { 1.0 }; let mut out = match &brush.kind { BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), @@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec = (bool, &'a str); + +/// What [`draws_brushed`] found of each symbol and pattern so far. +type Known = HashMap<(bool, String), bool>; + +/// The symbol an instance draws and the patterns that paint `n` (not its children). +fn named(n: &Node) -> impl Iterator> { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())), + _ => None, + }; + let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p { + Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())), + _ => None, + }); + symbol.into_iter().chain(patterns) +} + +/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in +/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far. +fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool { + let key = (def.0, def.1.to_string()); + if let Some(&answer) = known.get(&key) { + return answer; + } + let art = |(pattern, name): Named<'_>| -> Vec<&Node> { + match pattern { + false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(), + true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(), + } + }; + // Without recursion, each one looked at once: they may use one another without end. + let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def)); + let mut answer = false; + while let Some(n) = stack.pop() { + if has_brush(n) { + answer = true; + break; + } + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c)); + for other in named(n) { + if seen.insert((other.0, other.1.to_string())) { + stack.extend(art(other)); + } + } + } + known.insert(key, answer); + answer +} + +/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke +/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or +/// pattern holding art stroked with the brush itself would draw the brush again, without end. +fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { + fn walk(doc: &Document, n: &mut Node, known: &mut Known) { + for paint in vectorcraft_doc::swatches::node_paints_mut(n) { + if let Paint::Pattern { pattern, .. } = paint + && draws_brushed(doc, (true, pattern.as_str()), known) + { + *paint = Paint::None; + } + } + if let Some(children) = n.children_mut() { + children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known))); + for c in children.iter_mut() { + walk(doc, Arc::make_mut(c), known); + } + } + if n.extras().mask.is_some() + && let Some(mask) = n.extras_mut().mask.as_mut() + { + walk(doc, Arc::make_mut(&mut mask.art), known); + } + } + nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known))); + for n in nodes.iter_mut() { + walk(doc, n, known); + } +} + /// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each /// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed. pub fn node_pieces(doc: &Document, n: &Node) -> Option> { @@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option> { continue; } any = true; - for mut p in stroke_pieces(b, &bp, st) { + for mut p in stroke_pieces(doc, b, &bp, st) { p.opacity *= st.opacity; if st.blend != vectorcraft_color::BlendMode::Normal { p.blend = st.blend; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index b012fb97..94e9e601 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec) { #[test] fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() { - let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 1); let b = bounds(&out); assert!(near(b.height(), 10.0, 0.05), "{b:?}"); @@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() { #[test] fn calligraphic_flat_nib_is_thin_along_its_angle() { - let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out)); } #[test] fn calligraphic_width_at_45_degrees_matches_ellipse_support() { let (a, b) = (5.0_f64, 1.0_f64); - let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt(); assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height()); // A vertical stroke with the same nib has the same width (45° is symmetric). - let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0)); + let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0)); assert!(near(bounds(&v).width(), expect, 0.05)); } #[test] fn calligraphic_scales_with_stroke_weight() { - let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0)); assert!(near(bounds(&out).height(), 6.0, 0.05)); } #[test] fn round_nib_is_direction_independent() { - let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0)); let b = bounds(&out); // Round caps: the bounds grow by the radius on every side. assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}"); @@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() { #[test] fn closed_calligraphic_makes_a_ring() { let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); - let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0)); let pd = out[0].path_data().unwrap(); assert_eq!(pd.subpaths.len(), 2); let poly_area = |sp: &vectorcraft_geom::SubPath| { @@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node { #[test] fn art_brush_stretches_to_path_length() { - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0)); let b = bounds(&out); assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}"); assert!(near(b.height(), 4.0, 1e-6)); // 3 pt stroke → 3× wider. - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0)); assert!(near(bounds(&out).height(), 12.0, 1e-6)); } @@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() { let mut spacer = frame.clone(); spacer.appearance = Appearance::default().into(); let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]); - let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); let g = &out[0]; let m = g.children().unwrap()[1].geometric_bounds().unwrap(); assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}"); @@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() { #[test] fn art_brush_proportional_keeps_aspect() { - let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 8.0, 1e-6)); } @@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() { Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); let shaft = rect_art(80.0, 2.0); let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]); - let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let out = stroke_pieces( + &Document::new(1.0, 1.0), + &art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), + &line(0.0, 0.0, 300.0, 0.0), + &stroke(1.0), + ); let h = out[0].children().unwrap()[1].geometric_bounds().unwrap(); assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}"); } @@ -154,7 +162,7 @@ fn art_brush_follows_curves() { bp_open.push(*el); } } - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); let mut pts = vec![]; all_points(&out[0], &mut pts); assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len()); @@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush { #[test] fn scatter_count_and_spacing() { - let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 10); let centres: Vec = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect(); for (i, c) in centres.iter().enumerate() { assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}"); } - let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 5); } @@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() { }), }; let p = line(0.0, 0.0, 300.0, 0.0); - let a1 = stroke_pieces(&b, &p, &stroke(1.0)); - let a2 = stroke_pieces(&b, &p, &stroke(1.0)); + let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0)); + let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0)); assert_eq!(a1, a2); let ys: Vec = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect(); assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}"); @@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush { #[test] fn pattern_tile_count_by_fit() { let p = line(0.0, 0.0, 100.0, 0.0); - assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10); + assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10); let p2 = line(0.0, 0.0, 104.0, 0.0); - let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0)); + let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0)); assert_eq!(st.len(), 10); assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly"); - let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0)); + let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0)); assert_eq!(sp.len(), 10); assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size"); - assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5); + assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5); } #[test] fn pattern_places_corner_tiles() { let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); - let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); let corners = out .iter() .filter( @@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() { #[test] fn bristle_makes_translucent_strands() { let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); assert!(out.len() >= 5, "{}", out.len()); assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0)); let bb = bounds(&out); @@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() { let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") }; a.colorization = Colorization::Tints; let b = Brush { name: "t".into(), kind: BrushKind::Art(a) }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0)); assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); let mut white = rect_art(1.0, 1.0); white.appearance.set_fill(Paint::solid(Color::WHITE)); @@ -310,7 +318,7 @@ fn brushes_serde_round_trip() { fn every_default_brush_draws_something() { let bp = preview_path(120.0, 24.0); for b in defaults() { - let out = stroke_pieces(b, &bp, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0)); assert!(!out.is_empty(), "{}", b.name); let bb = bounds(&out); assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name); @@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer { fn pressure_maps_to_size_along_the_stroke() { let b = pressure_calli(10.0, 4.0); let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]); - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy); // Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there. assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5)); assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5)); assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5)); // The stroke weight scales it. let heavy = StrokeLayer { width: 2.0, ..light_to_heavy }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy); assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2)); // No pressure recorded (a mouse, or a path the brush was applied to): the size itself. - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out)); // Kept within the valid range: 2 ± 4 never goes below nothing. - let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)])); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)])); assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out)); } @@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() { let horizontal = line(0.0, 0.0, 100.0, 0.0); let draw = |c: Calligraphic, p: f64| { let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) }; - bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height() + bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height() }; use Variation::{Fixed, Pressure}; // Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke. @@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() { let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) }; let wide = |p| { let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) }; - bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width() + bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width() }; assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0)); assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0)); @@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() { ..old.clone() }; let bp = preview_path(120.0, 24.0); - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0)) + ); // Fixed ignores the variation; Random still draws a different size per stroke. let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() }; - assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05)); + assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05)); // Recorded pressure changes nothing for a brush that doesn't use it. - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])) + ); // Modes are saved by name. assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"])); assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v))); @@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() { let bp = preview_path(120.0, 24.0); for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] { for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] { - let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile)); let mut pts = vec![]; for n in &out { all_points(n, &mut pts); @@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush { #[test] fn scatter_values_follow_their_modes() { let bp = line(0.0, 0.0, 400.0, 0.0); - let heights = - |b: &Brush, st: &StrokeLayer| -> Vec { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() }; + let heights = |b: &Brush, st: &StrokeLayer| -> Vec { + stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() + }; // Fixed: every copy at 50 %, whatever the second value. assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6))); // Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt). @@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() { let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") }; assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]); let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() }; - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0)) + ); assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset"); } @@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() { assert_eq!(b.art().len(), 3, "the side and both corner tiles"); assert!(calli(0.0, 100.0, 3.0).art().is_empty()); } + +/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed +/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay. +#[test] +fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { + use vectorcraft_doc::{PatternDef, Symbol}; + let mut doc = Document::new(100.0, 100.0); + let mut brushed = rect_art(10.0, 4.0); + brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); + for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] { + doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) }); + doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)])); + } + let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let painted = |pattern: &str| { + let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY }; + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0)) + }; + let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into()); + let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty()); + for piece in &out { + let children = piece.children().unwrap(); + let symbols: Vec<&str> = children + .iter() + .filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None }) + .collect(); + assert_eq!(symbols, ["plain"]); + let fills: Vec = + children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect(); + assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]); + } +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 22fb1887..425505a7 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -506,7 +506,7 @@ impl<'a> Scene<'a> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; - Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 689124b0..19e36da8 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -226,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint { } /// The paints a node holds itself (not its children): appearance items, then text runs. -pub(crate) fn node_paints(n: &Node) -> impl Iterator { +pub fn node_paints(n: &Node) -> impl Iterator { let runs = match &n.kind { NodeKind::Text(t) => t.runs.as_slice(), _ => &[], @@ -235,7 +235,7 @@ pub(crate) fn node_paints(n: &Node) -> impl Iterator { } /// Mutable [`node_paints`], in the same order. -pub(crate) fn node_paints_mut(n: &mut Node) -> impl Iterator { +pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { NodeKind::Text(t) => t.runs.as_mut_slice(), _ => &mut [], diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 4169add6..1538c361 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke( st: &StrokeLayer, ) -> Option { if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st); + let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st); let art = match pieces.len() { 0 => return None, 1 => pieces.remove(0), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bd79a7b2..c3e2fb11 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2881,6 +2881,188 @@ fn deeply_nested_dxf_blocks_are_read_within_the_cap() { }); } +#[test] +fn dxf_blocks_inserting_themselves_are_left_out() { + on_a_small_stack(|| { + let pair = |c: i32, v: &str| (c, v.to_string()); + // A line, then an insert of `inserts`. + let block = |name: &str, inserts: &str| { + let line = [pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]; + let insert = [pair(0, "INSERT"), pair(2, inserts), pair(10, "1"), pair(20, "1"), pair(0, "ENDBLK")]; + [pair(0, "BLOCK"), pair(2, name), pair(10, "0"), pair(20, "0")].into_iter().chain(line).chain(insert).collect::>() + }; + let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")]; + pairs.extend([block("SELF", "SELF"), block("A", "B"), block("B", "A")].concat()); + pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES")]); + for name in ["SELF", "A"] { + pairs.extend([pair(0, "INSERT"), pair(2, name), pair(10, "0"), pair(20, "0")]); + } + pairs.extend([pair(0, "ENDSEC"), pair(0, "EOF")]); + let (_, warnings) = open_deep("self.dxf", dxf_text(&pairs).as_bytes()).expect("opens"); + assert!(warnings.iter().any(|w| w.contains("inside themselves")), "{warnings:?}"); + }); +} + +#[test] +fn deeply_nested_affinity_groups_are_read_within_the_cap() { + use vectorcraft_affinity::synth::{self, F, Method, tag}; + // Groups in groups: within what the reader takes, past its cap on layers (128), past its cap + // on the stream's own nesting (384), and far past both. + let files: Vec> = [20u32, 120, 180, 370, 2000] + .into_iter() + .map(|levels| { + let group = |id: u32, fields| F::Def(id, vec![tag(b"Grup")], fields); + let inner = (0..levels).fold(group(10, vec![]), |inner, i| group(11 + i, vec![(tag(b"Chld"), F::Shared(vec![inner]))])); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![inner]))]); + let stream = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + affinity_native(&stream, Method::Stored) + }) + .collect(); + on_a_small_stack(move || { + for bytes in &files { + open_deep("deep.afdesign", bytes); + } + }); +} + +#[test] +fn metafiles_saving_states_without_end_are_read_within_the_cap() { + on_a_small_stack(|| { + // A saved state, a clip and a rectangle, over and over (nothing is ever restored). + let longs = |v: [i32; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let emf: Vec<(u32, Vec)> = (0..50_000).flat_map(|_| [(33, vec![]), (30, longs([0, 0, 500, 500])), (43, longs([0, 0, 50, 50]))]).collect(); + open_deep("deep.emf", &emf_of(&emf)); + let shorts = |v: [i16; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let wmf: Vec<(u16, Vec)> = + (0..20_000).flat_map(|_| [(0x001E, vec![]), (0x0416, shorts([500, 500, 0, 0])), (0x041B, shorts([50, 50, 0, 0]))]).collect(); + open_deep("deep.wmf", &wmf_of(&wmf)); + }); +} + +#[test] +fn deeply_nested_plugin_manifests_and_output_are_errors() { + on_a_small_stack(|| { + // (A manifest may be 64 kB.) + let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)); + assert!(vectorcraft_plugins::Manifest::parse(deep(30_000).as_bytes()).is_err()); + assert!(vectorcraft_plugins::Manifest::parse(format!("{{\"params\": {}}}", deep(30_000)).as_bytes()).is_err()); + let deep = deep(100_000); + for output in [deep.clone(), format!("{{\"objects\": {deep}}}"), format!("{{\"objects\": [{{\"kind\": \"path\", \"path\": {deep}}}]}}")] { + assert!(vectorcraft_plugins::objects::decode(output.as_bytes()).is_err()); + } + }); +} + +/// A document whose symbol `s0` and pattern `p0` are each a line stroked with the brush `loop`, +/// with an instance of the symbol and a square painted with the pattern on the layer. The brush +/// is the `kind`th of those that hold art (art, scatter, pattern), and its art is the `art`th of: +/// an instance of the symbol; that instance inside as many groups as a file holds; a square +/// painted with the pattern. So what the brush draws is drawn with the brush. +fn brushed_document(kind: usize, art: usize) -> Document { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, PatternBrush, Scatter}; + use vectorcraft_color::Paint; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, PatternDef, Symbol}; + let mut doc = nested_document(1); + let square = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 60.0, 20.0)); + let brushed = |doc: &mut Document| { + let mut look = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)), 4.0); + for item in &mut look.items { + if let AppearanceItem::Stroke(s) = item { + s.brush = Some("loop".into()); + } + } + Node::path(doc.alloc_id(), square(), look) + }; + let instance = + |doc: &mut Document| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let painted = |doc: &mut Document| { + let fill = Paint::Pattern { pattern: "p0".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0)) + }; + let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc)); + doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); + doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)])); + let art = match art { + 0 => instance(&mut doc), + 1 => (0..MAX_DEPTH + 4).fold(instance(&mut doc), |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])), + _ => painted(&mut doc), + }; + let kind = match kind { + 0 => BrushKind::Art(ArtBrush { art, ..Default::default() }), + 1 => BrushKind::Scatter(Scatter { art, ..Default::default() }), + _ => BrushKind::Pattern(PatternBrush { side: art.clone(), start: Some(art), ..Default::default() }), + }; + vectorcraft_brush::store(&mut doc, &[Brush { name: "loop".into(), kind }]); + let layer = doc.layers[0].id; + for on_layer in [instance(&mut doc), painted(&mut doc), brushed(&mut doc)] { + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + } + doc +} + +/// `doc` saved, opened again, drawn, exported and edited: nothing overflows. +fn art_by_name_survives(what: &str, doc: Document) { + let (back, _) = open_deep("named.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens"); + let mut s = vectorcraft_engine::Session::new(); + s.add_document(back, None); + for (cmd, p) in [ + ("select.all", json!({})), + ("document.inspect", json!({})), + ("brush.expand", json!({})), + ("edit.undo", json!({})), + ("object.expand", json!({})), + ("edit.undo", json!({})), + ("object.outlineStroke", json!({})), + ("edit.undo", json!({})), + ("graph.design", json!({"paste": "deep"})), + ("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})), + ("graph.marker", json!({"design": "deep"})), + ("graph.design", json!({"new": "again"})), + ("edit.copy", json!({})), + ("edit.paste", json!({})), + ] { + let _ = no_panic(s.execute(cmd, &p)); + assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}"); + } + for format in ["svg", "pdf", "eps", "png", "dxf", "emf"] { + let _ = no_panic(s.execute("document.export", &json!({"format": format}))); + } +} + +/// A test for each brush whose art is drawn with the brush itself ([`brushed_document`]). +macro_rules! brushes_drawn_with_themselves { + ($($test:ident: $kind:literal, $art:literal;)*) => {$( + #[test] + fn $test() { + on_a_small_stack(|| art_by_name_survives(stringify!($test), brushed_document($kind, $art))); + } + )*}; +} + +brushes_drawn_with_themselves! { + an_art_brush_of_its_own_symbol: 0, 0; + an_art_brush_of_its_own_symbol_in_deep_art: 0, 1; + an_art_brush_painted_with_its_own_pattern: 0, 2; + a_scatter_brush_of_its_own_symbol: 1, 0; + a_scatter_brush_of_its_own_symbol_in_deep_art: 1, 1; + a_scatter_brush_painted_with_its_own_pattern: 1, 2; + a_pattern_brush_of_its_own_symbol: 2, 0; + a_pattern_brush_of_its_own_symbol_in_deep_art: 2, 1; + a_pattern_brush_painted_with_its_own_pattern: 2, 2; +} + +#[test] +fn a_graph_design_of_deep_art_and_of_a_symbol_holding_itself_is_drawn() { + use vectorcraft_doc::{GraphDesign, Node, NodeKind}; + on_a_small_stack(|| { + let mut doc = symbols_document(1, 1, "s0"); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let art = (0..MAX_DEPTH + 4).fold(instance, |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])); + doc.graph_designs.push(GraphDesign { name: "deep".into(), art: std::sync::Arc::new(art) }); + art_by_name_survives("a graph design of deep art", doc); + }); +} + /// A layer holding `levels` groups in one another around a rectangle, each with a second /// rectangle beside the next: `levels + 2` deep. fn nested_document(levels: usize) -> Document { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 89fff7c5..f43f840c 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -542,7 +542,7 @@ impl<'a> Scene<'a> { 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(b, bp, st); + let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); self.note_item(st.opacity, st.blend); self.nest += 1; for piece in &art { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 99957589..8a5d940c 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -403,7 +403,7 @@ impl<'a> Scene<'a> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; - Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } /// A placed image, decoded once per image. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ea9154c9..a64eb554 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1336,7 +1336,7 @@ impl Exporter<'_> { 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(b, bp, st); + 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)); diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 24558df5..72dc14de 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -107,7 +107,7 @@ impl Renderer { let pieces = match self.brushes.entries.get(&key, stamp) { Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(), _ => { - let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st)); + let pieces = Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st)); let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::(); self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); pieces diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 4f8e7f88..1f60936f 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -917,7 +917,7 @@ impl Writer<'_> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)), + Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), None => StrokePlan::Written(stroke::for_writer(bp, st)), } }