diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs index c9d8b6acb..a63dd44a3 100644 --- a/crates/render/src/stroke.rs +++ b/crates/render/src/stroke.rs @@ -6,6 +6,14 @@ use super::{ pdf_layers, peniko, vello_cpu, }; +/// `stroke` as round dots `weight` wide, one every `2 × weight`. Kurbo's stroker drops +/// zero-length dashes, so each dot is a dash a hair long, centred where the zero-length dash +/// would be (so the path's ends get dots too). +pub(crate) fn dotted(stroke: kurbo::Stroke, weight: f64) -> kurbo::Stroke { + let dot = weight * 1e-3; + stroke.with_dashes(dot / 2.0, [dot, weight * 2.0 - dot]).with_caps(kurbo::Cap::Round) +} + impl Renderer { pub(crate) fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { self.draw_stroke_painted(ctx, f, it, bp, xf); @@ -37,9 +45,7 @@ impl Renderer { StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => { stroke = stroke.with_dashes(0.0, pattern.iter().copied()) } - StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => { - stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round) - } + StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => stroke = dotted(stroke, st.weight), _ => {} } ctx.set_transform(f.view * xf); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0ea80e7fe..5f522a1e2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -695,3 +695,37 @@ fn inline_objects_are_painted_under_the_text() { (a.y as u32..b.y as u32).flat_map(|y| (a.x as u32..b.x as u32).map(move |x| (x, y))).filter(|&(x, y)| image.pixel(x, y)[0] < 100).count(); assert!(dark > 20, "the line's glyphs show over the box: {dark} dark pixels"); } + +#[test] +fn dotted_strokes_draw_every_dot() { + // A 4 pt dotted line 96 pt long: dots every 8 pt, 13 of them. + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let path = designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 400.0), designcraft_geom::Point::new(196.0, 400.0)); + let mut line = Item::new(id, lid, designcraft_doc::Shape::GraphicLine, path); + line.stroke = designcraft_doc::Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + weight: 4.0, + kind: designcraft_doc::StrokeType::Dotted, + ..Default::default() + }; + d.insert_item(SpreadRef::Doc(0), line, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + for i in 0..13 { + let x = 100 + i * 8; + assert!(img.pixel(x, 400)[0] < 128, "dot {i} at x={x}: {:?}", img.pixel(x, 400)); + if i < 12 { + assert_eq!(img.pixel(x + 4, 400), [255, 255, 255, 255], "gap after dot {i}"); + } + } + // Table and paragraph rules: one round dot per period. + let stroke = text::cell_stroke(&designcraft_doc::StrokeType::Dotted, 4.0, 96.0); + let edge = kurbo::Line::new((0.0, 0.0), (96.0, 0.0)); + let outline = kurbo::stroke(kurbo::Shape::path_elements(&edge, 0.01), &stroke, &kurbo::StrokeOpts::default(), 0.01); + let dots = outline.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(dots, 13); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index d10fd30f9..51271b577 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -613,9 +613,7 @@ pub(crate) fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64, lengt StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => { st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()) } - StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, weight * 2.0], length) => { - st.with_dashes(0.0, [0.0, weight * 2.0]).with_caps(kurbo::Cap::Round) - } + StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, weight * 2.0], length) => super::stroke::dotted(st, weight), _ => st, } }