Merge wp/C2: stroke export parity and Outline Stroke fidelity (M3.36, M3.37)

This commit is contained in:
bflatastic
2026-10-04 23:08:27 +07:00
22 changed files with 901 additions and 245 deletions
Generated
+4
View File
@@ -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",
+2 -3
View File
@@ -101,8 +101,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
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<Node> {
.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();
+48 -13
View File
@@ -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<EffectInfo> {
"{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)),
+4 -1
View File
@@ -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).
+116
View File
@@ -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<StrokeAlign>,
}
/// 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<BezPath>),
}
/// 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(&region, FillRule::NonZero, path, rule, BoolOp::Intersect),
StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Difference),
_ => region,
}
}
+8 -2
View File
@@ -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]
+1 -3
View File
@@ -249,9 +249,7 @@ fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<Node
}
let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return };
let w = n.appearance.stroke_width();
let ctx = effects::GeomContext { stroke_width: if w > 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
+93 -63
View File
@@ -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<Value> {
// ---------- 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<Node> {
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<Node> {
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<Node> = vec![];
let mut fills: Vec<AppearanceItem> = vec![];
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
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<Value> {
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<Value> {
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)?;
+2
View File
@@ -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;
+164
View File
@@ -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)));
}
+1 -1
View File
@@ -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.
+42 -3
View File
@@ -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<BezPath> = 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(&region, 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}");
}
}
+4
View File
@@ -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 }
+86 -72
View File
@@ -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<ExportRep
}
pdf.set_metadata(meta);
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() };
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None };
for i in indices {
let ab = &doc.artboards[i];
let r = ab.rect;
@@ -129,6 +130,8 @@ struct Exporter<'a> {
doc: &'a Document,
warnings: Vec<String>,
images: HashMap<String, Option<Image>>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
}
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<f32> = 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<f32> = 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);
+5 -1
View File
@@ -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;
+13 -11
View File
@@ -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<Rect>, Vec<Rect>) {
pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
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");
}
}
}
+71
View File
@@ -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");
}
+5 -10
View File
@@ -54,11 +54,6 @@ fn node_bezpath(n: &Node) -> Option<BezPath> {
}
}
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<Rect> {
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<Rect> = (!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) => {
+2
View File
@@ -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 }
+137 -55
View File
@@ -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> {
/// `<pattern>` def ids by pattern name + placement.
patterns: HashMap<String, String>,
pattern_nest: u32,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
}
/// How one stroke is written.
enum StrokePlan {
/// Its brush art (document space).
Brush(Vec<Node>),
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<Rect>) {
/// 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<BezPath>, 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<StrokeAlign>, 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!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
self.def(1, "</clipPath>");
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!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
self.def(1, "</mask>");
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<Rect>) {
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<Option<StrokePlan>> = 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!("<g{id}{a}>"));
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!("<path d=\"{d}\"{a}/>"));
}
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!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"{d}\"{rule_attr}/>"));
self.def(1, "</clipPath>");
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!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"),
);
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
self.def(1, "</mask>");
format!(" mask=\"url(#{mid})\"")
}
};
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}{extra}/>"));
self.line(&format!("<g{a}>"));
self.depth += 1;
for piece in &art {
self.node(piece);
}
self.depth -= 1;
self.line("</g>");
}
(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!("<path d=\"{d}\"{a}{side}/>"));
}
WrittenShape::Fill(outlines) => {
let paint = self.paint(&s.paint, paint_bounds(s));
let ds: Vec<String> = 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!("<path d=\"{one}\"{a}{side}/>"));
} 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!("<g{a}{side}>"));
self.depth += 1;
let fill = self.attrs(&vec![("fill", paint)]);
for one in &ds {
self.line(&format!("<path d=\"{one}\"{fill}/>"));
}
self.depth -= 1;
self.line("</g>");
}
}
}
}
(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<String> = 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<String> = 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("</g>");
+12 -7
View File
@@ -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 (`<clipPath>`); an *outside* stroke as a stroke of
//! double width masked by a `<mask>` 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 (`<clipPath>`); an
//! *outside* stroke as a stroke of double width masked by a `<mask>` 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 `<g>` holding one
//! `<path>` 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.
+81
View File
@@ -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}");
}