diff --git a/Cargo.lock b/Cargo.lock index 3e0705efb..1c3e883f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4218,10 +4218,12 @@ dependencies = [ "log", "smallvec", "thiserror 2.0.21", + "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", "vectorcraft-geom", + "vectorcraft-testkit", "vectorcraft-text", ] @@ -4251,10 +4253,12 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", + "resvg", "serde_json", "svgtypes 0.16.1", "thiserror 2.0.21", "usvg 0.48.1", + "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 3b66df255..9e2423d2c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -101,8 +101,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { fn bake_leaf(d: &mut Document, n: &Node) -> Option { let (base, rule) = geometry(n)?; - let w = n.appearance.stroke_width(); - let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }; + let ctx = GeomContext::of(n); let g = apply(&n.appearance.effects, &base, &ctx); let mut m = n.clone(); m.appearance.effects.retain(|e| !is_geometry(&e.id)); @@ -117,7 +116,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { .items .iter() .map(|item| { - let ig = apply(item_effects(item), &g, &ctx); + let ig = apply(item_effects(item), &g, &ctx.item(item)); let mut it = item.clone(); clear_item_effects(&mut it); let id = d.alloc_id(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 425d3167c..96d9e3784 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -33,8 +33,8 @@ mod warp; mod tests; use serde_json::{Map, Value, json}; -use vectorcraft_doc::Effect; -use vectorcraft_geom::{BezPath, PathData, Rect}; +use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; pub use bake::{bake_document, needs_bake}; pub use clip::clip_outline; @@ -158,7 +158,13 @@ pub fn effect_catalog() -> Vec { "{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}", json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}), ), - g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})), + g( + "path.outlineStroke", + "Outline Stroke", + PATH, + "{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads", + json!({}), + ), g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})), g( "stylize.scribble", @@ -269,15 +275,33 @@ pub fn has_geometry(effects: &[Effect]) -> bool { } /// Context the geometry effects may need from the object's appearance. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct GeomContext { - /// Stroke weight used by `path.outlineStroke` when the effect has no `width`. - pub stroke_width: f64, +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GeomContext<'a> { + /// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment, + /// profile and arrowheads): the stroke the effect sits on, else the object's top painted + /// stroke. `None` outlines a plain 1 pt stroke. + pub stroke: Option<&'a StrokeLayer>, + /// The object's fill rule (inside and outside alignment). + pub rule: FillRule, } -impl Default for GeomContext { - fn default() -> Self { - Self { stroke_width: 1.0 } +impl<'a> GeomContext<'a> { + /// The context of `n`'s object-level effects. + pub fn of(n: &'a Node) -> Self { + let rule = match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + }; + Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule } + } + + /// The context of the effects on `item`, one of the same object's appearance items: a + /// stroke's own effects outline that stroke. + pub fn item(self, item: &'a AppearanceItem) -> Self { + match item { + AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self }, + AppearanceItem::Fill(_) => self, + } } } @@ -330,11 +354,22 @@ fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) - vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0)) } "path.outlineStroke" => { - let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width); - if w <= 0.0 { + let fallback; + let base = match ctx.stroke { + Some(s) => s, + None => { + fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0); + &fallback + } + }; + let st = match p.get("width").and_then(Value::as_f64) { + Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }), + None => std::borrow::Cow::Borrowed(base), + }; + if st.width.is_nan() || st.width <= 0.0 { return path.clone(); } - vectorcraft_pathops::outline_stroke(path, w, vectorcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0)) + stroke::outline_region(path, ctx.rule, &st) } "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index f57f949fe..38a7b347f 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -7,10 +7,12 @@ //! turns into discs or squares ([`dot_outline`]); //! - [`width_outline`]: variable-width (profile) strokes; //! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots, -//! caps and joins). +//! caps and joins); +//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke. mod arrow; mod dash; +mod outline; mod width; use std::borrow::Cow; @@ -20,6 +22,7 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; pub use arrow::Arrow; pub use dash::{Dashed, Dot, dash, dot_outline}; +pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region}; pub use width::width_outline; /// Arc-length accuracy for dashing and trimming (document points). diff --git a/crates/effects/src/stroke/outline.rs b/crates/effects/src/stroke/outline.rs new file mode 100644 index 000000000..1688b3c24 --- /dev/null +++ b/crates/effects/src/stroke/outline.rs @@ -0,0 +1,116 @@ +//! Strokes as filled geometry outside the canvas: +//! +//! - [`for_writer`]: what a file format whose strokes only have a weight, caps, joins, a miter +//! limit and a dash array (SVG, PDF) writes so a stroke looks as it does on the canvas: a plain +//! stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles, +//! dashes fitted to corners, dots); +//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live +//! Outline Stroke effect). + +use kurbo::{BezPath, Rect, Shape}; +use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_pathops::BoolOp; + +use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces}; + +/// Flattening/fitting tolerance of written and outlined strokes (document points). +pub const OUTLINE_TOL: f64 = 0.01; + +/// What a writer draws for one stroke (in the coordinate space of its path). +#[derive(Clone, Debug)] +pub struct Written { + pub shape: WrittenShape, + /// `Some` for an inside or outside stroke of a closed path: the shape is drawn twice as wide + /// and clipped to the path's interior (inside) or has the interior removed (outside). + pub side: Option, +} + +/// The geometry of a [`Written`] stroke. +#[derive(Clone, Debug)] +pub enum WrittenShape { + /// A plain stroke of the path itself at `width`, with the layer's caps, joins, miter limit + /// and dash pattern. + Stroke { width: f64 }, + /// Filled outlines (non-zero rule): the line, then each arrowhead. They overlap, so they take + /// the stroke's opacity once, as a group. + Fill(Vec), +} + +/// Does `st` draw as a plain centred stroke on every path (no alignment, arrowheads, width +/// profile, brush, fitted dashes or dots)? Writers then need no geometry for it. +pub fn is_plain(st: &StrokeLayer) -> bool { + st.align == StrokeAlign::Center + && st.start_arrow.is_none() + && st.end_arrow.is_none() + && st.profile.is_none() + && st.brush.is_none() + && !needs_outline_dashes(st) +} + +/// Dashes a plain stroke can't draw like the canvas: fitted to corners, or zero-length dashes +/// (dots) with a cap that paints them. +fn needs_outline_dashes(st: &StrokeLayer) -> bool { + st.dash.as_ref().is_some_and(|d| d.is_dashed() && (d.align_corners || (st.cap != LineCap::Butt && d.pattern.contains(&0.0)))) +} + +/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't. +fn needs_outline(pieces: &StrokePieces, st: &StrokeLayer) -> bool { + !pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st) +} + +/// How a writer draws stroke `st` along `bp` (brushes aside) to match the canvas. +pub fn for_writer(bp: &BezPath, st: &StrokeLayer) -> Written { + if is_plain(st) { + return Written { shape: WrittenShape::Stroke { width: st.width }, side: None }; + } + let closed = is_closed(bp); + let side = (st.align != StrokeAlign::Center && closed).then_some(st.align); + let width = aligned_width(st, closed); + let pieces = stroke_pieces(bp, st); + let shape = if needs_outline(&pieces, st) { + let mut fills = vec![line_outline(&pieces.line, st, width, OUTLINE_TOL)]; + fills.extend(pieces.heads.into_iter().map(|h| h.outline)); + fills.retain(|f| !f.elements().is_empty()); + WrittenShape::Fill(fills) + } else { + WrittenShape::Stroke { width } + }; + Written { shape, side } +} + +impl Written { + /// Bounds of what is drawn before the alignment clip, for a path with bounds `path_bounds` + /// (covers miter spikes and projecting caps). + pub fn reach(&self, st: &StrokeLayer, path_bounds: Rect) -> Rect { + match &self.shape { + WrittenShape::Stroke { width } => { + let miter = if st.join == LineJoin::Miter { st.miter_limit.max(1.0) } else { 1.0 }; + let cap = if st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + let r = width / 2.0 * miter.max(cap); + path_bounds.inflate(r, r) + } + WrittenShape::Fill(fills) => fills.iter().map(Shape::bounding_box).fold(path_bounds, |a, b| a.union(b)), + } + } +} + +/// The area stroke `st` paints along `path` (fill rule `rule`) as one clean path, as on the +/// canvas: the line (width profile, dashes, caps, joins) united with the arrowheads, and kept +/// inside or outside a closed path for inside or outside alignment. Brushes are not drawn. +pub fn outline_region(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData { + if !(st.width > 0.0 && st.width.is_finite()) || path.is_empty() { + return PathData::default(); + } + let bp = path.to_bezpath(); + let closed = is_closed(&bp); + let pieces = stroke_pieces(&bp, st); + let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, closed), OUTLINE_TOL)]; + parts.extend(pieces.heads.into_iter().map(|h| h.outline)); + let region = vectorcraft_pathops::stroke_region(&parts); + match st.align { + StrokeAlign::Inside if closed => vectorcraft_pathops::boolean(®ion, FillRule::NonZero, path, rule, BoolOp::Intersect), + StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(®ion, FillRule::NonZero, path, rule, BoolOp::Difference), + _ => region, + } +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 1286b3d60..2ad398a1b 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -212,11 +212,17 @@ fn offset_path_grows_and_shrinks() { } #[test] -fn outline_stroke_uses_context_width() { - let ctx = GeomContext { stroke_width: 10.0 }; +fn outline_stroke_uses_context_stroke() { + let st = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 10.0); + let ctx = GeomContext { stroke: Some(&st), ..Default::default() }; let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx); let b = out.bounds().unwrap(); assert!(close(b.width(), 110.0, 0.1), "{b:?}"); + // An explicit width overrides the weight; without a stroke a plain 1 pt one is outlined. + let out = apply_geometry_with(&[fx("path.outlineStroke", json!({"width": 4}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx); + assert!(close(out.bounds().unwrap().width(), 104.0, 0.1)); + let out = run("path.outlineStroke", json!({}), &square()); + assert!(close(out.bounds().unwrap().width(), 101.0, 0.1)); } #[test] diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index dd712cf98..85bf5eeb3 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -249,9 +249,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec 0.0 { w } else { 1.0 } }; - let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx); + let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n)); let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 { let children = baked .subpaths diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 3657d6d56..5e4b6b5c1 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops as po; @@ -450,41 +450,107 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { // ---------- Outline Stroke ---------- -fn cap_of(c: LineCap) -> po::Cap { - match c { - LineCap::Butt => po::Cap::Butt, - LineCap::Round => po::Cap::Round, - LineCap::Square => po::Cap::Square, - } -} -fn join_of(j: LineJoin) -> po::Join { - match j { - LineJoin::Miter => po::Join::Miter, - LineJoin::Round => po::Join::Round, - LineJoin::Bevel => po::Join::Bevel, - } +/// Can Outline Stroke turn `st` into filled art? +fn outlinable(st: &StrokeLayer) -> bool { + st.visible && !st.paint.is_none() && st.width > 0.0 } -/// The filled outline of `st` stroking `path` (honours inside/outside alignment on closed paths). -pub(crate) fn stroke_outline(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData { - let (cap, join) = (cap_of(st.cap), join_of(st.join)); - let w = st.width; - if !w.is_finite() || w <= 0.0 { - return PathData::default(); +/// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a +/// group around it. +fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node { + if opacity >= 1.0 && blend == BlendMode::Normal { + return n; } - match st.align { - StrokeAlign::Inside | StrokeAlign::Outside if path.is_closed() => { - let ring = po::outline_stroke(path, 2.0 * w, cap, join, st.miter_limit); - let op = if st.align == StrokeAlign::Inside { BoolOp::Intersect } else { BoolOp::Difference }; - po::boolean(&ring, FillRule::NonZero, path, rule, op) + if n.opacity < 1.0 || n.blend != BlendMode::Normal { + n = Node::group(d.alloc_id(), vec![Arc::new(n)]); + } + n.opacity = opacity; + n.blend = blend; + n +} + +/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the +/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode +/// and effects. `None` when it paints nothing. +fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: &PathData, rule: FillRule, 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 art = match pieces.len() { + 0 => return None, + 1 => pieces.remove(0), + _ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()), + }; + let art = d.reid(&art); + return Some(with_transparency(d, art, st.opacity, st.blend)); + } + let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st); + if outline.is_empty() { + return None; + } + let mut n = shape_node(d, outline, None); + let fill = vectorcraft_doc::appearance::FillLayer { + opacity: st.opacity, + blend: st.blend, + effects: st.effects.clone(), + ..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) + }; + n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] }; + Some(n) +} + +/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of +/// the path, each stroke becomes its outline (or brush art), grouped when there are several. +/// `None` when `l` has no stroke to outline. +fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { + if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { + return None; + } + let (path, rule) = node_path(l)?; + let mut parts: Vec = vec![]; + let mut fills: Vec = vec![]; + let fill_node = |d: &mut Document, items: Vec| { + let mut f = d.reid(l); + f.appearance = Appearance { items, effects: vec![] }; + f.opacity = 1.0; + f.blend = BlendMode::Normal; + f.name = None; + f.mask = None; + if let NodeKind::Path { live, .. } = &mut f.kind { + *live = None; + } + f + }; + for item in &l.appearance.items { + match item { + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()), + AppearanceItem::Stroke(st) if outlinable(st) => { + if !fills.is_empty() { + parts.push(fill_node(d, std::mem::take(&mut fills))); + } + parts.extend(outlined_stroke(d, brushes, &path, rule, st)); + } + _ => {} } - _ => po::outline_stroke(path, w, cap, join, st.miter_limit), } + if !fills.is_empty() { + parts.push(fill_node(d, fills)); + } + let root = match parts.len() { + 0 => return None, + 1 => parts.remove(0), + _ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()), + }; + let mut root = with_transparency(d, root, l.opacity, l.blend); + root.name = l.name.clone(); + root.mask = l.mask.clone(); + root.appearance.effects = l.appearance.effects.clone(); + Some(root) } fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { + let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; for root in &roots { @@ -496,43 +562,7 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { if matches!(l.kind, NodeKind::Text(_)) { continue; } - let Some(st) = l.appearance.stroke().filter(|s| s.visible && !s.paint.is_none() && s.width > 0.0).cloned() else { continue }; - let Some((path, rule)) = node_path(&l) else { continue }; - let outline = stroke_outline(&path, rule, &st); - if outline.is_empty() { - continue; - } - let mut stroke_node = shape_node(d, outline, Some(&l)); - stroke_node.appearance = - Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] }; - let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned(); - let new = if let Some(f) = fill { - let mut fill_node = l.clone(); - fill_node.id = d.alloc_id(); - if let Some(ch) = fill_node.children_mut() { - for c in ch.iter_mut() { - Arc::make_mut(c).id = d.alloc_id(); - } - } - fill_node.appearance = Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] }; - if let NodeKind::Path { live, .. } = &mut fill_node.kind { - *live = None; - } - let gid = d.alloc_id(); - let mut g = Node::group(gid, vec![Arc::new(fill_node), Arc::new(stroke_node)]); - g.opacity = l.opacity; - g.blend = l.blend; - if let Some(ch) = g.children_mut() { - for c in ch.iter_mut() { - let c = Arc::make_mut(c); - c.opacity = 1.0; - c.blend = BlendMode::Normal; - } - } - g - } else { - stroke_node - }; + let Some(new) = outline_strokes(d, &brushes, &l) else { continue }; let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?; let nid = d.insert(par, idx, new)?; d.remove(l.id)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a4a930a7..c9aaaca27 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -760,6 +760,8 @@ mod tests_menucmds; #[cfg(test)] mod tests_opacitymask; #[cfg(test)] +mod tests_outlinestroke; +#[cfg(test)] mod tests_paintproxy; #[cfg(test)] mod tests_panelcmds; diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs new file mode 100644 index 000000000..d9298ffdf --- /dev/null +++ b/crates/engine/src/tests_outlinestroke.rs @@ -0,0 +1,164 @@ +//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the +//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke +//! item in order, with the stroke's opacity and blend mode moved to the new fill. + +use serde_json::{Value, json}; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// A horizontal 100 pt line from (0, 50), black, no fill, with stroke options `opts`. +fn line(s: &mut Session, opts: Value) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("stroke.set", &opts).unwrap(); + id +} + +fn outline(s: &mut Session) -> Node { + let r = s.execute("object.path.outlineStroke", &json!({})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn path_of(n: &Node) -> PathData { + match &n.kind { + NodeKind::Path { path, .. } => path.clone(), + NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), + _ => panic!("not a path: {:?}", n.kind), + } +} + +fn area(n: &Node) -> f64 { + vectorcraft_pathops::area(&path_of(n), FillRule::NonZero) +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +fn close(a: Rect, b: Rect, tol: f64) -> bool { + near(a.x0, b.x0, tol) && near(a.y0, b.y0, tol) && near(a.x1, b.x1, tol) && near(a.y1, b.y1, tol) +} + +#[test] +fn a_dashed_line_gives_one_subpath_per_dash() { + let mut s = session(); + line(&mut s, json!({"weight": 4, "dash": [10, 10]})); + let n = outline(&mut s); + assert_eq!(path_of(&n).subpaths.len(), 5, "dashes at 0, 20, 40, 60 and 80"); + assert!(near(area(&n), 5.0 * 10.0 * 4.0, 0.5), "{}", area(&n)); + assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 48.0, 90.0, 52.0), 0.01)); +} + +#[test] +fn arrowheads_are_part_of_the_outline() { + let mut s = session(); + line(&mut s, json!({"weight": 2, "endArrow": "Triangle"})); + let n = outline(&mut s); + // The 2 pt line plus an 8 × 8 triangle whose tip sits past the end point. + let b = n.geometric_bounds().unwrap(); + assert!(b.x1 > 103.0 && near(b.height(), 8.0, 0.05), "{b:?}"); + assert!(area(&n) > 200.0 + 20.0, "{}", area(&n)); + assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape"); +} + +#[test] +fn a_lens_profile_outlines_to_half_the_rectangle() { + let mut s = session(); + line(&mut s, json!({"weight": 10, "profile": "lens"})); + let n = outline(&mut s); + assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n)); +} + +#[test] +fn inside_alignment_keeps_the_outline_inside_the_path() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4, "align": "inside"})).unwrap(); + let g = outline(&mut s); + let ring = &g.children().unwrap()[1]; + assert!(close(ring.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01)); + assert!(near(area(ring), 100.0 * 100.0 - 92.0 * 92.0, 1.0), "{}", area(ring)); +} + +#[test] +fn stroke_opacity_and_blend_move_to_the_new_fill() { + let mut s = session(); + line(&mut s, json!({"weight": 4})); + s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50, "blend": "multiply"})).unwrap(); + let n = outline(&mut s); + let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { panic!("{:?}", n.appearance) }; + assert!(near(f.opacity as f64, 0.5, 1e-6) && f.blend == BlendMode::Multiply, "{f:?}"); + assert!(n.opacity == 1.0 && n.blend == BlendMode::Normal); +} + +#[test] +fn every_stroke_is_outlined_in_paint_order() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + // Items: fill, 1 pt stroke, then the new top stroke at 10 pt red. + s.execute("appearance.setItem", &json!({"index": 2, "weight": 10, "color": "#ff0000"})).unwrap(); + let g = outline(&mut s); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 3, "the fill, then both outlines"); + assert!(ch[0].appearance.items.iter().all(AppearanceItem::is_fill)); + assert!(close(ch[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-9)); + assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-0.5, -0.5, 100.5, 100.5), 0.01)); + assert!(close(ch[2].geometric_bounds().unwrap(), Rect::new(-5.0, -5.0, 105.0, 105.0), 0.01)); + assert_eq!(ch[2].appearance.fill().unwrap().paint.color().unwrap().to_hex(), "#ff0000"); + // One undo step restores the stroked rectangle. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3); +} + +/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke. +#[test] +fn the_live_effect_outlines_the_stroke_it_sits_on() { + let mut s = session(); + let id = line(&mut s, json!({"weight": 20})); + // Items: the line's empty fill, its 20 pt stroke, then a new 2 pt top stroke. + s.execute("appearance.addStroke", &json!({})).unwrap(); + s.execute("appearance.setItem", &json!({"index": 2, "weight": 2})).unwrap(); + // The lower (20 pt) stroke gets the effect. + s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": 1})).unwrap(); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + assert!(matches!(&n.appearance.items[1], AppearanceItem::Stroke(st) if st.width == 20.0 && st.effects.len() == 1), "{:?}", n.appearance.items); + let baked = vectorcraft_render::effects::bake_document(doc).unwrap(); + let parts = baked.node(id).unwrap().children().unwrap().to_vec(); + assert!(near(parts[1].geometric_bounds().unwrap().height(), 20.0, 0.01), "{:?}", parts[1].geometric_bounds()); + assert!(near(parts[2].geometric_bounds().unwrap().height(), 0.0, 1e-9), "the top stroke keeps the line"); + // On the object, the effect outlines the top stroke. + s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": null})).unwrap(); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + let ctx = vectorcraft_render::effects::GeomContext::of(n); + let g = vectorcraft_render::effects::apply_geometry_with(&n.appearance.effects, n.path_data().unwrap(), Rect::new(0.0, 50.0, 100.0, 50.0), &ctx); + assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds()); +} + +#[test] +fn brushed_strokes_outline_to_their_brush_art() { + let mut s = session(); + line(&mut s, json!({"weight": 2})); + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50})).unwrap(); + let n = outline(&mut s); + let doc = &s.doc().unwrap().doc; + let mut fills = 0; + n.walk(&mut |m| fills += m.appearance.items.iter().filter(|i| i.is_fill()).count()); + assert!(fills > 0 && n.appearance.stroke().is_none(), "brush art: {n:?}"); + assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art"); + assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c))); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 344c3963a..7f9b367f8 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -16,7 +16,7 @@ pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize pub use edit::{ AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid, }; -pub use offset::{Cap, Join, offset_path, outline_stroke}; +pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; /// Errors from fallible operations. diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 838180a9d..ff128e9c9 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -46,11 +46,31 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l if width <= 0.0 || !width.is_finite() || path.is_empty() { return PathData::default(); } - let s = stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit); - if s.elements().is_empty() { + stroke_region(&[stroke_bez(&path.to_bezpath(), width, cap, join, miter_limit)]) +} + +/// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths +/// closed) as one clean path: overlaps removed and the stroker's pieces refitted. +pub fn stroke_region(outlines: &[BezPath]) -> PathData { + let tidy = Tidy::free(DEFAULT_PRECISION); + let parts: Vec = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect(); + if let [one] = &parts[..] { + return normalize_bez(one, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default(); + } + // Each outline is normalised on its own first: a hollow arrowhead may wind against the line + // it overlaps, and the raw windings would cancel there under the non-zero rule. + let mut all = BezPath::new(); + for p in &parts { + if let Ok(c) = normalize_bez(p, FillRule::NonZero) { + for ct in c.contours() { + all.extend(ct.path.iter()); + } + } + } + if all.elements().is_empty() { return PathData::default(); } - normalize_bez(&s, FillRule::NonZero).map(|c| all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION))).unwrap_or_default() + normalize_bez(&all, FillRule::NonZero).map(|c| all_contours_to_path(&c, &tidy)).unwrap_or_default() } /// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it. @@ -154,3 +174,22 @@ fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 { use kurbo::ParamCurveNearest; bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt() } + +#[cfg(test)] +mod tests { + use kurbo::Shape; + + use super::*; + + #[test] + fn stroke_region_unites_outlines_whatever_their_winding() { + // A bar, and a square ring wound the other way that the bar runs into. + let bar = kurbo::Rect::new(0.0, 0.0, 20.0, 4.0).to_path(0.1); + let mut ring = kurbo::Rect::new(10.0, -8.0, 26.0, 12.0).to_path(0.1).reverse_subpaths(); + ring.extend(kurbo::Rect::new(14.0, -4.0, 22.0, 8.0).to_path(0.1).iter()); + let region = stroke_region(&[bar, ring]); + let area = crate::area(®ion, FillRule::NonZero); + // Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4). + assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}"); + } +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 35143d4ef..540e7f33d 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -12,6 +12,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-brush = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-text = { workspace = true } kurbo = { workspace = true } @@ -22,3 +23,6 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } log = { workspace = true } smallvec = "1" + +[dev-dependencies] +vectorcraft-testkit = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0d34a7506..f90b59535 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -15,6 +15,7 @@ use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; @@ -70,7 +71,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { doc: &'a Document, warnings: Vec, images: HashMap>, + /// The brush library, parsed when the first brushed stroke is written. + brushes: Option>, } fn xf(a: Affine) -> Transform { @@ -406,7 +409,7 @@ impl Exporter<'_> { } NodeKind::Path { path, rule, guide, .. } => { if !*guide { - self.shape(s, n, &path.to_bezpath(), *rule); + self.shape(s, n, &path.to_bezpath(), *rule, page); } } NodeKind::Compound { children, rule } => { @@ -416,7 +419,7 @@ impl Exporter<'_> { bp.extend(p.to_bezpath()); } } - self.shape(s, n, &bp, *rule); + self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t), NodeKind::Image(im) => self.image(s, im), @@ -440,7 +443,7 @@ impl Exporter<'_> { } } - fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) { + fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) { let Some(path) = to_path(bp) else { return }; let bounds = bp.bounding_box(); for item in &n.appearance.items { @@ -486,7 +489,7 @@ impl Exporter<'_> { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } - self.stroke(s, bp, &path, r, st, bounds); + self.stroke(s, bp, &path, r, st, page); } } } @@ -494,43 +497,27 @@ impl Exporter<'_> { s.set_stroke(None); } - fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) { - if st.profile.is_some() || st.brush.is_some() { - self.warn("variable-width profiles and brushes are exported as uniform strokes"); + fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) { + if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { + return; } - let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return }; - let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)); + let bounds = bp.bounding_box(); + let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; + let w = stroke::for_writer(bp, st); let mut pushes = 0; if st.blend != BlendMode::Normal { s.push_blend_mode(blend(st.blend)); pushes += 1; } - // The line ends under its arrowheads; they overlap, so both take the opacity once, as a group. - let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st); - let grouped = !pieces.heads.is_empty() && st.opacity < 1.0; - if grouped { - s.push_opacity(norm(st.opacity)); - pushes += 1; - } - let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; - let trimmed = match &pieces.line { - std::borrow::Cow::Owned(line) => Some(to_path(line)), - std::borrow::Cow::Borrowed(_) => None, - }; - let width = match st.align { - StrokeAlign::Center => st.width, - _ if closed => st.width * 2.0, - _ => st.width, - }; - match st.align { - StrokeAlign::Inside if closed => { + match w.side { + Some(StrokeAlign::Inside) => { s.push_clip_path(path, &rule(r)); pushes += 1; } - StrokeAlign::Outside if closed => { - // Clip to everything outside the path: a big frame plus the path, even-odd. - let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0); - let mut outside = big.to_path(0.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(&outside) { s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); @@ -539,55 +526,82 @@ impl Exporter<'_> { } _ => {} } - 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()); + 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()); + } + StrokeDash { array: pat, offset: d.offset as f32 } + }); + 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); } - StrokeDash { array: pat, offset: d.offset as f32 } - }); - s.set_fill(None); - s.set_stroke(Some(Stroke { - paint: paint.clone(), - 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, - dash, - })); - // A line fully covered by its heads (Tip alignment) has nothing left to stroke. - if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) { - s.draw_path(line); - } - s.set_stroke(None); - // Pop the alignment clip before drawing arrowheads. - if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 { - s.pop(); - pushes -= 1; - } - if !pieces.heads.is_empty() { - s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); - for head in &pieces.heads { - if let Some(p) = to_path(&head.outline) { + 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 p in outlines.iter().filter_map(to_path) { s.draw_path(&p); } + s.set_fill(None); } - 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(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 + } + fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let tb = t.xf.transform_rect_bbox(layout.bounds); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 680c00760..e0a18467d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1,7 +1,9 @@ //! VectorCraft PDF export and import. //! //! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with -//! caps/joins/miter/dashes, non-zero/even-odd), opacity and blend modes (transparency groups), +//! caps/joins/miter/dashes, inside/outside alignment as clips, non-zero/even-odd; arrowheads, +//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes +//! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph //! paths. Hidden objects, guides and template layers are skipped. //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a @@ -107,3 +109,5 @@ pub enum PdfError { mod tests; #[cfg(test)] mod tests_stroke; +#[cfg(test)] +mod tests_strokeout; diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests_stroke.rs index 26515ccfb..25c413b9b 100644 --- a/crates/pdf/src/tests_stroke.rs +++ b/crates/pdf/src/tests_stroke.rs @@ -1,14 +1,14 @@ -//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared -//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once. +//! Stroke geometry in PDF export: arrowheads and the trimmed line are written as the shared +//! `vectorcraft_effects::stroke` outlines, and they take the stroke opacity once. use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_effects::stroke::stroke_pieces; +use vectorcraft_effects::stroke::{OUTLINE_TOL, line_outline, stroke_pieces}; use vectorcraft_geom::{Point, Rect, Shape, shapes}; use crate::*; -fn line_doc(st: StrokeLayer) -> (Document, Node) { +pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) { let mut d = Document::new(100.0, 100.0); let mut n = Node::path( NodeId(0), @@ -34,7 +34,7 @@ fn close(a: Rect, b: Rect) -> bool { } /// Bounding boxes of the filled and the stroked paths of a PDF read back. -fn painted_boxes(d: &Document) -> (Vec, Vec) { +pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap(); let (mut fills, mut strokes) = (vec![], vec![]); back.walk(|m| { @@ -54,15 +54,17 @@ fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { for kind in Arrowhead::ALL { let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); - let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap()); + let st = n.appearance.stroke().unwrap(); + let pieces = stroke_pieces(&bp, st); let (fills, strokes) = painted_boxes(&d); let head = pieces.heads[0].outline.bounding_box(); - assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head"); - assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]); - assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line"); - assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]); + let line = line_outline(&pieces.line, st, st.width, OUTLINE_TOL).bounding_box(); + assert!(strokes.is_empty(), "{kind:?} {align:?}: the line is written as its outline"); + assert_eq!(fills.len(), 2, "{kind:?} {align:?}: the line, then the head"); + assert!(close(fills[0], line), "{kind:?} {align:?}: {:?} vs {line:?}", fills[0]); + assert!(close(fills[1], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[1]); if align == ArrowAlign::Tip { - assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head"); + assert!((fills[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head"); } } } diff --git a/crates/pdf/src/tests_strokeout.rs b/crates/pdf/src/tests_strokeout.rs new file mode 100644 index 000000000..23a8ea8aa --- /dev/null +++ b/crates/pdf/src/tests_strokeout.rs @@ -0,0 +1,71 @@ +//! Strokes a PDF stroke can't draw — width profiles, brushes — are written as the canvas's filled +//! outlines; aligned strokes are clipped like on the canvas, open paths stroke centred. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineJoin, Node, NodeId, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_testkit::raster::render_artboard; + +use crate::tests_stroke::{line_doc, painted_boxes}; +use crate::*; + +fn black(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + f(&mut st); + st +} + +#[test] +fn a_lens_profile_is_written_as_a_fill() { + let (d, _) = line_doc(black(10.0, |s| s.profile = Some(WidthProfile::lens()))); + let (fills, strokes) = painted_boxes(&d); + assert!(strokes.is_empty(), "{strokes:?}"); + assert_eq!(fills.len(), 1); + // The lens is full width (10 pt) only in the middle of the 20..80 line. + assert!((fills[0].height() - 10.0).abs() < 0.05 && (fills[0].width() - 60.0).abs() < 0.05, "{:?}", fills[0]); +} + +#[test] +fn an_open_path_with_inside_alignment_strokes_centred() { + let (d, _) = line_doc(black(4.0, |s| s.align = StrokeAlign::Inside)); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut widths = vec![]; + back.walk(|m| { + assert!(!matches!(m.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }), "no clip"); + widths.extend(m.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None })); + }); + assert_eq!(widths.len(), 1); + assert!((widths[0] - 4.0).abs() < 1e-3, "{widths:?}"); +} + +#[test] +fn brushed_strokes_are_written_as_their_art() { + let (d, _) = line_doc(black(2.0, |s| s.brush = Some("Tapered Stroke".into()))); + let (fills, strokes) = painted_boxes(&d); + assert!(strokes.is_empty() && !fills.is_empty(), "{fills:?} {strokes:?}"); +} + +/// The clip frame of an outside stroke covers its miter spikes: a thin spike far above a sharp +/// apex is as dark in the exported PDF as on the canvas. +#[test] +fn outside_strokes_keep_their_miter_spikes() { + let mut d = Document::new(300.0, 300.0); + let tri = SubPath::polyline(&[Point::new(130.0, 250.0), Point::new(170.0, 250.0), Point::new(150.0, 100.0)], true); + let st = black(10.0, |s| { + s.align = StrokeAlign::Outside; + s.join = LineJoin::Miter; + s.miter_limit = 10.0; + }); + let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + // The spike's tip is ~76 pt above the apex (10 pt / sin 7.6°); 60 pt up it is still 4 pt wide. + let (x, y) = (150, 40); + let canvas = render_artboard(&d); + let back = render_artboard(&import(&export(&d, &PdfOptions::default()).unwrap()).unwrap()); + let dark = |p: [u8; 3]| p.iter().all(|c| *c < 64); + assert!(dark(canvas.over_white(x, y)), "the canvas draws the spike: {:?}", canvas.over_white(x, y)); + assert!(dark(back.over_white(x, y)), "the PDF keeps the spike: {:?}", back.over_white(x, y)); + assert!(!dark(back.over_white(x, 20)), "past its tip"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index c0d3f4aff..273a445e6 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -54,11 +54,6 @@ fn node_bezpath(n: &Node) -> Option { } } -fn geom_ctx(n: &Node) -> GeomContext { - let w = n.appearance.stroke_width(); - GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } } -} - fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { if !effects::has_geometry(effects) || bp.elements().is_empty() { return bp.clone(); @@ -68,7 +63,7 @@ fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath { /// The object-level effected geometry of `n` (base path `bp`). pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath { - apply(&n.appearance.effects, bp, &geom_ctx(n)) + apply(&n.appearance.effects, bp, &GeomContext::of(n)) } fn item_effects(item: &AppearanceItem) -> &[Effect] { @@ -81,13 +76,13 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { /// Visual bounds including geometry effects, stroke outsets and shadows/glows. pub(crate) fn visual_bounds(n: &Node) -> Option { let bp = node_bezpath(n)?; - let ctx = geom_ctx(n); + let ctx = GeomContext::of(n); let g = effected_path(n, &bp); let mut r: Option = (!g.elements().is_empty()).then(|| g.bounding_box()); for item in &n.appearance.items { let fx = item_effects(item); if effects::has_geometry(fx) { - let ig = apply(fx, &g, &ctx); + let ig = apply(fx, &g, &ctx.item(item)); if !ig.elements().is_empty() { let b = ig.bounding_box(); r = Some(r.map_or(b, |r| r.union(b))); @@ -298,7 +293,7 @@ impl Renderer { self.hairline(ctx, f, &g, [0, 0, 0, 255]); return; } - let gctx = geom_ctx(n); + let gctx = GeomContext::of(n); let rfx = effects::raster_effects(&n.appearance.effects); // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); @@ -452,7 +447,7 @@ impl Renderer { fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) { let bounds = g.bounding_box(); for item in &n.appearance.items { - let ig = apply(item_effects(item), g, gctx); + let ig = apply(item_effects(item), g, &gctx.item(item)); let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; match item { AppearanceItem::Fill(fl) => { diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 97f496faa..8cebaabec 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } kurbo = { workspace = true } usvg = { workspace = true } @@ -22,6 +23,7 @@ image = { workspace = true } svgtypes = "0.16" [dev-dependencies] +resvg = { version = "0.45", default-features = false } vectorcraft-testkit = { workspace = true } proptest = { workspace = true } image = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 60064d690..db75cf0d1 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -7,7 +7,8 @@ use vectorcraft_doc::{ AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject, }; use vectorcraft_doc::{CharStyle, Justify}; -use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; +use vectorcraft_effects::stroke::{self, Written, WrittenShape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect}; use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape}; @@ -44,6 +45,7 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { depth: 1, patterns: HashMap::new(), pattern_nest: 0, + brushes: None, }; w.assign_name_ids(); for l in &doc.layers { @@ -101,6 +103,15 @@ struct Writer<'a> { /// `` def ids by pattern name + placement. patterns: HashMap, pattern_nest: u32, + /// The brush library, parsed when the first brushed stroke is written. + brushes: Option>, +} + +/// How one stroke is written. +enum StrokePlan { + /// Its brush art (document space). + Brush(Vec), + Written(Written), } pub(crate) fn blend_css(b: BlendMode) -> &'static str { @@ -430,8 +441,57 @@ impl Writer<'_> { } } - /// Paint a shape (`d`) with a node's appearance stack. - fn shape(&mut self, n: &Node, d: &str, rule: FillRule, bounds: Option) { + /// How stroke `st` is written (`bp`: the shape in document space, built on first use from + /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. + fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { + if stroke::is_plain(st) { + return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); + } + let bp = bp.get_or_insert_with(|| { + let mut bp = BezPath::new(); + for p in paths { + bp.extend(p.to_bezpath()); + } + bp + }); + 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)), + None => StrokePlan::Written(stroke::for_writer(bp, st)), + } + } + + /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its + /// side of the shape `d`; `reach` (document space) is what the stroke covers. + fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { + match side { + None | Some(StrokeAlign::Center) => String::new(), + Some(StrokeAlign::Inside) => { + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" clip-path=\"url(#{cid})\"") + } + Some(StrokeAlign::Outside) => { + let mid = self.fresh_id("mask"); + let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0); + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" mask=\"url(#{mid})\"") + } + } + } + + /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document + /// space, for strokes that aren't plain (it may be empty when all are). + fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { let id = self.id_attr(n); let items: Vec<&AppearanceItem> = n .appearance @@ -442,14 +502,22 @@ impl Writer<'_> { AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, }) .collect(); + let mut bp = None; + let plans: Vec> = items + .iter() + .map(|i| match i { + AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), + AppearanceItem::Fill(_) => None, + }) + .collect(); let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); let strokes = items.len() - fills; + // A stroke's paint box is the bounds grown by half its weight, as on the canvas. + let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); let simple = fills <= 1 && strokes <= 1 - && items.iter().all(|i| match i { - AppearanceItem::Fill(f) => f.blend == BlendMode::Normal, - AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center, - }); + && items.iter().all(|i| i.blend() == BlendMode::Normal) + && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); if simple { let mut p = Props::new(); match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { @@ -462,7 +530,7 @@ impl Writer<'_> { } } if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { - self.stroke_props(s, s.width, bounds, &mut p); + self.stroke_props(s, s.width, paint_bounds(s), &mut p); if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { p.push(("paint-order", "stroke".into())); } @@ -475,10 +543,10 @@ impl Writer<'_> { let a = self.attrs(&Self::node_props(n)); self.line(&format!("")); self.depth += 1; - for it in items { + for (it, plan) in items.into_iter().zip(plans) { let mut p = Props::new(); - match it { - AppearanceItem::Fill(f) => { + match (it, plan) { + (AppearanceItem::Fill(f), _) => { self.fill_props(f, rule, bounds, &mut p); if f.blend != BlendMode::Normal { p.push(("mix-blend-mode", blend_css(f.blend).into())); @@ -486,44 +554,62 @@ impl Writer<'_> { let a = self.attrs(&p); self.line(&format!("")); } - AppearanceItem::Stroke(s) => { - p.push(("fill", "none".into())); - let width = if s.align == StrokeAlign::Center { s.width } else { s.width * 2.0 }; - self.stroke_props(s, width, bounds, &mut p); + (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { + // The brush art takes the stroke's opacity and blend mode as a group. + if s.opacity < 1.0 { + p.push(("opacity", fmt_num(s.opacity as f64, 3))); + } if s.blend != BlendMode::Normal { p.push(("mix-blend-mode", blend_css(s.blend).into())); } - let rule_attr = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - let extra = match s.align { - StrokeAlign::Center => String::new(), - StrokeAlign::Inside => { - let cid = self.fresh_id("clip-path"); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" clip-path=\"url(#{cid})\"") - } - StrokeAlign::Outside => { - let mid = self.fresh_id("mask"); - let b = self - .xf - .transform_rect_bbox(bounds.unwrap_or_default()) - .inflate(width * s.miter_limit.max(1.0) + 1.0, width * s.miter_limit.max(1.0) + 1.0); - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - self.def( - 1, - &format!(""), - ); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" mask=\"url(#{mid})\"") - } - }; let a = self.attrs(&p); - self.line(&format!("")); + self.line(&format!("")); + self.depth += 1; + for piece in &art { + self.node(piece); + } + self.depth -= 1; + self.line(""); } + (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { + let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); + let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); + match &w.shape { + WrittenShape::Stroke { width } => { + p.push(("fill", "none".into())); + self.stroke_props(s, *width, paint_bounds(s), &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + WrittenShape::Fill(outlines) => { + let paint = self.paint(&s.paint, paint_bounds(s)); + let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); + if let [one] = &ds[..] { + p.push(("fill", paint)); + p.extend(opacity.map(|o| ("fill-opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } else if !ds.is_empty() { + // The line and its arrowheads overlap: one group takes the opacity. + p.extend(opacity.map(|o| ("opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + let fill = self.attrs(&vec![("fill", paint)]); + for one in &ds { + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + } + } + } + } + (AppearanceItem::Stroke(_), None) => {} } } self.depth -= 1; @@ -622,20 +708,15 @@ impl Writer<'_> { return; } let d = self.path_d(path, self.xf); - self.shape(n, &d, *rule, n.geometric_bounds()); + self.shape(n, &d, &[path], *rule, n.geometric_bounds()); } NodeKind::Compound { children, rule } => { - let d: Vec = children - .iter() - .filter(|c| c.visible) - .filter_map(|c| c.path_data()) - .filter(|p| !p.is_empty()) - .map(|p| self.path_d(p, self.xf)) - .collect(); - if d.is_empty() { + let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); + if paths.is_empty() { return; } - self.shape(n, &d.join(" "), *rule, n.geometric_bounds()); + let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); + self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); } NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t), NodeKind::Text(t) => self.text(n, t), @@ -824,7 +905,8 @@ impl Writer<'_> { Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width)); let d = self.path_d(&pd, Affine::IDENTITY); let bounds = pd.bounds(); - self.shape(&glyphs, &d, FillRule::NonZero, bounds); + // Run strokes are plain: they need no document-space geometry. + self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds); } self.depth -= 1; self.line(""); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a5937723f..15aba18c1 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -8,14 +8,19 @@ //! //! ## Export approximations //! -//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke is written as a stroke of -//! double width clipped to the path's own shape (``); an *outside* stroke as a stroke of -//! double width masked by a `` that hides the path's interior. Both are visually exact for -//! closed paths but re-import as a group (clip group / masked stroke) rather than an aligned stroke. +//! * **Stroke alignment** is not expressible in SVG 1.1. An *inside* stroke of a closed path is +//! written as a stroke of double width clipped to the path's own shape (``); an +//! *outside* stroke as a stroke of double width masked by a `` that hides the path's +//! interior. Both are visually exact but re-import as a group (clip group / masked stroke) +//! rather than an aligned stroke. Open paths have no inside: their strokes are written centred, +//! as the canvas draws them. +//! * **Arrowheads, width profiles, dashes fitted to corners and dotted dashes** are written as the +//! filled outlines the canvas paints (the line and each arrowhead, grouped under the stroke's +//! opacity); **brushed strokes** as their brush art. Both re-import as filled paths. //! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `` holding one //! `` per appearance item, in paint order. -//! * Effects, arrowheads, width profiles, brushes, patterns and freeform gradients are not -//! expanded (patterns export as `none`, freeform gradients as linear). +//! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs) +//! as SVG filters. Freeform gradients export as linear gradients. //! //! ## Import approximations //! @@ -34,7 +39,7 @@ mod import; pub use vectorcraft_doc::Document; pub use vectorcraft_doc::TextObject; -/// How style properties are written (Illustrator's "Styling" option). +/// How style properties are written (the "Styling" export option). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Styling { /// `fill="#ff0000"` attributes. diff --git a/crates/svg/tests/stroke_export.rs b/crates/svg/tests/stroke_export.rs new file mode 100644 index 000000000..d38b4d75c --- /dev/null +++ b/crates/svg/tests/stroke_export.rs @@ -0,0 +1,81 @@ +//! Strokes in SVG export look as on the canvas: arrowheads, width profiles, dots and brushes are +//! written as the canvas's filled outlines or art, aligned strokes are clipped or masked, and open +//! paths stroke centred. Checked by rendering the exported SVG with resvg. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_geom::{PathData, Point, SubPath, shapes}; +use vectorcraft_svg::{ExportOptions, export}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +fn add(d: &mut Document, path: PathData, f: impl FnOnce(&mut StrokeLayer)) { + let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0); + f(&mut st); + let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +fn line(x0: f64, y: f64, x1: f64) -> PathData { + shapes::line(Point::new(x0, y), Point::new(x1, y)) +} + +/// The exported SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +#[test] +fn exported_strokes_render_like_the_canvas() { + let mut d = Document::new(200.0, 200.0); + add(&mut d, line(20.0, 20.0, 150.0), |s| { + s.end_arrow = Some(Arrowhead::Triangle); + s.start_arrow = Some(Arrowhead::CircleOpen); + s.opacity = 0.5; + }); + add(&mut d, line(20.0, 50.0, 180.0), |s| { + s.width = 14.0; + s.profile = Some(WidthProfile::lens()); + }); + add(&mut d, line(20.0, 80.0, 180.0), |s| { + s.cap = LineCap::Square; + s.dash = Some(Dash { pattern: vec![0.0, 12.0], offset: 0.0, align_corners: false }); + }); + add(&mut d, line(20.0, 110.0, 180.0), |s| s.brush = Some("Tapered Stroke".into())); + let tri = SubPath::polyline(&[Point::new(60.0, 185.0), Point::new(100.0, 185.0), Point::new(80.0, 135.0)], true); + add(&mut d, PathData::single(tri), |s| { + s.align = StrokeAlign::Outside; + s.join = LineJoin::Miter; + s.width = 6.0; + }); + let svg = export(&d, &ExportOptions::default()); + assert!(!svg.contains("stroke-dasharray"), "dots are outlines:\n{svg}"); + let canvas = render_artboard(&d); + let written = resvg_render(&svg, 200, 200); + assert_similar(&canvas, &written, 24.0, 0.002); + // The comparison is meaningful: every stroke drew something. + for y in [20, 50, 80, 110, 160] { + assert!((0..200).any(|x| canvas.over_white(x, y).iter().any(|c| *c < 200)), "row {y} has ink"); + } +} + +#[test] +fn an_open_path_with_inside_alignment_exports_a_plain_stroke() { + let mut d = Document::new(200.0, 100.0); + add(&mut d, line(20.0, 50.0, 180.0), |s| s.align = StrokeAlign::Inside); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("stroke-width=\"4\""), "{svg}"); + assert!(!svg.contains("clip-path") && !svg.contains("mask"), "{svg}"); +} + +#[test] +fn a_lens_profile_exports_a_fill() { + let mut d = Document::new(200.0, 100.0); + add(&mut d, line(20.0, 50.0, 180.0), |s| s.profile = Some(WidthProfile::lens())); + let svg = export(&d, &ExportOptions::default()); + assert!(svg.contains("fill=\"#1a3399\"") && !svg.contains("stroke="), "{svg}"); +}