diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e6cc5d1..415ec36 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1025,10 +1025,11 @@ impl Renderer { std::borrow::Cow::Owned(moved) }; // The line stops inside its arrowheads, so a wide line doesn't poke out of them. + let whole = path.clone(); if Some(i) == first_stroked && trims != (0.0, 0.0) { path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1)); } - stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke); + stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); } if ends { arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); @@ -1203,7 +1204,7 @@ impl Renderer { for sp in geo.paths.iter().filter(|sp| sp.stroke) { let mut path = sp.path.clone(); path.apply_affine(o.rel * own); - stroke(ctx, rctx, &cast, o.ph, &path, pre * o.base, lw, o.alpha); + stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); } } ctx.pop_layer(); @@ -1326,7 +1327,20 @@ fn blur(sigma: f64) -> Filter { Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) } -fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, path: &BezPath, m: Affine, lw: f64, alpha: f64) { +/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the +/// line belongs to; `path` itself when `None`). +#[allow(clippy::too_many_arguments)] +fn stroke( + ctx: &mut RenderContext, + rctx: &Ctx, + line: &Line, + ph: Option, + path: &BezPath, + extent: Option<&BezPath>, + m: Affine, + lw: f64, + alpha: f64, +) { use deckcraft_model::style::{LineCap, LineJoin}; let cap = match line.cap.unwrap_or_default() { LineCap::Flat => kurbo::Cap::Butt, @@ -1361,10 +1375,7 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa Some(Fill::Solid { color: c }) => { ctx.set_paint(color(rctx.color(c, ph), alpha)); } - Some(Fill::Gradient(g)) => { - let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK); - ctx.set_paint(color(c, alpha)); - } + Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)), _ => ctx.set_paint(color(Rgba::BLACK, alpha)), } // Compound lines: each band stroked along the path moved to its place across the width. @@ -1465,6 +1476,16 @@ fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { out } +/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the +/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs +/// across its width. +fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { + let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); + let bounds = paint::tile_box(bounds, g.tile); + let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); + paint::gradient(&g.shape, stops, bounds, alpha) +} + /// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As /// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a /// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid @@ -1641,6 +1662,19 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option Some(Fill::Solid { color: c }) => rctx.color(c, ph), _ => Rgba::BLACK, }; + // A gradient line's heads take the gradient where they are. + let grad = match &line.fill { + Some(Fill::Gradient(g)) if !g.stops.is_empty() => { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + Some(line_gradient(rctx, g, ph, &moved, lw, 1.0)) + } + _ => None, + }; + let set_paint = |ctx: &mut RenderContext| match &grad { + Some(g) => ctx.set_paint(g.clone()), + None => ctx.set_paint(color(col, 1.0)), + }; for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] { let Some(e) = end else { continue }; if e.kind == "none" { @@ -1683,7 +1717,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option o.line_to(tip - back); o.line_to(base - n * wl / 2.0 - back); ctx.set_transform(m); - ctx.set_paint(color(col, 1.0)); + set_paint(ctx); ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round)); ctx.stroke_path(&o); continue; @@ -1696,7 +1730,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option } } ctx.set_transform(m); - ctx.set_paint(color(col, 1.0)); + set_paint(ctx); ctx.fill_path(&p); } } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 63d381c..b87fd45 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -478,3 +478,36 @@ fn picture_resolution_sizes_tiles() { jpeg.extend_from_slice(b"JFIF\0\x01\x01\x01\x00\x48\x00\x48"); assert_eq!(images::dpi(&jpeg), Some((72.0, 72.0))); } + +#[test] +fn gradient_lines_and_their_heads_take_the_gradient() { + use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; + let blue = Rgba::rgb(0, 0, 255); + let grad = |angle: f64| { + Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(blue) }], + shape: GradientShape::Linear { angle, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + }) + }; + // Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at + // the end blue too. + let mut sh = arrow_line("triangle", 20.0, RED); + if let Some(l) = &mut sh.line { + l.fill = Some(grad(0.0)); + } + let img = render(vec![sh]); + let (start, mid, head) = (img.pixel(102, 100), img.pixel(200, 100), img.pixel(296, 100)); + assert!(start[0] > 200 && start[2] < 60, "{start:?}"); + assert!(mid[0] > 60 && mid[2] > 60, "{mid:?}"); + assert!(head[2] > 200 && head[0] < 60, "head {head:?}"); + // Top to bottom across a horizontal line: the gradient runs over the line's width. + let mut sh = arrow_line("none", 20.0, RED); + if let Some(l) = &mut sh.line { + l.fill = Some(grad(90.0)); + } + let img = render(vec![sh]); + let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109)); + assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}"); +}