From c20b7deec5ed1a161edcea73772aacca800504ea Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 00:00:39 -0700 Subject: [PATCH] Stroke dashes: an invalid dash pattern draws a solid stroke instead of hanging the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/doc/src/appearance.rs | 9 +++++++++ crates/pdf/src/export.rs | 4 ++-- crates/pdf/src/import.rs | 10 +++------- crates/pdf/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/render/src/width.rs | 2 +- crates/svg/src/export.rs | 3 +-- 8 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 91ef1b84..2bf1b626 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -39,6 +39,15 @@ pub struct Dash { pub align_corners: bool, } +impl Dash { + /// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a + /// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the + /// stroke is drawn solid. + pub fn is_dashed(&self) -> bool { + self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Arrowhead { Triangle, diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..aac044e2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -545,8 +545,8 @@ impl Exporter<'_> { } _ => {} } - let dash = st.dash.as_ref().filter(|d| d.pattern.iter().any(|v| *v > 0.0)).map(|d| { - let mut pat: Vec = d.pattern.iter().map(|v| v.max(0.0) as f32).collect(); + 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()); } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ddfefa2a..384e74c0 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -189,13 +189,9 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro kurbo::Join::Bevel => LineJoin::Bevel, }; st.miter_limit = p.miter_limit as f64; - if p.dash_array.iter().any(|v| *v > 0.0) { - st.dash = Some(Dash { - pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), - offset: p.dash_offset as f64 * scale, - align_corners: false, - }); - } + let dash = Dash { pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false }; + // An invalid dash array (a negative value, or all zeros) strokes solid. + st.dash = dash.is_dashed().then_some(dash); st } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 82e21218..b7cb0732 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -427,6 +427,32 @@ fn import_handmade_pdf() { assert!(close(l[2].geometric_bounds().unwrap(), Rect::new(150.0, 270.0, 190.0, 300.0), 1e-6)); } +#[test] +fn import_invalid_dash_array_strokes_solid() { + // A dash array with a negative value is invalid (PDF 32000-1 §8.4.3.6): the line is solid. A valid + // array next to it stays dashed. + let content = "0 0 1 RG 4 w [-5 3] 0 d 10 10 m 90 10 l S [6 2] 1 d 10 50 m 90 50 l S"; + let d = import(&handmade_pdf(content, "[0 0 100 100]")).unwrap(); + let l = leaves(&d); + assert_eq!(l.len(), 2); + let dash = |n: &Node| match &n.appearance.items[..] { + [.., AppearanceItem::Stroke(st)] => st.dash.clone(), + items => panic!("no stroke: {items:?}"), + }; + assert_eq!(dash(l[0]), None); + assert_eq!(dash(l[1]).map(|d| d.pattern), Some(vec![6.0, 2.0])); + // And the exporter never writes an invalid array back. + let mut d2 = doc(100.0, 100.0); + let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + if let Some(AppearanceItem::Stroke(st)) = n.appearance.items.last_mut() { + st.dash = Some(Dash { pattern: vec![-5.0, 3.0], offset: 0.0, align_corners: false }); + } + add(&mut d2, n); + let s = uncompressed(&d2); + assert!(!s.contains("-5 3]") && !s.contains("[0 3]"), "invalid dash written:\n{s}"); +} + #[test] fn import_clip_from_handmade_pdf() { let content = "q 0 0 50 50 re W n 1 0 0 rg 0 0 100 100 re f Q"; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c5f0e4e6..e025272a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 1397f2ba..a9a5bbab 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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>| { + 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:?}"); + } +} diff --git a/crates/render/src/width.rs b/crates/render/src/width.rs index b7901343..5ec8d5e7 100644 --- a/crates/render/src/width.rs +++ b/crates/render/src/width.rs @@ -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 { 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)); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ba71ddca..7e843e42 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -413,8 +413,7 @@ impl Writer<'_> { LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), } if let Some(d) = &s.dash - && !d.pattern.is_empty() - && d.pattern.iter().any(|v| *v > 0.0) + && d.is_dashed() { p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); if d.offset != 0.0 {