From 18d870c7dfc6338568508a8f4fe0ad55c3d2e163 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 22:53:58 +0700 Subject: [PATCH 1/2] M3.37: Outline Stroke and the live Outline Stroke effect outline strokes as the canvas paints them Outline Stroke (and the stroke part of Expand) now builds each stroke from the shared canvas geometry: dash shapes, arrowheads, width profiles and inside/outside alignment, united into one clean path (pathops::stroke_region). Every stroke item is outlined in paint order (runs of fills stay on copies of the path), stroke opacity, blend mode and effects move to the new fill, and brushed strokes become their brush art. The live Effect > Path > Outline Stroke outlines the stroke it sits on (on a fill or the object: the top stroke) with that stroke's cap, join, dashes, alignment, profile, arrowheads and weight: GeomContext now carries the stroke and the fill rule, built per item by the renderer, export baking and Expand Appearance. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 5 +- crates/effects/src/lib.rs | 61 +++++++-- crates/effects/src/stroke.rs | 5 +- crates/effects/src/stroke/outline.rs | 33 +++++ crates/effects/src/tests.rs | 10 +- crates/engine/src/cmd/effectcmd.rs | 4 +- crates/engine/src/cmd/pathops.rs | 156 ++++++++++++--------- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_outlinestroke.rs | 164 +++++++++++++++++++++++ crates/pathops/src/lib.rs | 2 +- crates/pathops/src/offset.rs | 45 ++++++- crates/render/src/fx.rs | 15 +-- 12 files changed, 403 insertions(+), 99 deletions(-) create mode 100644 crates/effects/src/stroke/outline.rs create mode 100644 crates/engine/src/tests_outlinestroke.rs 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..36abf1102 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); +//! - [`outline_region`]: a stroke as one clean filled path (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, 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..39eca794a --- /dev/null +++ b/crates/effects/src/stroke/outline.rs @@ -0,0 +1,33 @@ +//! Strokes as filled geometry outside the canvas: +//! +//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live +//! Outline Stroke effect). + +use vectorcraft_doc::{StrokeAlign, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_pathops::BoolOp; + +use super::{aligned_width, is_closed, line_outline, stroke_pieces}; + +/// Flattening/fitting tolerance of outlined strokes (document points). +pub const OUTLINE_TOL: f64 = 0.01; + +/// 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 6ea09da73..4031dd20e 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/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) => { From f363cdb4eef51dd6a0ac5cdeb4be4054fa24d421 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:07:33 +0700 Subject: [PATCH 2/2] M3.36: SVG and PDF export draw arrowheads, width profiles, brushes and aligned strokes as the canvas does Both writers now ask the shared stroke geometry how to write each stroke (effects::stroke::for_writer): a plain stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles, dashes fitted to corners, dots with round or projecting caps), grouped under the stroke opacity when the line and its heads overlap. Brushed strokes are written as their brush art. Inside/outside alignment only applies to closed paths, so open paths export as centred strokes. The PDF outside-stroke clip frame and the SVG outside mask now cover the whole stroke reach (miter spikes, projecting caps). SVG stroke gradients use the same paint box as the canvas. The svg crate docs no longer claim arrowheads, profiles, brushes and effects are dropped. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 + crates/effects/src/stroke.rs | 4 +- crates/effects/src/stroke/outline.rs | 89 ++++++++++++- crates/pdf/Cargo.toml | 4 + crates/pdf/src/export.rs | 158 ++++++++++++---------- crates/pdf/src/lib.rs | 6 +- crates/pdf/src/tests_stroke.rs | 24 ++-- crates/pdf/src/tests_strokeout.rs | 71 ++++++++++ crates/svg/Cargo.toml | 2 + crates/svg/src/export.rs | 192 +++++++++++++++++++-------- crates/svg/src/lib.rs | 19 ++- crates/svg/tests/stroke_export.rs | 81 +++++++++++ 12 files changed, 503 insertions(+), 151 deletions(-) create mode 100644 crates/pdf/src/tests_strokeout.rs create mode 100644 crates/svg/tests/stroke_export.rs 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/stroke.rs b/crates/effects/src/stroke.rs index 36abf1102..38a7b347f 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -8,7 +8,7 @@ //! - [`width_outline`]: variable-width (profile) strokes; //! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots, //! caps and joins); -//! - [`outline_region`]: a stroke as one clean filled path (Outline Stroke). +//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke. mod arrow; mod dash; @@ -22,7 +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, outline_region}; +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 index 39eca794a..1688b3c24 100644 --- a/crates/effects/src/stroke/outline.rs +++ b/crates/effects/src/stroke/outline.rs @@ -1,17 +1,100 @@ //! 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 vectorcraft_doc::{StrokeAlign, StrokeLayer}; +use kurbo::{BezPath, Rect, Shape}; +use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops::BoolOp; -use super::{aligned_width, is_closed, line_outline, stroke_pieces}; +use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces}; -/// Flattening/fitting tolerance of outlined strokes (document points). +/// 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. 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/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}"); +}