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:
parent
604c20ed90
commit
c20b7deec5
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<f32> = 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<f32> = d.pattern.iter().map(|v| *v as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
pat.extend(pat.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>().join(" ")));
|
||||
if d.offset != 0.0 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue