mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6702ec8878 |
@@ -248,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (e, _) = resolve::effects(&rctx, s);
|
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;
|
any = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -261,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
|||||||
any
|
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
|
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
||||||
/// blank image (logged), never a crash.
|
/// blank image (logged), never a crash.
|
||||||
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
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) {
|
for d in l.decos.iter().filter(|d| d.behind) {
|
||||||
deco(ctx, d);
|
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 {
|
for run in &l.runs {
|
||||||
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
|
||||||
continue;
|
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)
|
(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`.
|
/// 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) {
|
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_with(ctx, run, gid, at, opacity, None);
|
||||||
|
|||||||
@@ -701,3 +701,52 @@ fn a_text_gradient_starts_over_on_every_line() {
|
|||||||
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ pub struct GlyphRun {
|
|||||||
/// Alpha of the fill (0–1).
|
/// Alpha of the fill (0–1).
|
||||||
pub alpha: f64,
|
pub alpha: f64,
|
||||||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
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 {
|
impl std::fmt::Debug for GlyphRun {
|
||||||
@@ -174,6 +193,7 @@ struct Style {
|
|||||||
outline: Option<(Rgba, f64)>,
|
outline: Option<(Rgba, f64)>,
|
||||||
link: bool,
|
link: bool,
|
||||||
gradient: Option<deckcraft_model::style::Gradient>,
|
gradient: Option<deckcraft_model::style::Gradient>,
|
||||||
|
effects: Option<RunEffects>,
|
||||||
/// "Regular", "Bold"… for the slot and fallback faces.
|
/// "Regular", "Bold"… for the slot and fallback faces.
|
||||||
style_name: &'static str,
|
style_name: &'static str,
|
||||||
/// BCP 47 language (`a:rPr/@lang`), for shaping.
|
/// 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,
|
outline,
|
||||||
link: r.link.is_some(),
|
link: r.link.is_some(),
|
||||||
gradient,
|
gradient,
|
||||||
|
effects: run_effects(ctx, r, scale),
|
||||||
style_name: style_name(bold, italic),
|
style_name: style_name(bold, italic),
|
||||||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||||||
ea,
|
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,
|
/// 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.
|
/// 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> {
|
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 db = FontDb::global();
|
||||||
let chars: Vec<char> = text.chars().collect();
|
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,
|
link: false,
|
||||||
alpha: st.alpha,
|
alpha: st.alpha,
|
||||||
gradient: None,
|
gradient: None,
|
||||||
|
effects: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Group visually consecutive cells by (style, face).
|
// 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,
|
link: st.link,
|
||||||
alpha: st.alpha,
|
alpha: st.alpha,
|
||||||
gradient: st.gradient.clone(),
|
gradient: st.gradient.clone(),
|
||||||
|
effects: st.effects,
|
||||||
});
|
});
|
||||||
cur_key = key;
|
cur_key = key;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user