Skip to main content

rpptx_render/
timeline.rs

1//! Composition of evaluated slide frames and transitions.
2
3use oxml_layout::{
4    Diagnostic, FontManager, GroupElement, PageFrame, Path, PathCommand, PathElement,
5    PositionedElement, Rect, Transform,
6};
7use rpptx_layout::timeline::{EvaluatedTransition, ResolvedTimelineSlide};
8use rpptx_layout::{ResolvedGeometry, ResolvedShape, ResolvedSlideTextDirections};
9use rpptx_oxml::timing::TransitionEffect;
10
11use crate::{
12    RenderInput, RenderInputError, layout_resolved_slide_with_fonts_text_directions_and_states,
13};
14
15/// One composed page plus stable approximation diagnostics.
16#[derive(Clone, Debug)]
17pub struct TimelinePageFrame {
18    pub page: PageFrame,
19    pub diagnostics: Vec<Diagnostic>,
20}
21
22/// Lower one evaluated slide through the existing deterministic shape and text path.
23pub fn layout_timeline_slide_deterministic(
24    input: &RenderInput,
25    index: usize,
26    timeline: &ResolvedTimelineSlide,
27    text_directions: Option<&ResolvedSlideTextDirections>,
28) -> Result<PageFrame, RenderInputError> {
29    let mut fonts =
30        FontManager::new_deterministic().map_err(|error| RenderInputError::TextLayout {
31            detail: error.to_string(),
32        })?;
33    fonts.load_additional_fonts(&input.fonts);
34    layout_timeline_slide_with_font_manager(input, index, timeline, text_directions, &mut fonts)
35}
36
37/// Internal facade hook for `rpptx` prepared timeline rendering.
38///
39/// `fonts` must be the same manager that resolved and shaped `timeline`,
40/// including any dynamic fallback labels. Rasterization must use the font data
41/// retained by that manager so every [`oxml_layout::FontId`] remains coherent.
42#[doc(hidden)]
43pub fn layout_timeline_slide_with_font_manager(
44    input: &RenderInput,
45    index: usize,
46    timeline: &ResolvedTimelineSlide,
47    text_directions: Option<&ResolvedSlideTextDirections>,
48    fonts: &mut FontManager,
49) -> Result<PageFrame, RenderInputError> {
50    if index >= input.slides.len() {
51        return Err(RenderInputError::SlideIndexOutOfBounds {
52            index,
53            slide_count: input.slides.len(),
54        });
55    }
56    layout_resolved_slide_with_fonts_text_directions_and_states(
57        input,
58        &timeline.slide,
59        index + 1,
60        fonts,
61        text_directions.map(Vec::as_slice),
62        Some(&timeline.shape_states),
63    )
64}
65
66/// Compose an evaluated incoming page with an optional outgoing page.
67pub fn compose_transition(
68    mut incoming: PageFrame,
69    outgoing: Option<&PageFrame>,
70    transition: Option<&EvaluatedTransition>,
71) -> TimelinePageFrame {
72    let Some(transition) = transition else {
73        return TimelinePageFrame {
74            page: incoming,
75            diagnostics: Vec::new(),
76        };
77    };
78    if matches!(transition.effect, TransitionEffect::Other(_)) {
79        return TimelinePageFrame {
80            page: incoming,
81            diagnostics: vec![Diagnostic {
82                message: "unsupported slide transition".to_owned(),
83            }],
84        };
85    }
86    let direction = match transition_direction(transition) {
87        Ok(direction) => direction,
88        Err(message) => {
89            return TimelinePageFrame {
90                page: incoming,
91                diagnostics: vec![Diagnostic { message }],
92            };
93        }
94    };
95    if matches!(transition.effect, TransitionEffect::Cut) || transition.progress >= 1.0 {
96        return TimelinePageFrame {
97            page: incoming,
98            diagnostics: Vec::new(),
99        };
100    }
101    let Some(outgoing) = outgoing else {
102        return TimelinePageFrame {
103            page: incoming,
104            diagnostics: vec![Diagnostic {
105                message: "transition requires an outgoing slide".to_owned(),
106            }],
107        };
108    };
109    let progress = f64::from(transition.progress.clamp(0.0, 1.0));
110    let incoming_elements = take_page_content(&mut incoming);
111    let outgoing_elements = page_content(outgoing);
112    let groups = match transition.effect {
113        TransitionEffect::Fade | TransitionEffect::Morph => vec![
114            page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
115            page_group(incoming_elements, Transform::IDENTITY, progress, None),
116        ],
117        TransitionEffect::Wipe => vec![
118            page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
119            page_group(
120                incoming_elements,
121                Transform::IDENTITY,
122                1.0,
123                Some(Path::rect(wipe_rect(
124                    incoming.width,
125                    incoming.height,
126                    progress,
127                    direction,
128                ))),
129            ),
130        ],
131        TransitionEffect::Push => vec![
132            page_group(
133                outgoing_elements,
134                push_translation(incoming.width, incoming.height, progress, direction, false),
135                1.0,
136                None,
137            ),
138            page_group(
139                incoming_elements,
140                push_translation(incoming.width, incoming.height, progress, direction, true),
141                1.0,
142                None,
143            ),
144        ],
145        TransitionEffect::Zoom => vec![
146            if direction == "out" {
147                page_group(
148                    outgoing_elements,
149                    scale_about(
150                        1.0 - 0.5 * progress,
151                        incoming.width / 2.0,
152                        incoming.height / 2.0,
153                    ),
154                    1.0 - progress,
155                    None,
156                )
157            } else {
158                page_group(outgoing_elements, Transform::IDENTITY, 1.0 - progress, None)
159            },
160            if direction == "out" {
161                page_group(incoming_elements, Transform::IDENTITY, progress, None)
162            } else {
163                page_group(
164                    incoming_elements,
165                    scale_about(
166                        0.5 + 0.5 * progress,
167                        incoming.width / 2.0,
168                        incoming.height / 2.0,
169                    ),
170                    progress,
171                    None,
172                )
173            },
174        ],
175        TransitionEffect::Cut => unreachable!("cut returned before composition"),
176        TransitionEffect::Other(_) => {
177            return TimelinePageFrame {
178                page: incoming,
179                diagnostics: vec![Diagnostic {
180                    message: "unsupported slide transition".to_owned(),
181                }],
182            };
183        }
184    };
185    incoming.elements = groups;
186    let diagnostics = if matches!(transition.effect, TransitionEffect::Morph) {
187        vec![Diagnostic {
188            message: "morph has no compatible explicit-name pairs and uses crossfade".to_owned(),
189        }]
190    } else {
191        Vec::new()
192    };
193    TimelinePageFrame {
194        page: incoming,
195        diagnostics,
196    }
197}
198
199fn transition_direction(transition: &EvaluatedTransition) -> Result<&str, String> {
200    let explicit = transition
201        .parameters
202        .iter()
203        .find(|parameter| parameter.name == "dir")
204        .map(|parameter| parameter.value.as_str());
205    let (default, supported): (&str, &[&str]) = match transition.effect {
206        TransitionEffect::Wipe | TransitionEffect::Push => ("l", &["l", "r", "u", "d"]),
207        TransitionEffect::Zoom => ("in", &["in", "out"]),
208        _ => return Ok(""),
209    };
210    let direction = explicit.unwrap_or(default);
211    if supported.contains(&direction) {
212        Ok(direction)
213    } else {
214        Err(format!(
215            "unsupported {} transition direction {direction}",
216            match transition.effect {
217                TransitionEffect::Wipe => "wipe",
218                TransitionEffect::Push => "push",
219                TransitionEffect::Zoom => "zoom",
220                _ => unreachable!("non-directional transition returned above"),
221            }
222        ))
223    }
224}
225
226fn wipe_rect(width: f64, height: f64, progress: f64, direction: &str) -> Rect {
227    match direction {
228        "r" => Rect {
229            x: width * (1.0 - progress),
230            y: 0.0,
231            width: width * progress,
232            height,
233        },
234        "u" => Rect {
235            x: 0.0,
236            y: height * (1.0 - progress),
237            width,
238            height: height * progress,
239        },
240        "d" => Rect {
241            x: 0.0,
242            y: 0.0,
243            width,
244            height: height * progress,
245        },
246        _ => Rect {
247            x: 0.0,
248            y: 0.0,
249            width: width * progress,
250            height,
251        },
252    }
253}
254
255fn push_translation(
256    width: f64,
257    height: f64,
258    progress: f64,
259    direction: &str,
260    incoming: bool,
261) -> Transform {
262    let remaining = 1.0 - progress;
263    let (x, y) = match (direction, incoming) {
264        ("r", false) => (width * progress, 0.0),
265        ("r", true) => (-width * remaining, 0.0),
266        ("u", false) => (0.0, -height * progress),
267        ("u", true) => (0.0, height * remaining),
268        ("d", false) => (0.0, height * progress),
269        ("d", true) => (0.0, -height * remaining),
270        (_, false) => (-width * progress, 0.0),
271        (_, true) => (width * remaining, 0.0),
272    };
273    translation(x, y)
274}
275
276/// Compose the bounded explicit-name morph subset, crossfading every fallback.
277pub fn compose_morph_transition(
278    mut incoming_page: PageFrame,
279    incoming: &ResolvedTimelineSlide,
280    outgoing_page: &PageFrame,
281    outgoing: &ResolvedTimelineSlide,
282    transition: &EvaluatedTransition,
283) -> TimelinePageFrame {
284    if !matches!(transition.morph_option.as_deref(), None | Some("byObject")) {
285        let option = transition.morph_option.as_deref().unwrap_or_default();
286        let mut fallback = transition.clone();
287        fallback.effect = TransitionEffect::Fade;
288        let mut frame = compose_transition(incoming_page, Some(outgoing_page), Some(&fallback));
289        frame.diagnostics.push(Diagnostic {
290            message: format!("unsupported morph option {option} uses crossfade"),
291        });
292        return frame;
293    }
294    let progress = f64::from(transition.progress.clamp(0.0, 1.0));
295    if progress >= 1.0 {
296        return TimelinePageFrame {
297            page: incoming_page,
298            diagnostics: Vec::new(),
299        };
300    }
301    let incoming_offset = incoming_page
302        .elements
303        .len()
304        .saturating_sub(incoming.identities.len());
305    let outgoing_offset = outgoing_page
306        .elements
307        .len()
308        .saturating_sub(outgoing.identities.len());
309    let mut used_incoming = vec![false; incoming.identities.len()];
310    let mut elements = Vec::new();
311    let mut diagnostics = Vec::new();
312    if let Some(background) = outgoing_page.background.clone() {
313        elements.push(with_opacity(
314            background_element(outgoing_page.width, outgoing_page.height, background),
315            1.0,
316        ));
317    }
318    if let Some(background) = incoming_page.background.take() {
319        elements.push(with_opacity(
320            background_element(incoming_page.width, incoming_page.height, background),
321            progress,
322        ));
323    }
324
325    for (outgoing_index, outgoing_identity) in outgoing.identities.iter().enumerate() {
326        let Some(name) = outgoing_identity
327            .name
328            .as_deref()
329            .filter(|name| name.starts_with("!!"))
330        else {
331            if let Some(element) = outgoing_page.elements.get(outgoing_offset + outgoing_index) {
332                elements.push(with_opacity(element.clone(), 1.0 - progress));
333            }
334            continue;
335        };
336        let matching = incoming
337            .identities
338            .iter()
339            .enumerate()
340            .find(|(index, identity)| {
341                !used_incoming[*index]
342                    && identity.name.as_deref() == Some(name)
343                    && identity.source == outgoing_identity.source
344            });
345        let Some((incoming_index, _)) = matching else {
346            diagnostics.push(Diagnostic {
347                message: format!("morph shape {name} has no incoming match and uses crossfade"),
348            });
349            if let Some(element) = outgoing_page.elements.get(outgoing_offset + outgoing_index) {
350                elements.push(with_opacity(element.clone(), 1.0 - progress));
351            }
352            continue;
353        };
354        used_incoming[incoming_index] = true;
355        let resolved_pair = outgoing
356            .slide
357            .shapes
358            .get(outgoing_index)
359            .zip(incoming.slide.shapes.get(incoming_index));
360        let compatible = resolved_pair.is_some_and(geometry_compatible);
361        let pair = outgoing_page
362            .elements
363            .get(outgoing_offset + outgoing_index)
364            .zip(incoming_page.elements.get(incoming_offset + incoming_index));
365        if compatible
366            && let Some((outgoing_element, incoming_element)) = pair
367            && let (
368                PositionedElement::Group(outgoing_group),
369                PositionedElement::Group(incoming_group),
370            ) = (outgoing_element, incoming_element)
371        {
372            let (outgoing_shape, incoming_shape) = resolved_pair.expect("compatible pair exists");
373            let width = interpolate(
374                outgoing_shape.bounds.width,
375                incoming_shape.bounds.width,
376                progress,
377            );
378            let height = interpolate(
379                outgoing_shape.bounds.height,
380                incoming_shape.bounds.height,
381                progress,
382            );
383            let transform =
384                interpolate_transform(outgoing_group.transform, incoming_group.transform, progress);
385            let mut outgoing_group = outgoing_group.clone();
386            outgoing_group.transform = Transform {
387                a: width / outgoing_shape.bounds.width,
388                d: height / outgoing_shape.bounds.height,
389                ..Transform::IDENTITY
390            }
391            .then(transform);
392            let mut incoming_group = incoming_group.clone();
393            incoming_group.transform = Transform {
394                a: width / incoming_shape.bounds.width,
395                d: height / incoming_shape.bounds.height,
396                ..Transform::IDENTITY
397            }
398            .then(transform);
399            incoming_group.opacity *= progress;
400            elements.push(page_group(
401                vec![
402                    PositionedElement::Group(outgoing_group),
403                    PositionedElement::Group(incoming_group),
404                ],
405                Transform::IDENTITY,
406                1.0,
407                None,
408            ));
409            continue;
410        }
411        diagnostics.push(Diagnostic {
412            message: format!("morph shape {name} has incompatible geometry and uses crossfade"),
413        });
414        if let Some((outgoing_element, incoming_element)) = pair {
415            elements.push(with_opacity(outgoing_element.clone(), 1.0 - progress));
416            elements.push(with_opacity(incoming_element.clone(), progress));
417        }
418    }
419    for (index, used) in used_incoming.into_iter().enumerate() {
420        if !used && let Some(element) = incoming_page.elements.get(incoming_offset + index) {
421            if let Some(name) = incoming.identities[index]
422                .name
423                .as_deref()
424                .filter(|name| name.starts_with("!!"))
425            {
426                diagnostics.push(Diagnostic {
427                    message: format!("morph shape {name} has no outgoing match and uses crossfade"),
428                });
429            }
430            elements.push(with_opacity(element.clone(), progress));
431        }
432    }
433    incoming_page.elements = elements;
434    TimelinePageFrame {
435        page: incoming_page,
436        diagnostics,
437    }
438}
439
440fn rect_is_finite(rect: Rect) -> bool {
441    [rect.x, rect.y, rect.width, rect.height]
442        .into_iter()
443        .all(f64::is_finite)
444}
445
446fn geometry_compatible((outgoing, incoming): (&ResolvedShape, &ResolvedShape)) -> bool {
447    if !rect_is_finite(outgoing.bounds)
448        || !rect_is_finite(incoming.bounds)
449        || outgoing.bounds.width <= 0.0
450        || outgoing.bounds.height <= 0.0
451        || incoming.bounds.width <= 0.0
452        || incoming.bounds.height <= 0.0
453    {
454        return false;
455    }
456    match (&outgoing.geometry, &incoming.geometry) {
457        (ResolvedGeometry::Rectangle, ResolvedGeometry::Rectangle)
458        | (ResolvedGeometry::BoundsFallback, ResolvedGeometry::BoundsFallback) => true,
459        (
460            ResolvedGeometry::Custom {
461                paths: outgoing_paths,
462                text_rect: outgoing_text_rect,
463            },
464            ResolvedGeometry::Custom {
465                paths: incoming_paths,
466                text_rect: incoming_text_rect,
467            },
468        ) => {
469            outgoing_paths.len() == incoming_paths.len()
470                && outgoing_paths.iter().zip(incoming_paths).all(
471                    |(outgoing_path, incoming_path)| {
472                        outgoing_path.fill_rule == incoming_path.fill_rule
473                            && outgoing_path.commands.len() == incoming_path.commands.len()
474                            && outgoing_path
475                                .commands
476                                .iter()
477                                .zip(&incoming_path.commands)
478                                .all(|(outgoing_command, incoming_command)| {
479                                    path_command_compatible(
480                                        outgoing_command,
481                                        outgoing.bounds,
482                                        incoming_command,
483                                        incoming.bounds,
484                                    )
485                                })
486                    },
487                )
488                && optional_rect_compatible(
489                    *outgoing_text_rect,
490                    outgoing.bounds,
491                    *incoming_text_rect,
492                    incoming.bounds,
493                )
494        }
495        _ => false,
496    }
497}
498
499fn path_command_compatible(
500    outgoing: &PathCommand,
501    outgoing_bounds: Rect,
502    incoming: &PathCommand,
503    incoming_bounds: Rect,
504) -> bool {
505    match (outgoing, incoming) {
506        (PathCommand::MoveTo(outgoing), PathCommand::MoveTo(incoming))
507        | (PathCommand::LineTo(outgoing), PathCommand::LineTo(incoming)) => {
508            point_compatible(*outgoing, outgoing_bounds, *incoming, incoming_bounds)
509        }
510        (
511            PathCommand::CurveTo {
512                c1: outgoing_c1,
513                c2: outgoing_c2,
514                to: outgoing_to,
515            },
516            PathCommand::CurveTo {
517                c1: incoming_c1,
518                c2: incoming_c2,
519                to: incoming_to,
520            },
521        ) => {
522            point_compatible(*outgoing_c1, outgoing_bounds, *incoming_c1, incoming_bounds)
523                && point_compatible(*outgoing_c2, outgoing_bounds, *incoming_c2, incoming_bounds)
524                && point_compatible(*outgoing_to, outgoing_bounds, *incoming_to, incoming_bounds)
525        }
526        (PathCommand::Close, PathCommand::Close) => true,
527        _ => false,
528    }
529}
530
531fn point_compatible(
532    outgoing: oxml_layout::Point,
533    outgoing_bounds: Rect,
534    incoming: oxml_layout::Point,
535    incoming_bounds: Rect,
536) -> bool {
537    normalized_close(
538        outgoing.x / outgoing_bounds.width,
539        incoming.x / incoming_bounds.width,
540    ) && normalized_close(
541        outgoing.y / outgoing_bounds.height,
542        incoming.y / incoming_bounds.height,
543    )
544}
545
546fn optional_rect_compatible(
547    outgoing: Option<Rect>,
548    outgoing_bounds: Rect,
549    incoming: Option<Rect>,
550    incoming_bounds: Rect,
551) -> bool {
552    match (outgoing, incoming) {
553        (None, None) => true,
554        (Some(outgoing), Some(incoming)) => {
555            normalized_close(
556                outgoing.x / outgoing_bounds.width,
557                incoming.x / incoming_bounds.width,
558            ) && normalized_close(
559                outgoing.y / outgoing_bounds.height,
560                incoming.y / incoming_bounds.height,
561            ) && normalized_close(
562                outgoing.width / outgoing_bounds.width,
563                incoming.width / incoming_bounds.width,
564            ) && normalized_close(
565                outgoing.height / outgoing_bounds.height,
566                incoming.height / incoming_bounds.height,
567            )
568        }
569        _ => false,
570    }
571}
572
573fn normalized_close(left: f64, right: f64) -> bool {
574    left.is_finite() && right.is_finite() && (left - right).abs() <= 1e-9
575}
576
577fn interpolate(from: f64, to: f64, progress: f64) -> f64 {
578    from + (to - from) * progress
579}
580
581fn interpolate_transform(from: Transform, to: Transform, progress: f64) -> Transform {
582    Transform {
583        a: interpolate(from.a, to.a, progress),
584        b: interpolate(from.b, to.b, progress),
585        c: interpolate(from.c, to.c, progress),
586        d: interpolate(from.d, to.d, progress),
587        e: interpolate(from.e, to.e, progress),
588        f: interpolate(from.f, to.f, progress),
589    }
590}
591
592fn with_opacity(mut element: PositionedElement, opacity: f64) -> PositionedElement {
593    if let PositionedElement::Group(group) = &mut element {
594        group.opacity *= opacity;
595        return element;
596    }
597    page_group(vec![element], Transform::IDENTITY, opacity, None)
598}
599
600fn page_content(page: &PageFrame) -> Vec<PositionedElement> {
601    let mut elements = page
602        .background
603        .clone()
604        .map(|background| background_element(page.width, page.height, background))
605        .into_iter()
606        .collect::<Vec<_>>();
607    elements.extend(page.elements.clone());
608    elements
609}
610
611fn take_page_content(page: &mut PageFrame) -> Vec<PositionedElement> {
612    let mut elements = page
613        .background
614        .take()
615        .map(|background| background_element(page.width, page.height, background))
616        .into_iter()
617        .collect::<Vec<_>>();
618    elements.append(&mut page.elements);
619    elements
620}
621
622fn background_element(
623    width: f64,
624    height: f64,
625    background: oxml_layout::Paint,
626) -> PositionedElement {
627    PositionedElement::Path(PathElement {
628        path: Path::rect(Rect {
629            x: 0.0,
630            y: 0.0,
631            width,
632            height,
633        }),
634        fill: Some(background),
635        stroke: None,
636    })
637}
638
639fn page_group(
640    children: Vec<PositionedElement>,
641    transform: Transform,
642    opacity: f64,
643    clip: Option<Path>,
644) -> PositionedElement {
645    PositionedElement::Group(GroupElement {
646        transform,
647        clip,
648        opacity,
649        effects: Vec::new(),
650        children,
651    })
652}
653
654fn translation(x: f64, y: f64) -> Transform {
655    Transform {
656        e: x,
657        f: y,
658        ..Transform::IDENTITY
659    }
660}
661
662fn scale_about(scale: f64, center_x: f64, center_y: f64) -> Transform {
663    Transform {
664        a: scale,
665        d: scale,
666        e: center_x * (1.0 - scale),
667        f: center_y * (1.0 - scale),
668        ..Transform::IDENTITY
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use oxml_layout::{
675        FillRule, GroupElement, PageFrame, Path, PathCommand, Point, PositionedElement, Rect,
676        Transform,
677    };
678    use rpptx_layout::timeline::{
679        EvaluatedFrameState, EvaluatedShapeState, EvaluatedTransition, ResolvedShapeIdentity,
680        ResolvedTimelineSlide,
681    };
682    use rpptx_layout::{
683        FlattenedSource, ResolvedContent, ResolvedGeometry, ResolvedShape, ResolvedSlide,
684    };
685    use rpptx_oxml::timing::{TransitionEffect, TransitionParameter};
686
687    use super::{compose_morph_transition, compose_transition};
688
689    #[test]
690    fn fade_transition_keeps_outgoing_opaque_and_fades_incoming_at_exact_progress() {
691        let outgoing = PageFrame::new(
692            1,
693            100.0,
694            50.0,
695            vec![PositionedElement::Group(GroupElement {
696                transform: Transform::IDENTITY,
697                clip: None,
698                opacity: 1.0,
699                effects: Vec::new(),
700                children: Vec::new(),
701            })],
702        );
703        let incoming = PageFrame::new(2, 100.0, 50.0, Vec::new());
704        let frame = compose_transition(
705            incoming,
706            Some(&outgoing),
707            Some(&EvaluatedTransition {
708                effect: TransitionEffect::Fade,
709                progress: 0.25,
710                duration_ms: 1000,
711                parameters: Vec::new(),
712                morph_option: None,
713            }),
714        );
715
716        assert_eq!(frame.page.elements.len(), 2);
717        let PositionedElement::Group(first) = &frame.page.elements[0] else {
718            panic!("expected outgoing group")
719        };
720        let PositionedElement::Group(second) = &frame.page.elements[1] else {
721            panic!("expected incoming group")
722        };
723        assert_eq!(first.opacity, 1.0);
724        assert_eq!(second.opacity, 0.25);
725    }
726
727    #[test]
728    fn cut_wipe_push_and_zoom_have_finite_bounded_compositions() {
729        for effect in [
730            TransitionEffect::Cut,
731            TransitionEffect::Wipe,
732            TransitionEffect::Push,
733            TransitionEffect::Zoom,
734        ] {
735            let outgoing = PageFrame::new(1, 100.0, 50.0, Vec::new());
736            let incoming = PageFrame::new(2, 100.0, 50.0, Vec::new());
737            let frame = compose_transition(
738                incoming,
739                Some(&outgoing),
740                Some(&EvaluatedTransition {
741                    effect: effect.clone(),
742                    progress: 0.5,
743                    duration_ms: 1000,
744                    parameters: Vec::new(),
745                    morph_option: None,
746                }),
747            );
748            assert!(frame.diagnostics.is_empty());
749            if effect != TransitionEffect::Cut {
750                assert_eq!(frame.page.elements.len(), 2);
751            }
752        }
753    }
754
755    #[test]
756    fn invalid_transition_directions_diagnose_without_composition() {
757        for (effect, name) in [
758            (TransitionEffect::Wipe, "wipe"),
759            (TransitionEffect::Push, "push"),
760            (TransitionEffect::Zoom, "zoom"),
761        ] {
762            for progress in [0.5, 1.0] {
763                let incoming = PageFrame::new(
764                    2,
765                    100.0,
766                    50.0,
767                    vec![PositionedElement::Group(GroupElement {
768                        transform: Transform::IDENTITY,
769                        clip: None,
770                        opacity: 1.0,
771                        effects: Vec::new(),
772                        children: Vec::new(),
773                    })],
774                );
775                let frame = compose_transition(
776                    incoming.clone(),
777                    Some(&PageFrame::new(1, 100.0, 50.0, Vec::new())),
778                    Some(&EvaluatedTransition {
779                        effect: effect.clone(),
780                        progress,
781                        duration_ms: 1000,
782                        parameters: vec![TransitionParameter {
783                            name: "dir".to_owned(),
784                            value: "diagonal".to_owned(),
785                        }],
786                        morph_option: None,
787                    }),
788                );
789
790                assert_eq!(frame.page.width, incoming.width);
791                assert_eq!(frame.page.height, incoming.height);
792                assert_eq!(frame.page.elements, incoming.elements);
793                assert_eq!(
794                    frame.diagnostics[0].message,
795                    format!("unsupported {name} transition direction diagonal")
796                );
797            }
798        }
799
800        let other = compose_transition(
801            PageFrame::new(2, 100.0, 50.0, Vec::new()),
802            None,
803            Some(&EvaluatedTransition {
804                effect: TransitionEffect::Other("producer".to_owned()),
805                progress: 1.0,
806                duration_ms: 1000,
807                parameters: Vec::new(),
808                morph_option: None,
809            }),
810        );
811        assert_eq!(other.diagnostics[0].message, "unsupported slide transition");
812    }
813
814    #[test]
815    fn explicit_name_morph_interpolates_compatible_shape_groups() {
816        let mut outgoing_timeline = timeline_slide("!!Hero", 0.0);
817        outgoing_timeline.slide.shapes[0].bounds.width = 20.0;
818        outgoing_timeline.slide.shapes[0].bounds.height = 30.0;
819        let incoming_timeline = timeline_slide("!!Hero", 100.0);
820        let outgoing_page = page_with_translation(0.0);
821        let incoming_page = page_with_translation(100.0);
822        let frame = compose_morph_transition(
823            incoming_page,
824            &incoming_timeline,
825            &outgoing_page,
826            &outgoing_timeline,
827            &EvaluatedTransition {
828                effect: TransitionEffect::Morph,
829                progress: 0.5,
830                duration_ms: 1000,
831                parameters: Vec::new(),
832                morph_option: Some("byObject".to_owned()),
833            },
834        );
835
836        assert!(frame.diagnostics.is_empty());
837        let PositionedElement::Group(group) = &frame.page.elements[0] else {
838            panic!("morph pair should remain one group")
839        };
840        assert_eq!(group.transform, Transform::IDENTITY);
841        assert_eq!(group.children.len(), 2);
842        let PositionedElement::Group(outgoing_group) = &group.children[0] else {
843            panic!("morph pair should retain outgoing content")
844        };
845        let PositionedElement::Group(incoming_group) = &group.children[1] else {
846            panic!("morph pair should retain incoming content")
847        };
848        assert_eq!(outgoing_group.transform.e, 50.0);
849        assert_eq!(outgoing_group.transform.a, 0.75);
850        assert_eq!(outgoing_group.transform.d, 2.0 / 3.0);
851        assert_eq!(outgoing_group.opacity, 1.0);
852        assert_eq!(incoming_group.transform.e, 50.0);
853        assert_eq!(incoming_group.transform.a, 1.5);
854        assert_eq!(incoming_group.transform.d, 2.0);
855        assert_eq!(incoming_group.opacity, 0.5);
856    }
857
858    #[test]
859    fn compatible_morph_keeps_outgoing_content_at_progress_zero() {
860        let outgoing_timeline = timeline_slide("!!Hero", 0.0);
861        let incoming_timeline = timeline_slide("!!Hero", 100.0);
862        let outgoing_page = page_with_marker(0.0, 7.0);
863        let incoming_page = page_with_marker(100.0, 9.0);
864        let frame = compose_morph_transition(
865            incoming_page,
866            &incoming_timeline,
867            &outgoing_page,
868            &outgoing_timeline,
869            &EvaluatedTransition {
870                effect: TransitionEffect::Morph,
871                progress: 0.0,
872                duration_ms: 1000,
873                parameters: Vec::new(),
874                morph_option: Some("byObject".to_owned()),
875            },
876        );
877
878        let PositionedElement::Group(pair) = &frame.page.elements[0] else {
879            panic!("morph pair should remain one group")
880        };
881        let PositionedElement::Group(outgoing) = &pair.children[0] else {
882            panic!("morph pair should retain outgoing content")
883        };
884        let PositionedElement::Group(incoming) = &pair.children[1] else {
885            panic!("morph pair should retain incoming content")
886        };
887        let PositionedElement::Group(outgoing_marker) = &outgoing.children[0] else {
888            panic!("expected outgoing marker")
889        };
890        let PositionedElement::Group(incoming_marker) = &incoming.children[0] else {
891            panic!("expected incoming marker")
892        };
893        assert_eq!(outgoing.opacity, 1.0);
894        assert_eq!(incoming.opacity, 0.0);
895        assert_eq!(outgoing_marker.transform.e, 7.0);
896        assert_eq!(incoming_marker.transform.e, 9.0);
897    }
898
899    #[test]
900    fn word_and_character_morph_options_crossfade_with_diagnostics() {
901        for option in ["byWord", "byChar"] {
902            let outgoing_timeline = timeline_slide("!!Hero", 0.0);
903            let incoming_timeline = timeline_slide("!!Hero", 100.0);
904            let frame = compose_morph_transition(
905                page_with_translation(100.0),
906                &incoming_timeline,
907                &page_with_translation(0.0),
908                &outgoing_timeline,
909                &EvaluatedTransition {
910                    effect: TransitionEffect::Morph,
911                    progress: 0.25,
912                    duration_ms: 1000,
913                    parameters: Vec::new(),
914                    morph_option: Some(option.to_owned()),
915                },
916            );
917
918            assert_eq!(frame.page.elements.len(), 2);
919            let PositionedElement::Group(outgoing) = &frame.page.elements[0] else {
920                panic!("expected outgoing crossfade group")
921            };
922            let PositionedElement::Group(incoming) = &frame.page.elements[1] else {
923                panic!("expected incoming crossfade group")
924            };
925            assert_eq!(outgoing.opacity, 1.0);
926            assert_eq!(incoming.opacity, 0.25);
927            assert_eq!(
928                frame.diagnostics[0].message,
929                format!("unsupported morph option {option} uses crossfade")
930            );
931        }
932    }
933
934    #[test]
935    fn zoom_in_and_out_transform_opposite_pages() {
936        let compose = |direction: &str| {
937            compose_transition(
938                PageFrame::new(2, 100.0, 50.0, Vec::new()),
939                Some(&PageFrame::new(1, 100.0, 50.0, Vec::new())),
940                Some(&EvaluatedTransition {
941                    effect: TransitionEffect::Zoom,
942                    progress: 0.5,
943                    duration_ms: 1000,
944                    parameters: vec![TransitionParameter {
945                        name: "dir".to_owned(),
946                        value: direction.to_owned(),
947                    }],
948                    morph_option: None,
949                }),
950            )
951        };
952
953        let zoom_in = compose("in");
954        let zoom_out = compose("out");
955        let PositionedElement::Group(in_outgoing) = &zoom_in.page.elements[0] else {
956            panic!("expected zoom-in outgoing group")
957        };
958        let PositionedElement::Group(in_incoming) = &zoom_in.page.elements[1] else {
959            panic!("expected zoom-in incoming group")
960        };
961        let PositionedElement::Group(out_outgoing) = &zoom_out.page.elements[0] else {
962            panic!("expected zoom-out outgoing group")
963        };
964        let PositionedElement::Group(out_incoming) = &zoom_out.page.elements[1] else {
965            panic!("expected zoom-out incoming group")
966        };
967        assert_eq!(in_outgoing.transform.a, 1.0);
968        assert_eq!(in_incoming.transform.a, 0.75);
969        assert_eq!(out_outgoing.transform.a, 0.75);
970        assert_eq!(out_incoming.transform.a, 1.0);
971    }
972
973    #[test]
974    fn morph_reports_explicit_names_missing_on_either_slide() {
975        let outgoing_timeline = timeline_slide("!!Outgoing", 0.0);
976        let incoming_timeline = timeline_slide("!!Incoming", 100.0);
977        let frame = compose_morph_transition(
978            page_with_translation(100.0),
979            &incoming_timeline,
980            &page_with_translation(0.0),
981            &outgoing_timeline,
982            &EvaluatedTransition {
983                effect: TransitionEffect::Morph,
984                progress: 0.5,
985                duration_ms: 1000,
986                parameters: Vec::new(),
987                morph_option: Some("byObject".to_owned()),
988            },
989        );
990
991        assert_eq!(frame.page.elements.len(), 2);
992        assert_eq!(frame.diagnostics.len(), 2);
993        assert!(
994            frame
995                .diagnostics
996                .iter()
997                .any(|diagnostic| diagnostic.message.contains("!!Outgoing")
998                    && diagnostic.message.contains("no incoming match"))
999        );
1000        assert!(
1001            frame
1002                .diagnostics
1003                .iter()
1004                .any(|diagnostic| diagnostic.message.contains("!!Incoming")
1005                    && diagnostic.message.contains("no outgoing match"))
1006        );
1007    }
1008
1009    #[test]
1010    fn morph_crossfades_custom_geometries_with_different_path_structure() {
1011        let mut outgoing_timeline = timeline_slide("!!Hero", 0.0);
1012        outgoing_timeline.slide.shapes[0].geometry = ResolvedGeometry::Custom {
1013            paths: vec![Path {
1014                commands: vec![
1015                    PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
1016                    PathCommand::LineTo(Point { x: 10.0, y: 0.0 }),
1017                    PathCommand::LineTo(Point { x: 5.0, y: 10.0 }),
1018                    PathCommand::Close,
1019                ],
1020                fill_rule: FillRule::NonZero,
1021            }],
1022            text_rect: None,
1023        };
1024        let mut incoming_timeline = timeline_slide("!!Hero", 100.0);
1025        incoming_timeline.slide.shapes[0].geometry = ResolvedGeometry::Custom {
1026            paths: vec![Path::rect(Rect {
1027                x: 0.0,
1028                y: 0.0,
1029                width: 10.0,
1030                height: 10.0,
1031            })],
1032            text_rect: None,
1033        };
1034        let frame = compose_morph_transition(
1035            page_with_translation(100.0),
1036            &incoming_timeline,
1037            &page_with_translation(0.0),
1038            &outgoing_timeline,
1039            &EvaluatedTransition {
1040                effect: TransitionEffect::Morph,
1041                progress: 0.5,
1042                duration_ms: 1000,
1043                parameters: Vec::new(),
1044                morph_option: Some("byObject".to_owned()),
1045            },
1046        );
1047
1048        assert_eq!(frame.page.elements.len(), 2);
1049        assert_eq!(frame.diagnostics.len(), 1);
1050        assert!(
1051            frame.diagnostics[0]
1052                .message
1053                .contains("incompatible geometry")
1054        );
1055    }
1056
1057    fn timeline_slide(name: &str, x: f64) -> ResolvedTimelineSlide {
1058        ResolvedTimelineSlide {
1059            slide: ResolvedSlide {
1060                size: (100.0, 50.0),
1061                background: None,
1062                shapes: vec![ResolvedShape {
1063                    group_transform: Transform::IDENTITY,
1064                    bounds: Rect {
1065                        x,
1066                        y: 0.0,
1067                        width: 10.0,
1068                        height: 10.0,
1069                    },
1070                    rotation_deg: 0.0,
1071                    flip_h: false,
1072                    flip_v: false,
1073                    geometry: ResolvedGeometry::Rectangle,
1074                    fill: None,
1075                    image_fill: None,
1076                    line: None,
1077                    head_end: None,
1078                    tail_end: None,
1079                    shadow: None,
1080                    content: ResolvedContent::None,
1081                    unsupported: None,
1082                }],
1083                diagnostics: Vec::new(),
1084            },
1085            identities: vec![ResolvedShapeIdentity {
1086                source: FlattenedSource::Slide,
1087                shape_id: Some(2),
1088                containing_group_ids: Vec::new(),
1089                name: Some(name.to_owned()),
1090            }],
1091            shape_states: vec![EvaluatedShapeState::default()],
1092            state: EvaluatedFrameState::default(),
1093        }
1094    }
1095
1096    fn page_with_translation(x: f64) -> PageFrame {
1097        PageFrame::new(
1098            1,
1099            100.0,
1100            50.0,
1101            vec![PositionedElement::Group(GroupElement {
1102                transform: Transform {
1103                    e: x,
1104                    ..Transform::IDENTITY
1105                },
1106                clip: None,
1107                opacity: 1.0,
1108                effects: Vec::new(),
1109                children: Vec::new(),
1110            })],
1111        )
1112    }
1113
1114    fn page_with_marker(x: f64, marker_x: f64) -> PageFrame {
1115        let mut page = page_with_translation(x);
1116        let PositionedElement::Group(group) = &mut page.elements[0] else {
1117            unreachable!("page helper creates a group")
1118        };
1119        group.children.push(PositionedElement::Group(GroupElement {
1120            transform: Transform {
1121                e: marker_x,
1122                ..Transform::IDENTITY
1123            },
1124            clip: None,
1125            opacity: 1.0,
1126            effects: Vec::new(),
1127            children: Vec::new(),
1128        }));
1129        page
1130    }
1131}