Stroke dashes: an invalid dash pattern draws a solid stroke instead of hanging the renderer

A dash pattern with a negative value (for example [-5, 3], from `stroke.set` or a PDF `d`
operator) was handed to the dasher whenever any value was positive. When the values sum to zero
or less the dasher never advances, so rendering the document hung forever (canvas, screenshot,
PNG/JPEG export, `vectorcraft-cli convert`).

PDF (32000-1 §8.4.3.6) and SVG (`stroke-dasharray`) both define such a pattern as invalid and the
stroke as solid. `Dash::is_dashed` now says whether a pattern draws dashes (finite, non-negative,
not all zero), and the renderer, the width-profile outline, the SVG and PDF exporters and the PDF
importer use it, so an invalid pattern is drawn, written and read back as a solid stroke everywhere
(the PDF exporter used to clamp negatives to 0, writing zero-length dashes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
storybored379
2026-10-04 01:25:17 -07:00
co-authored by Claude Opus 5.5
parent 604c20ed90
commit c20b7deec5
8 changed files with 69 additions and 13 deletions
+1 -1
View File
@@ -669,7 +669,7 @@ impl Renderer {
})
.with_miter_limit(st.miter_limit);
if let Some(d) = &st.dash
&& d.pattern.iter().any(|v| *v > 0.0)
&& d.is_dashed()
{
let mut pat = d.pattern.clone();
if pat.len() % 2 == 1 {
+26
View File
@@ -322,3 +322,29 @@ fn blend_mode_on_a_single_fill_is_exact_without_a_layer() {
assert_eq!(img.pixel(10, 50), [255, 0, 0, 255]);
}
}
#[test]
fn invalid_dash_pattern_strokes_solid() {
// A negative dash value makes the pattern invalid (PDF `d`, SVG `stroke-dasharray`): the stroke
// is drawn solid. [-5, 3] used to send the dasher into an endless loop.
let line = |dash: Option<Vec<f64>>| {
let mut d = Document::new(100.0, 100.0);
let mut bp = BezPath::new();
bp.move_to((10.0, 50.0));
bp.line_to((90.0, 50.0));
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), 6.0));
n.appearance.stroke_mut().unwrap().dash = dash.map(|pattern| vectorcraft_doc::Dash { pattern, ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
};
let solid = render(&line(None));
for pattern in [vec![-5.0, 3.0], vec![3.0, -5.0], vec![4.0, f64::NAN], vec![0.0, 0.0]] {
let d = line(Some(pattern.clone()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(render(&d)).ok());
let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish"));
assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}");
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ use vectorcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
/// The outline to fill instead of stroking, when `st` has a width profile (and no dashes).
pub(crate) fn outline_for(bp: &BezPath, st: &StrokeLayer, tol: f64) -> Option<BezPath> {
let profile = st.profile.as_ref()?;
if st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0)) {
if st.dash.as_ref().is_some_and(|d| d.is_dashed()) {
return None;
}
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));