diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3296a1c..9a63af7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -248,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { return; } let (e, _) = resolve::effects(&rctx, s); - if e.is_some_and(|e| !e.is_empty()) { + if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { any = true; } }); @@ -261,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { any } +/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's +/// text styles it inherits)? Such text is blurred, which needs filters. +fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { + use deckcraft_model::text::{ListStyle, RunProps}; + let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); + let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); + let Some(t) = &s.text else { return false }; + t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) + || styled(&t.list_style) + || styled(&rctx.master.title_style) + || styled(&rctx.master.body_style) + || styled(&rctx.master.other_style) +} + /// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a /// blank image (logged), never a crash. pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { @@ -1124,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, for d in l.decos.iter().filter(|d| d.behind) { deco(ctx, d); } + // Text shadows, then glows, under all the text. + for pass in [TextEffect::Shadow, TextEffect::Glow] { + for run in &l.runs { + let ps = para(run.para); + if ps.visible && ps.opacity > 0.001 { + text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); + } + } + } for run in &l.runs { if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { continue; @@ -1176,6 +1199,49 @@ fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) } +#[derive(Clone, Copy, PartialEq)] +enum TextEffect { + Shadow, + Glow, +} + +/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. +fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { + let Some(fx) = run.effects else { return }; + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let px = scale_of(m); + let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { + (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), + // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r + // past the glyph's edge and fades out by about two thirds of r. + (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), + _ => return, + }; + let alpha = (c.a as f64 / 255.0) * opacity; + if alpha <= 0.0 { + return; + } + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + for (gid, x, y) in &run.glyphs { + let path = glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + // The offset is in slide space: a shadow falls the same way whatever the text's rotation. + let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k); + ctx.set_transform(at); + ctx.fill_path(&path); + if grow > 0.0 { + ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + ctx.pop_layer(); +} + /// 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); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d739e77..172fccb 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -701,3 +701,52 @@ fn a_text_gradient_starts_over_on_every_line() { assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band)); } } + +/// A text box of big black "IIII" with `set` applied to its run. +fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation { + let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("IIII"); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(100.0); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))); + set(props); + sh.text = Some(t); + deck_with(vec![sh]) +} + +#[test] +fn text_glow_and_shadow_are_drawn() { + let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count(); + let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120; + let plain = effect_text(|_| {}); + assert!(!needs_filters(&plain, &plain.slides[0])); + let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(count(&img, &green), 0); + // Glow: a green halo hugging the letters, needing the blur filter. + let glow = + effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 })); + assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs"); + let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() }); + let halo = count(&img, &green); + assert!(halo > 300, "green halo pixels {halo}"); + // Shadow: grey ink below and right of the letters, where the plain text has none. + let shadow = effect_text(|p| { + p.shadow = Some(deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }), + blur: 2.0, + dist: 12.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }) + }); + let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() }); + let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2]; + assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 32bfdc4..bb98a58 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -57,6 +57,25 @@ pub struct GlyphRun { /// Alpha of the fill (0–1). pub alpha: f64, pub gradient: Option, + /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). + pub effects: Option, +} + +/// A text run's shadow and glow, colours resolved. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RunEffects { + pub shadow: Option, + /// Glow colour (with its alpha) and radius in points. + pub glow: Option<(Rgba, f64)>, +} + +/// An outer shadow: colour (with its alpha), blur radius and offset in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextShadow { + pub color: Rgba, + pub blur: f64, + pub dx: f64, + pub dy: f64, } impl std::fmt::Debug for GlyphRun { @@ -174,6 +193,7 @@ struct Style { outline: Option<(Rgba, f64)>, link: bool, gradient: Option, + effects: Option, /// "Regular", "Bold"… for the slot and fallback faces. style_name: &'static str, /// BCP 47 language (`a:rPr/@lang`), for shaping. @@ -288,6 +308,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl outline, link: r.link.is_some(), gradient, + effects: run_effects(ctx, r, scale), style_name: style_name(bold, italic), lang: r.lang.clone().filter(|l| !l.is_empty()), ea, @@ -494,6 +515,16 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, /// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level, /// script, face and shaping style, each shaped with the whole paragraph as context. +/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`. +fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option { + let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| { + let a = s.dir.to_radians(); + TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale } + }); + let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale)); + (shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow }) +} + fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec>, rtl: bool) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); @@ -1231,6 +1262,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts link: false, alpha: st.alpha, gradient: None, + effects: None, }); } // Group visually consecutive cells by (style, face). @@ -1264,6 +1296,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts link: st.link, alpha: st.alpha, gradient: st.gradient.clone(), + effects: st.effects, }); cur_key = key; }