mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 06:57:04 -06:00
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:
co-authored by
Brandon Thomas
parent
89d23ffa85
commit
fa9a94a1c0
@@ -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,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user