mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down (flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation still applies, and the same holds for shapes inside flipped groups. render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret all use it, so a click lands on the character drawn under it. Co-authored-by: lloyd <lloyd@stackworx.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
231 lines
9.7 KiB
Rust
231 lines
9.7 KiB
Rust
//! 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, text_transform};
|
|
|
|
/// 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);
|
|
// Flipped boxes keep their text readable, as when drawn (see `text_transform`).
|
|
let (tra, trb) = (shape_geometry(a, m.from.w, m.from.h).text_rect, shape_geometry(b, m.to.w, m.to.h).text_rect);
|
|
let (ma, mb) = (text_transform(view * m.from.affine(), tra, 0.0), text_transform(view * m.to.affine(), trb, 0.0));
|
|
let (mta, mtb) = (text_transform(mt, tra, 0.0), text_transform(mt, trb, 0.0));
|
|
let c = mta.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 {
|
|
(mta, mtb)
|
|
};
|
|
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()
|
|
}
|