Text: shadows and glows are drawn (#283)

Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but
never drawn. The text layout now carries them on each glyph run (colours
resolved, sizes scaled with autofit) and the renderer draws every run's
shadow, then every glow, blurred, under the text. A text glow is solid to
about a third of its radius past the letters and fades out by two thirds,
as measured on PowerPoint's export. needs_filters also looks at text
effects (runs, list styles, master text styles), so they render with the
blur filter.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 17:53:17 -04:00
1 parent 1bb86db54a
commit 2bf62c97e2
3 files changed
+149 -1

No files matched your search

+67 -1
View File
@@ -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<Rect> {
(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);
+49
View File
@@ -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");
}
+33
View File
@@ -57,6 +57,25 @@ pub struct GlyphRun {
/// Alpha of the fill (0–1).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
}
/// A text run's shadow and glow, colours resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunEffects {
pub shadow: Option<TextShadow>,
/// 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<deckcraft_model::style::Gradient>,
effects: Option<RunEffects>,
/// "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<Rgba>) -> 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<RunEffects> {
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<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = 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;
}