Text: gradient fills draw, starting over on every line (#281)

A gradient on text was read but drawn in its first colour. Each line is now
filled with the whole gradient, spanning that line's text from its top to
its bottom and its first to its last character, as PowerPoint does. The
gradient builder moves out of fill_path into paint::gradient so fills and
text share it (and PowerPoint's two-colour ramp). Gradient colours are
resolved when the text style is made.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 17:51:20 -04:00 committed by GitHub
parent ba56742fa9
commit 1bb86db54a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 141 additions and 30 deletions

View File

@ -1133,8 +1133,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
// A gradient fill starts over on every line and spans that line's text (from its top to
// its bottom, from its first to its last character), as in PowerPoint.
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
for (gid, x, y) in &run.glyphs {
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
let at = m * Affine::translate((*x, *y));
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@ -1142,8 +1158,39 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
l.lines
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
@ -1160,7 +1207,14 @@ pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if alpha > 0.0 && run.color.a > 0 {
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {

View File

@ -35,33 +35,8 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
let grad = match &g.shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
ctx.fill_path(path);
}
Fill::Picture(pf) => {
@ -131,6 +106,38 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
std::borrow::Cow::Owned(out)
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
for s in &mut stops {
s.0 = s.0.clamp(0.0, 1.0);
}
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
match shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
}
}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {

View File

@ -658,3 +658,46 @@ fn colour_glyphs_paint_their_layers() {
// The text-colour glyph is as big as the other square.
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Ink rows (any pixel not white), grouped into the two lines.
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
// The darkest ink in the first rows of each line.
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
// ...and each line ends light.
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}

View File

@ -245,7 +245,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
let (mut color, alpha, gradient) = match &r.fill {
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
Some(Fill::Gradient(g)) => {
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
let mut g = g.clone();
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
}
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
};