Merge pull request #1619 from marcoarnulfo/fix/ai-zero-length-dots

Strokes: a zero-length subpath draws its round or projecting cap as a dot
This commit is contained in:
BFlat 2026-10-13 13:27:11 +07:00 committed by GitHub
commit 67dcfd4fc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 63 additions and 1 deletions

View File

@ -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<Dot> {
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

View File

@ -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");
}