Morph: shapes move, resize and blend; Words and Characters options morph text (#5)

* Morph: shapes move, resize and blend in the slide show

Morph used to fall back to a plain crossfade in the show: morph_pairs and
morph_xfrm existed, but nothing rendered frames with them.

- anim: morph_frame builds a frame from two slides. Paired shapes move along
  morph_xfrm (eased); a pure fill colour change blends, other content changes
  cross-fade on the same moving box. Unpaired shapes fade out/in, shapes with
  entrance animations or hidden media stay out of the frame.
- render: render_blend draws such a frame in one pass: both backdrops
  (background, master and layout graphics) cross-fade, every shape resolves
  against the slide it comes from, so placeholders keep their own layout.
  Renderer::slide is split into canvas/fields/backdrop to share the code.
- ui-egui: the show renders morph frames with these, crossfade otherwise.

generated with Claude Code

* Contributors: add my own entry (alexanderalber)

generated with Claude Code

* Morph: Words and Characters options morph text token by token

The transition option (objects/words/characters) was stored and round-tripped
but ignored by the show. With Words or Characters, paired shapes whose text
differs now morph their text instead of cross-fading it:

- text: GlyphRun records per glyph the paragraph character it draws, so
  glyphs can be grouped into words or characters.
- anim: morph_frame reads the option and lists such pairs in MorphFrame::text.
- render: morph_text draws them. Shared words (or characters) travel from their
  place in the old text to their place in the new one and grow or shrink to the
  new size; a different look cross-fades on the way. The remaining tokens fade
  while following a scale and shift fitted to the shared ones, so a line moves
  and rearranges as one. Rotated text falls back to a cross-fade.
  draw_layout_with's glyph drawing moved into draw_glyph for reuse.
- ui-egui: the show passes the pairs to render_blend.

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
alexanderalber
2026-10-09 10:32:41 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 89d23ffa85
commit fa9a94a1c0
10 changed files with 715 additions and 56 deletions
+3
View File
@@ -22,6 +22,9 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
+3 -2
View File
@@ -3,7 +3,8 @@
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
//! per-paragraph) [`AnimState`] at any point of the show.
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
@@ -26,7 +27,7 @@ pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
pub use morph::{morph_pairs, morph_xfrm};
pub use morph::{MorphFrame, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{ShowAction, ShowState, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
+133 -2
View File
@@ -1,8 +1,10 @@
//! Morph: matching shapes between two slides and interpolating their boxes.
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use deckcraft_model::{Shape, ShapeId, Slide, Xfrm};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
use crate::clampf;
use crate::easing::smooth;
fn text_of(s: &Shape) -> String {
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
@@ -91,3 +93,132 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
flip_v: if late { b.flip_v } else { a.flip_v },
}
}
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
#[derive(Clone, Debug, Default)]
pub struct MorphFrame {
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
/// its placeholder, theme and style against that slide).
pub shapes: Vec<(Shape, bool)>,
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
pub opacity: Vec<(ShapeId, f64)>,
pub mix: f64,
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
/// characters instead of cross-fading (the renderer's `TextMorph`).
pub text: Vec<MorphText>,
}
/// Two frame shapes whose texts morph by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MorphText {
/// Frame shape ids: from the old slide, from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// The boxes of the two shapes on their own slides.
pub from: Xfrm,
pub to: Xfrm,
pub chars: bool,
}
impl MorphFrame {
/// Opacity of shape `id` of the frame.
pub fn opacity_of(&self, id: ShapeId) -> f64 {
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
}
}
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
}
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
let ctx = ctx?;
match resolve::fill(ctx, s) {
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
_ => None,
}
}
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
match ctx {
Some(c) => Some(resolve::xfrm(c, s)),
None => s.xfrm,
}
}
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
fn content(s: &Shape) -> Shape {
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
}
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
/// colour blends, otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
let ca = Ctx::for_slide(pres, a);
let cb = Ctx::for_slide(pres, b);
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
let mut next = 1u32;
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
let mut f = MorphFrame { mix: s, ..Default::default() };
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
let mut text = Vec::new();
let mut push = |mut sh: Shape, old: bool, op: f64| {
let id = ShapeId(next);
sh.id = id;
next = next.saturating_add(1);
if op < 1.0 {
f.opacity.push((id, op));
}
f.shapes.push((sh, old));
id
};
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
}
continue;
};
let x = match (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) {
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
(_, xb) => xb,
};
if content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
}
push(m, false, 1.0);
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
if by_text
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
{
text.push(MorphText { old, new, from, to, chars });
}
}
}
f.text = text;
f
}
+73
View File
@@ -543,6 +543,79 @@ fn morph_xfrm_interpolates() {
assert!(m.rot.is_finite());
}
fn solid(s: Shape, r: u8) -> Shape {
Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s }
}
#[test]
fn morph_frame_moves_blends_and_fades() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") };
a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")];
let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") };
b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")];
let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4));
assert!((f.mix - 0.5).abs() < 1e-9);
// Gone fades out, Box moves halfway and blends red, New fades in, Later stays out.
let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect();
assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]);
let m = &f.shapes[1].0;
assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9);
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
assert_eq!(f.opacity_of(m.id), 1.0);
assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9);
// Ids are fresh and unique.
let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect();
ids.dedup();
assert_eq!(ids.len(), 3);
assert!(ids.iter().all(|i| i.0 > 4));
// At the start only the old slide shows.
let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4));
assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New"));
assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0);
}
#[test]
fn morph_frame_cross_fades_changed_content() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }];
let f = morph_frame(&p, &a, &b, 0.25, &|_| false);
// The new text under the fading old one, both on the moving box.
assert_eq!(f.shapes.len(), 2);
assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false));
assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true));
let s = f.mix;
assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9);
}
#[test]
fn morph_frame_lists_texts_for_words_and_characters() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }];
for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] {
b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() });
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert_eq!(f.text.first().map(|m| m.chars), want, "{option}");
if let Some(m) = f.text.first() {
assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id));
assert_eq!(m.from, a.shapes[0].xfrm.unwrap());
}
}
// Same text: one shape moves, nothing to morph.
b.shapes[0].text = a.shapes[0].text.clone();
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
}
fn pres(n: usize) -> Presentation {
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
}
+1 -1
View File
@@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R {
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
/// frames with [`crate::morph_pairs`]).
/// frames with [`crate::morph_frame`]).
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
let t = clampf(t, 0.0, 1.0, 0.0);
let v = if t >= 1.0 {
+141 -49
View File
@@ -9,6 +9,7 @@
mod chart;
mod images;
mod morph_text;
mod paint;
mod placed;
mod table;
@@ -29,6 +30,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::{RenderContext, Resources, peniko};
pub use images::decode as decode_image;
pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
@@ -241,6 +243,26 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
}
}
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
/// characters ([`TextMorph`]), on top of the later shape of the pair. `opts.state` applies to
/// `shapes` only.
pub fn render_blend(pres: &Presentation, old: usize, new: usize, mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], opts: &RenderOpts) -> Image {
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, opts))
})) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
render_slide(pres, new, opts)
}
}
}
/// Render a layout (Slide Master view thumbnail and canvas).
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
@@ -371,46 +393,56 @@ struct Frame<'a> {
}
impl Renderer {
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(
w,
h,
vello_cpu::RenderSettings {
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
..Default::default()
},
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
);
if let Some(c) = opts.clear {
ctx.set_paint(color(c, 1.0));
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
}
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
let fields = SlideFields {
(ctx, w, h, view)
}
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
SlideFields {
num: pres.slide_number(index),
date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()),
footer: pres.header_footer.footer_text.clone(),
};
let f = Frame { pres, opts, fields };
}
}
/// Background plus the master and layout graphics the slide shows.
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
self.background(ctx, f, rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(f.pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &lctx, s, view, 0);
}
}
}
}
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
if let Some(rctx) = Ctx::for_slide(pres, slide) {
self.background(&mut ctx, &f, &rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &lctx, s, view, 0);
}
}
}
self.backdrop(&mut ctx, &f, &rctx, slide, view);
for s in &slide.shapes {
self.shape(&mut ctx, &f, &rctx, s, view, 0);
}
@@ -418,6 +450,60 @@ impl Renderer {
self.finish(ctx, w, h)
}
#[allow(clippy::too_many_arguments)]
fn blend(
&mut self,
pres: &Presentation,
old: (&Slide, usize),
new: (&Slide, usize),
mix: f64,
shapes: &[(Shape, bool)],
text: &[TextMorph],
opts: &RenderOpts,
) -> Image {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts };
if mix < 1.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) };
self.backdrop(&mut ctx, &f, &co, old.0, view);
}
if mix > 0.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) };
let layered = mix < 1.0;
if layered {
ctx.push_layer(None, None, Some(mix as f32), None, None);
}
self.backdrop(&mut ctx, &f, &cn, new.0, view);
if layered {
ctx.pop_layer();
}
}
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
let mut drawn: Vec<ShapeId> = Vec::new();
for (s, from_old) in shapes {
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
self.shape(&mut ctx, f, c, s, view, 0);
continue;
};
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
drawn.push(s.id);
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
continue;
}
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
}
}
self.finish(ctx, w, h)
}
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -971,30 +1057,8 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
let alpha = run.alpha * ps.opacity;
let k = run.size / run.face.upem.max(1.0);
ctx.set_paint(color(run.color, alpha));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
ctx.set_transform(gm);
if alpha > 0.0 && run.color.a > 0 {
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, ps.opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
ctx.set_paint(color(run.color, alpha));
}
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1002,6 +1066,34 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// 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) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
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 };
ctx.set_transform(at * skew * Affine::scale(k));
if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
let rctx = Ctx::for_slide(pres, slide)?;
+227
View File
@@ -0,0 +1,227 @@
//! Morph by words or characters: the words (or characters) two texts share travel from their place
//! in the old text to their place in the new one, growing or shrinking to the new size; the rest
//! of the old text fades out and the rest of the new one fades in, both riding the moving box.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::Ctx;
use deckcraft_model::{Shape, ShapeId};
use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout};
use kurbo::{Affine, Point, Vec2};
use vello_cpu::RenderContext;
use crate::{color, draw_glyph, draw_layout, shape_geometry};
/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextMorph {
/// The frame shape from the old slide and the one from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// Their boxes on their own slides: where the shared words start and end.
pub from: Xfrm,
pub to: Xfrm,
/// Characters instead of words.
pub chars: bool,
/// Progress, 0..1 (already eased).
pub t: f64,
}
/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph.
struct Token {
key: String,
glyphs: Vec<(usize, usize)>,
origin: Point,
run: usize,
}
/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets).
fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new();
let mut loose = Vec::new();
for (ri, run) in l.runs.iter().enumerate() {
for gi in 0..run.glyphs.len() {
match run.cells.get(gi) {
Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)),
None => loose.push((ri, gi)),
}
}
}
cells.sort_by_key(|c| (c.0, c.1, c.3, c.4));
let mut out: Vec<Token> = Vec::new();
let mut last: Option<(usize, usize)> = None;
for (para, ci, ch, ri, gi) in cells {
if ch.is_whitespace() {
last = None;
continue;
}
let same_char = last == Some((para, ci));
let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci));
let extend = same_char || (!chars && next_char);
match out.last_mut() {
Some(tok) if extend => {
if !same_char {
tok.key.push(ch);
}
tok.glyphs.push((ri, gi));
}
_ => {
let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO);
out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri });
}
}
last = Some((para, ci));
}
(out, loose)
}
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
let body = s.text.as_ref().filter(|b| !b.is_empty())?;
let rect = shape_geometry(s, x.w, x.h).text_rect;
Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false }))
}
fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> {
let run = l.runs.get(ri)?;
let g = run.glyphs.get(gi)?;
Some((run, g.0, Point::new(g.1, g.2)))
}
fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {
a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both
/// frame shapes share at this moment, `view` the slide-to-pixels transform.
pub(crate) fn draw(
ctx: &mut RenderContext,
(ca, a, fa): (&Ctx, &Shape, &dyn Fields),
(cb, b, fb): (&Ctx, &Shape, &dyn Fields),
m: &TextMorph,
now: Xfrm,
view: Affine,
) {
let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 };
let la = layout(ca, a, m.from, fa);
let lb = layout(cb, b, m.to, fb);
let mt = view * now.affine();
// Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box.
let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0);
if rotated || la.is_none() || lb.is_none() {
for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] {
if let Some(l) = l
&& alpha > 0.001
{
ctx.push_layer(None, None, Some(alpha as f32), None, None);
draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt);
ctx.pop_layer();
}
}
return;
}
let (Some(la), Some(lb)) = (la, lb) else { return };
let (ta, loose_a) = tokens(&la, m.chars);
let (tb, loose_b) = tokens(&lb, m.chars);
let pairs = matches(&ta, &tb);
let (ma, mb) = (view * m.from.affine(), view * m.to.affine());
let c = mt.as_coeffs();
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
let mut moved_a = vec![false; ta.len()];
// Shared tokens travel.
for (j, i) in pairs.iter().enumerate() {
let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue };
let Some(tok_a) = ta.get(i) else { continue };
if let Some(f) = moved_a.get_mut(i) {
*f = true;
}
let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue };
let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
let same = same_look(ra, rb);
let alpha_a = if same { 1.0 } else { 1.0 - t };
let sa = lerp(1.0, ratio, t);
for &g in &tok_a.glyphs {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin);
draw_glyph(ctx, run, gid, at, alpha_a);
}
}
if !same {
let sb = lerp(1.0 / ratio, 1.0, t);
for &g in &tok_b.glyphs {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin);
draw_glyph(ctx, run, gid, at, t);
}
}
}
}
// The rest fades while following the shared tokens: they define a scale and shift from the old
// text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as
// one. With nothing shared it rides the moving box.
let shared: Vec<(Point, Point, f64)> = pairs
.iter()
.enumerate()
.filter_map(|(j, i)| {
let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?);
let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
Some((ma * tok_a.origin, mb * tok_b.origin, ratio))
})
.collect();
let n = shared.len() as f64;
let s = shared.iter().map(|x| x.2).sum::<f64>() / n;
let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n;
let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() {
// Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t).
let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t));
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
(a * ma, b * mb)
} else {
(mt, mt)
};
let unmatched_a =
ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_a.chain(loose_a) {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t);
}
}
let unmatched_b =
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_b.chain(loose_b) {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
}
}
// Underlines, strikethroughs and highlights fade with their text.
for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] {
for d in &l.decos {
ctx.set_transform(place);
ctx.set_paint(color(d.color, alpha));
ctx.fill_rect(&d.rect);
}
}
}
#[cfg(test)]
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
+83
View File
@@ -157,3 +157,86 @@ fn custom_path_parse() {
assert!(p.elements().len() >= 5);
assert!(parse_path("").elements().is_empty());
}
#[test]
fn blend_ends_match_the_slides_and_mixes_backdrops() {
let sh = Shape {
id: ShapeId(500),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![sh.clone()]);
let mut dark = (*p.slides[0]).clone();
dark.background =
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
p.slides.push(std::sync::Arc::new(dark));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let shapes = vec![(sh, false)];
// At the ends the frame is the slide itself; the shape stays opaque throughout.
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &o).pixels, render_slide(&p, 1, &o).pixels);
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &o);
let px = mid.pixel(5, 5);
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
// Hostile input: bad indices and NaN never panic.
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &o).width, 0);
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &o);
}
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
let mut t = TextBody::from_text(text);
for p in &mut t.paragraphs {
for r in &mut p.runs {
r.props.size = Some(40.0);
}
}
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
}
#[test]
fn morph_text_tokens_are_words_or_characters() {
let p = Presentation::default();
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
let l = deckcraft_text::layout(
&rctx,
&s,
s.text.as_ref().unwrap(),
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
}
#[test]
fn morph_text_ends_match_the_slides() {
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let frame = |t: f64| {
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
sb.xfrm = Some(x);
sa.xfrm = Some(x);
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &o)
};
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
// Halfway the shared words are on their way: ink between the two texts' rows.
let mid = frame(0.5);
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
assert!(ink > 50, "ink {ink}");
}
+5
View File
@@ -26,6 +26,8 @@ pub struct GlyphRun {
pub color: Rgba,
/// Glyph id and pen position of its origin (baseline), in shape-local points.
pub glyphs: Vec<(u32, f64, f64)>,
/// Per glyph: the paragraph character it draws (index, char); empty for bullets.
pub cells: Vec<(usize, char)>,
/// Draw a heavier stroke because the family has no bold face.
pub fake_bold: bool,
/// Slant because the family has no italic face.
@@ -849,6 +851,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
.into_iter()
.map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy))
.collect(),
cells: vec![],
fake_bold: false,
fake_italic: false,
outline: None,
@@ -881,6 +884,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
size,
color: st.color,
glyphs: vec![],
cells: vec![],
fake_bold: st.fake_bold,
fake_italic: st.fake_italic,
outline: st.outline,
@@ -897,6 +901,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
for (gid, gx, gy) in &cell.glyphs {
let k = if st.baseline != 0.0 { 0.66 } else { 1.0 };
r.glyphs.push((*gid, x + gx * k, base_y + gy));
r.cells.push((*ci, cell.ch));
}
}
let adv = line.caret_x.get(ci - line.start + 1).copied().unwrap_or(x) - line.caret_x.get(ci - line.start).copied().unwrap_or(x);
+46 -2
View File
@@ -51,6 +51,9 @@ struct Key {
struct Transition {
old: Option<TextureHandle>,
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
from: Option<usize>,
morph: Option<TextureHandle>,
start: f64,
kind: String,
option: String,
@@ -119,8 +122,9 @@ impl Show {
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
.unwrap_or(("none".into(), String::new(), 0.0));
let old = self.tex.as_ref().map(|(_, t)| t.clone());
let from = self.tex.as_ref().map(|(k, _)| k.slide);
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
Some(Transition { old, start: now, kind, option, dur })
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
} else {
None
};
@@ -389,7 +393,47 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
Some(tex)
};
let mut drew = false;
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur);
let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None };
let morph = match (&show.trans, tl) {
(Some(tr), Some(tl)) => {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
let hidden_b = |id: ShapeId| {
let st = state_for(&tl, id, 0, 0.0);
!st.visible || st.opacity <= 0.0 || hidden.contains(&id)
};
tr.from
.and_then(|i| doc.slides.get(i).map(|a| (i, a)))
.zip(doc.slides.get(idx))
.map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b)))
}
_ => None,
};
if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
let threads =
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
let opts =
RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };
let text: Vec<deckcraft_render::TextMorph> = m
.text
.iter()
.map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
.collect();
let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &opts);
let ci = crate::textures::to_color_image(&img, false);
let frame = match tr.morph.take() {
Some(mut h) => {
h.set(ci, egui::TextureOptions::LINEAR);
h
}
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
};
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
tr.morph = Some(frame);
drew = true;
ctx.request_repaint();
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
if p < 1.0 {
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {