diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 33bec34b..3296a1c2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { + 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 { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 365178aa..18822467 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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 = - 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) -> 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 = + 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) { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 15f70167..d739e775 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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)); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d91e146d..32bfdc42 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -245,7 +245,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> 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), };