diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 6980c002..0760c38a 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -116,10 +116,36 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); out } - (None, None) => plain_outline(line, &style(st, width), tol), + (None, None) => { + let mut out = plain_outline(line, &style(st, width), tol); + out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter()); + out + } } } +/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as +/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG +/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows +/// (SVG 2, "Path data": zero-length path segments). +fn point_dots(line: &BezPath) -> Vec { + let els = line.elements(); + subpaths(line) + .into_iter() + .filter_map(|(r, _)| { + let sub = els.get(r)?; + let at = sub.first()?.end_point()?; + let still = sub.iter().all(|e| match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at, + PathEl::QuadTo(a, b) => *a == at && *b == at, + PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at, + PathEl::ClosePath => true, + }); + still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 }) + }) + .collect() +} + /// `line` stroked with `style`, to fill with the non-zero rule. /// /// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f60a9a11..4586d794 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -467,3 +467,39 @@ fn supersampled_art_leaves_no_seam_between_shapes_that_meet() { assert_eq!(supersample_factor(5000, 5000), None); assert_eq!(supersample_factor(40_000, 10), None); } + +/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a +/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing +/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments). +#[test] +fn zero_length_subpaths_draw_dots_with_their_cap() { + let dots = |cap: vectorcraft_doc::LineCap| { + let mut d = Document::new(100.0, 100.0); + let mut bp = BezPath::new(); + for at in [(30.0, 50.0), (70.0, 50.0)] { + bp.move_to(at); + bp.line_to(at); + } + let id = d.alloc_id(); + let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0)); + n.appearance.stroke_mut().unwrap().cap = cap; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + render(&d) + }; + let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]); + let round = dots(vectorcraft_doc::LineCap::Round); + for x in [30, 70] { + assert_eq!(round.pixel(x, 50), black, "round dot at {x}"); + assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}"); + assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}"); + } + let square = dots(vectorcraft_doc::LineCap::Square); + for x in [30, 70] { + assert_eq!(square.pixel(x, 50), black, "square dot at {x}"); + assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}"); + assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}"); + } + let butt = dots(vectorcraft_doc::LineCap::Butt); + assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot"); +}