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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 06:16:39 -04:00
parent 4773e4ee0f
commit aafce0d2ef
2 changed files with 76 additions and 9 deletions

View File

@ -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<Rgba>, 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<Rgba>,
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<Rgba>, 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<Rgba>, 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<Rgba>
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<Rgba>
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<Rgba>
}
}
ctx.set_transform(m);
ctx.set_paint(color(col, 1.0));
set_paint(ctx);
ctx.fill_path(&p);
}
}

View File

@ -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:?}");
}