Compare commits
1 Commits
main
...
feat/text-
| Author | SHA1 | Date |
|---|---|---|
|
|
e626f23925 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue