#3692: dash patterns too fine or broken to draw are drawn solid

Finding 36. kurbo's dash iterator stops advancing when a dash is so small
that subtracting it from the segment length changes nothing (and loops on
negative entries), so a document dash pattern like [1e-30, 1e-30] hung the
renderer. Before calling kurbo, safe_dashes drops a pattern that is
negative, non-finite, zero-length, or would cut the stroke into more than
100 000 pieces for its path's length bound; the stroke is drawn solid.
kurbo is unchanged; normal patterns dash as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-26 00:44:51 +00:00
co-authored by Claude Opus 5.5
parent 05ee2e0126
commit 2a647661fb
+69 -5
View File
@@ -887,6 +887,44 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath {
p
}
/// Most pieces one dashed stroke may be cut into; past it the stroke is drawn solid. kurbo's dash
/// iterator makes no progress along a segment when a dash is too small next to it (it never ends),
/// and every piece is more stroke to build.
const MAX_DASH_PIECES: f64 = 100_000.0;
/// `dashes` to stroke a path at most `len` long with, or none (solid) when they can't be drawn: a
/// negative or non-finite length, a pattern that adds up to nothing, or more than
/// [`MAX_DASH_PIECES`] pieces along the path.
fn safe_dashes(dashes: impl IntoIterator<Item = f64>, len: f64) -> Vec<f64> {
let d: Vec<f64> = dashes.into_iter().take(MAX_DASH_PIECES as usize + 1).collect();
let period: f64 = d.iter().sum();
let ok = d.iter().all(|v| v.is_finite() && *v >= 0.0) && period > 0.0 && period.is_finite() && len.is_finite();
// Each repeat of the pattern is up to `d.len()` pieces; a partial repeat at each end counts too.
if ok && (len / period + 2.0) * d.len() as f64 <= MAX_DASH_PIECES { d } else { Vec::new() }
}
/// An upper bound on `path`'s length: its control polygon's (a curve is no longer than it).
fn path_len_bound(path: &BezPath) -> f64 {
let (mut len, mut at, mut start) = (0.0, kurbo::Point::ZERO, kurbo::Point::ZERO);
for el in path.elements() {
let pts: &[kurbo::Point] = match el {
kurbo::PathEl::MoveTo(p) => {
(at, start) = (*p, *p);
continue;
}
kurbo::PathEl::LineTo(p) => std::slice::from_ref(p),
kurbo::PathEl::QuadTo(a, b) => &[*a, *b],
kurbo::PathEl::CurveTo(a, b, c) => &[*a, *b, *c],
kurbo::PathEl::ClosePath => std::slice::from_ref(&start),
};
for p in pts {
len += at.distance(*p);
at = *p;
}
}
len
}
/// Rasterize one display item with `view` mapping its current point coordinates to pixels.
/// `visible` is a culling area in those point coordinates; nested clips intersect it and turns
/// transform it into the child's coordinates. Shape and path strokes apply their stored caps,
@@ -915,10 +953,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
// every end, blobbing over the neighbouring border at a table's corners (#2892). Wavy
// underlines keep round ends, as the PDF draws them.
let mut st = kurbo::Stroke::new(w).with_caps(kurbo::Cap::Butt);
if let Some(d) = stroke.dashes() {
st = st.with_dashes(0.0, d.iter().map(|k| f64::from(*k) * w));
}
let (x0, y0, x1, y1) = (*x0 as f64, *y0 as f64, *x1 as f64, *y1 as f64);
if let Some(d) = stroke.dashes() {
st = st.with_dashes(0.0, safe_dashes(d.iter().map(|k| f64::from(*k) * w), (x1 - x0).hypot(y1 - y0)));
}
let line = |ctx: &mut RenderContext, ox: f64, oy: f64| {
let mut p = BezPath::new();
p.move_to((x0 + ox, y0 + oy));
@@ -1094,7 +1132,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
// Width 0 (or none) is a hairline.
let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
ctx.set_paint(color(opts.ink(*s), 1.0));
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))));
ctx.stroke_path(&path);
}
}
@@ -1185,7 +1223,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.set_stroke(
kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64)
.with_caps(screen_cap(*cap))
.with_dashes(0.0, dash.iter().map(|v| f64::from(*v))),
.with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))),
);
ctx.stroke_path(&path);
}
@@ -2579,6 +2617,32 @@ mod tests {
assert_eq!(image.pixel(832, 800), [255, 255, 255, 255], "inside the first gap");
}
/// Dash lengths too small to cut the path make kurbo's dash iterator spin without moving along
/// it (and every piece is more stroke to build): such a pattern is drawn solid, as are negative
/// and non-finite ones. A normal dash pattern still dashes.
#[test]
fn hostile_dash_patterns_draw_solid_and_finish() {
use wordcraft_doc::graphic::PathSeg;
let line = |dash: Vec<f32>| Draw::Path {
segs: vec![PathSeg::Move(10.0, 20.0), PathSeg::Line(90.0, 20.0)],
fill: None,
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 2.0,
dash,
};
let start = std::time::Instant::now();
for dash in [vec![1e-30, 1e-30], vec![1e-30], vec![-1.0, 2.0], vec![f32::NAN, 1.0], vec![f32::INFINITY, 1.0], vec![0.0, 0.0]] {
let img = render_items(&Document::new(), &[line(dash.clone())], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default());
assert!(img.pixel(50, 20)[3] > 200, "{dash:?} draws solid");
}
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
let dashed = render_items(&Document::new(), &[line(vec![10.0, 10.0])], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default());
assert!(dashed.pixel(15, 20)[3] > 200 && dashed.pixel(25, 20)[3] == 0, "a normal pattern dashes");
assert!(safe_dashes([3.0, 1.0], 1000.0).len() == 2);
assert!(safe_dashes([1e-3, 1e-3], 1000.0).is_empty(), "a million pieces");
assert!(safe_dashes(std::iter::repeat_n(1.0, 200_000), 1.0).is_empty(), "too many entries");
}
#[test]
fn flowchart_or_spokes() {
let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0);