From aafce0d2ef96c5b3ec092ca963aa1a1faf24f66a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:16:39 -0400 Subject: [PATCH] Render: gradient lines draw their gradient, heads included A line filled with a gradient drew in its first stop's colour only. As PowerPoint draws it, the gradient is laid out over the box the stroke covers (the outline's bounds grown by half the line width, before the line is cut back under its arrowheads), so a horizontal line's 90 degree gradient runs across its width; arrowheads take the gradient where they sit. Wild synthetic set: gradient lines slide 8.43 -> 0.40; picture with a gradient border 8.54 -> 7.67. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 52 +++++++++++++++++++++++++++------ crates/render/src/tests_wild.rs | 33 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) 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:?}"); +}