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::{RenderInput, RenderInputError, layout_resolved_slide_with_diagnostics};
12
13/// One composed page plus stable approximation diagnostics.
14#[derive(Clone, Debug)]
15pub struct TimelinePageFrame {
16    pub page: PageFrame,
17    pub diagnostics: Vec<Diagnostic>,
18}
19
20/// Lower one evaluated slide through the existing deterministic shape and text path.
21pub fn layout_timeline_slide_deterministic(
22    input: &RenderInput,
23    index: usize,
24    timeline: &ResolvedTimelineSlide,
25    text_directions: Option<&ResolvedSlideTextDirections>,
26) -> Result<PageFrame, RenderInputError> {
27    let mut fonts =
28        FontManager::new_deterministic().map_err(|error| RenderInputError::TextLayout {
29            detail: error.to_string(),
30        })?;
31    fonts.load_additional_fonts(&input.fonts);
32    layout_timeline_slide_with_font_manager(input, index, timeline, text_directions, &mut fonts)
33}
34
35/// Internal facade hook for `rpptx` prepared timeline rendering.
36///
37/// `fonts` must be the same manager that resolved and shaped `timeline`,
38/// including any dynamic fallback labels. Rasterization must use the font data
39/// retained by that manager so every [`oxml_layout::FontId`] remains coherent.
40#[doc(hidden)]
41pub fn layout_timeline_slide_with_font_manager(
42    input: &RenderInput,
43    index: usize,
44    timeline: &ResolvedTimelineSlide,
45    text_directions: Option<&ResolvedSlideTextDirections>,
46    fonts: &mut FontManager,
47) -> Result<PageFrame, RenderInputError> {
48    layout_timeline_slide_with_diagnostics(input, index, timeline, text_directions, fonts)
49        .map(|frame| frame.page)
50}
51
52/// Facade hook retaining text approximation diagnostics alongside the evaluated page.
53#[doc(hidden)]
54pub fn layout_timeline_slide_with_diagnostics(
55    input: &RenderInput,
56    index: usize,
57    timeline: &ResolvedTimelineSlide,
58    text_directions: Option<&ResolvedSlideTextDirections>,
59    fonts: &mut FontManager,
60) -> Result<TimelinePageFrame, RenderInputError> {
61    layout_timeline_slide_with_displayed_number(input, index, timeline, text_directions, fonts, 1)
62}
63
64/// Lowers a timeline frame using the same explicit displayed-number context as
65/// static layout, retaining the physical frame ordinal for selection and output.
66pub fn layout_timeline_slide_with_displayed_number(
67    input: &RenderInput,
68    index: usize,
69    timeline: &ResolvedTimelineSlide,
70    text_directions: Option<&ResolvedSlideTextDirections>,
71    fonts: &mut FontManager,
72    first_slide_number: usize,
73) -> Result<TimelinePageFrame, RenderInputError> {
74    if index >= input.slides.len() {
75        return Err(RenderInputError::SlideIndexOutOfBounds {
76            index,
77            slide_count: input.slides.len(),
78        });
79    }
80    // Owner facts lead shaping diagnostics in the same order as static rendering.
81    let mut owner_diagnostics = Vec::new();
82    if timeline.slide.unresolved_member_count > 0 {
83        owner_diagnostics.push(Diagnostic {
84            message: format!(
85                "slide {} incomplete: {} unresolved members",
86                index + 1,
87                timeline.slide.unresolved_member_count
88            ),
89        });
90    }
91    owner_diagnostics.extend(timeline.slide.diagnostics.iter().cloned());
92    let mut diagnostics = Vec::new();
93    let mut page = layout_resolved_slide_with_diagnostics(
94        input,
95        &timeline.slide,
96        first_slide_number
97            .checked_add(index)
98            .ok_or_else(|| RenderInputError::TextLayout {
99                detail: "displayed slide number overflow".into(),
100            })?,
101        fonts,
102        text_directions.map(Vec::as_slice),
103        Some(&timeline.shape_states),
104        &mut diagnostics,
105    )?;
106    page.page_number = index + 1;
107    owner_diagnostics.extend(
108        diagnostics
109            .into_iter()
110            .map(|message| Diagnostic { message }),
111    );
112    Ok(TimelinePageFrame {
113        page,
114        diagnostics: owner_diagnostics,
115    })
116}
117
118/// Compose an evaluated incoming page with an optional outgoing page.
119pub fn compose_transition(
120    mut incoming: PageFrame,
121    outgoing: Option<&PageFrame>,
122    transition: Option<&EvaluatedTransition>,
123) -> TimelinePageFrame {
124    let Some(transition) = transition else {
125        return TimelinePageFrame {
126            page: incoming,
127            diagnostics: Vec::new(),
128        };
129    };
130    if matches!(transition.effect, TransitionEffect::Other(_)) {
131        return TimelinePageFrame {
132            page: incoming,
133            diagnostics: vec![Diagnostic {
134                message: "unsupported slide transition".to_owned(),
135            }],
136        };
137    }
138    let direction = match transition_direction(transition) {
139        Ok(direction) => direction,
140        Err(message) => {
141            return TimelinePageFrame {
142                page: incoming,
143                diagnostics: vec![Diagnostic { message }],
144            };
145        }
146    };
147    if matches!(transition.effect, TransitionEffect::Cut) || transition.progress >= 1.0 {
148        return TimelinePageFrame {
149            page: incoming,
150            diagnostics: Vec::new(),
151        };
152    }
153    let Some(outgoing) = outgoing else {
154        return TimelinePageFrame {
155            page: incoming,
156            diagnostics: vec![Diagnostic {
157                message: "transition requires an outgoing slide".to_owned(),
158            }],
159        };
160    };
161    let progress = f64::from(transition.progress.clamp(0.0, 1.0));
162    let incoming_elements = take_page_content(&mut incoming);
163    let outgoing_elements = page_content(outgoing);
164    let groups = match transition.effect {
165        TransitionEffect::Fade | TransitionEffect::Morph => vec![
166            page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
167            page_group(incoming_elements, Transform::IDENTITY, progress, None),
168        ],
169        TransitionEffect::Wipe => vec![
170            page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
171            page_group(
172                incoming_elements,
173                Transform::IDENTITY,
174                1.0,
175                Some(Path::rect(wipe_rect(
176                    incoming.width,
177                    incoming.height,
178                    progress,
179                    direction,
180                ))),
181            ),
182        ],
183        TransitionEffect::Push => vec![
184            page_group(
185                outgoing_elements,
186                push_translation(incoming.width, incoming.height, progress, direction, false),
187                1.0,
188                None,
189            ),
190            page_group(
191                incoming_elements,
192                push_translation(incoming.width, incoming.height, progress, direction, true),
193                1.0,
194                None,
195            ),
196        ],
197        TransitionEffect::Cover => vec![
198            page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
199            page_group(
200                incoming_elements,
201                push_translation(incoming.width, incoming.height, progress, direction, true),
202                1.0,
203                None,
204            ),
205        ],
206        TransitionEffect::Uncover => vec![
207            page_group(incoming_elements, Transform::IDENTITY, 1.0, None),
208            page_group(
209                outgoing_elements,
210                push_translation(incoming.width, incoming.height, progress, direction, false),
211                1.0,
212                None,
213            ),
214        ],
215        TransitionEffect::Split => {
216            let vertical = transition
217                .parameters
218                .iter()
219                .any(|p| p.name == "orient" && p.value == "vert");
220            let rect = if vertical {
221                Rect {
222                    x: incoming.width * (1.0 - progress) / 2.0,
223                    y: 0.0,
224                    width: incoming.width * progress,
225                    height: incoming.height,
226                }
227            } else {
228                Rect {
229                    x: 0.0,
230                    y: incoming.height * (1.0 - progress) / 2.0,
231                    width: incoming.width,
232                    height: incoming.height * progress,
233                }
234            };
235            if direction == "in" {
236                let remainder = if vertical {
237                    Rect {
238                        x: incoming.width * progress / 2.0,
239                        y: 0.0,
240                        width: incoming.width * (1.0 - progress),
241                        height: incoming.height,
242                    }
243                } else {
244                    Rect {
245                        x: 0.0,
246                        y: incoming.height * progress / 2.0,
247                        width: incoming.width,
248                        height: incoming.height * (1.0 - progress),
249                    }
250                };
251                vec![
252                    page_group(incoming_elements, Transform::IDENTITY, 1.0, None),
253                    page_group(
254                        outgoing_elements,
255                        Transform::IDENTITY,
256                        1.0,
257                        Some(Path::rect(remainder)),
258                    ),
259                ]
260            } else {
261                vec![
262                    page_group(outgoing_elements, Transform::IDENTITY, 1.0, None),
263                    page_group(
264                        incoming_elements,
265                        Transform::IDENTITY,
266                        1.0,
267                        Some(Path::rect(rect)),
268                    ),
269                ]
270            }
271        }
272        TransitionEffect::Zoom => vec![
273            if direction == "out" {
274                page_group(
275                    outgoing_elements,
276                    scale_about(
277                        1.0 - 0.5 * progress,
278                        incoming.width / 2.0,
279                        incoming.height / 2.0,
280                    ),
281                    1.0 - progress,
282                    None,
283                )
284            } else {
285                page_group(outgoing_elements, Transform::IDENTITY, 1.0 - progress, None)
286            },
287            if direction == "out" {
288                page_group(incoming_elements, Transform::IDENTITY, progress, None)
289            } else {
290                page_group(
291                    incoming_elements,
292                    scale_about(
293                        0.5 + 0.5 * progress,
294                        incoming.width / 2.0,
295                        incoming.height / 2.0,
296                    ),
297                    progress,
298                    None,
299                )
300            },
301        ],
302        TransitionEffect::Cut => unreachable!("cut returned before composition"),
303        TransitionEffect::Other(_) => {
304            return TimelinePageFrame {
305                page: incoming,
306                diagnostics: vec![Diagnostic {
307                    message: "unsupported slide transition".to_owned(),
308                }],
309            };
310        }
311    };
312    incoming.elements = groups;
313    let diagnostics = if matches!(transition.effect, TransitionEffect::Morph) {
314        vec![Diagnostic {
315            message: "morph has no compatible explicit-name pairs and uses crossfade".to_owned(),
316        }]
317    } else {
318        Vec::new()
319    };
320    TimelinePageFrame {
321        page: incoming,
322        diagnostics,
323    }
324}
325
326fn transition_direction(transition: &EvaluatedTransition) -> Result<&str, String> {
327    let explicit = transition
328        .parameters
329        .iter()
330        .find(|parameter| parameter.name == "dir")
331        .map(|parameter| parameter.value.as_str());
332    let (default, supported): (&str, &[&str]) = match transition.effect {
333        TransitionEffect::Wipe | TransitionEffect::Push => ("l", &["l", "r", "u", "d"]),
334        TransitionEffect::Zoom => ("out", &["in", "out"]),
335        TransitionEffect::Split => ("out", &["in", "out"]),
336        TransitionEffect::Cover | TransitionEffect::Uncover => {
337            ("l", &["l", "r", "u", "d", "lu", "ru", "ld", "rd"])
338        }
339        _ => return Ok(""),
340    };
341    let direction = explicit.unwrap_or(default);
342    if supported.contains(&direction) {
343        Ok(direction)
344    } else {
345        Err(format!(
346            "unsupported {} transition direction {direction}",
347            match transition.effect {
348                TransitionEffect::Wipe => "wipe",
349                TransitionEffect::Push => "push",
350                TransitionEffect::Zoom => "zoom",
351                TransitionEffect::Split => "split",
352                TransitionEffect::Cover => "cover",
353                TransitionEffect::Uncover => "uncover",
354                _ => unreachable!("non-directional transition returned above"),
355            }
356        ))
357    }
358}
359
360fn wipe_rect(width: f64, height: f64, progress: f64, direction: &str) -> Rect {
361    match direction {
362        "r" => Rect {
363            x: width * (1.0 - progress),
364            y: 0.0,
365            width: width * progress,
366            height,
367        },
368        "u" => Rect {
369            x: 0.0,
370            y: height * (1.0 - progress),
371            width,
372            height: height * progress,
373        },
374        "d" => Rect {
375            x: 0.0,
376            y: 0.0,
377            width,
378            height: height * progress,
379        },
380        _ => Rect {
381            x: 0.0,
382            y: 0.0,
383            width: width * progress,
384            height,
385        },
386    }
387}
388
389fn push_translation(
390    width: f64,
391    height: f64,
392    progress: f64,
393    direction: &str,
394    incoming: bool,
395) -> Transform {
396    let remaining = 1.0 - progress;
397    let (x, y) = match (direction, incoming) {
398        ("r", false) => (width * progress, 0.0),
399        ("r", true) => (-width * remaining, 0.0),
400        ("u", false) => (0.0, -height * progress),
401        ("u", true) => (0.0, height * remaining),
402        ("d", false) => (0.0, height * progress),
403        ("d", true) => (0.0, -height * remaining),
404        ("lu", false) => (-width * progress, -height * progress),
405        ("lu", true) => (width * remaining, height * remaining),
406        ("ru", false) => (width * progress, -height * progress),
407        ("ru", true) => (-width * remaining, height * remaining),
408        ("ld", false) => (-width * progress, height * progress),
409        ("ld", true) => (width * remaining, -height * remaining),
410        ("rd", false) => (width * progress, height * progress),
411        ("rd", true) => (-width * remaining, -height * remaining),
412        (_, false) => (-width * progress, 0.0),
413        (_, true) => (width * remaining, 0.0),
414    };
415    translation(x, y)
416}
417
418/// Compose the bounded explicit-name morph subset, crossfading every fallback.
419pub fn compose_morph_transition(
420    mut incoming_page: PageFrame,
421    incoming: &ResolvedTimelineSlide,
422    outgoing_page: &PageFrame,
423    outgoing: &ResolvedTimelineSlide,
424    transition: &EvaluatedTransition,
425) -> TimelinePageFrame {
426    if !matches!(transition.morph_option.as_deref(), None | Some("byObject")) {
427        let option = transition.morph_option.as_deref().unwrap_or_default();
428        let mut fallback = transition.clone();
429        fallback.effect = TransitionEffect::Fade;
430        let mut frame = compose_transition(incoming_page, Some(outgoing_page), Some(&fallback));
431        frame.diagnostics.push(Diagnostic {
432            message: format!("unsupported morph option {option} uses crossfade"),
433        });
434        return frame;
435    }
436    let progress = f64::from(transition.progress.clamp(0.0, 1.0));
437    if progress >= 1.0 {
438        return TimelinePageFrame {
439            page: incoming_page,
440            diagnostics: Vec::new(),
441        };
442    }
443    let incoming_offset = incoming_page
444        .elements
445        .len()
446        .saturating_sub(incoming.identities.len());
447    let outgoing_offset = outgoing_page
448        .elements
449        .len()
450        .saturating_sub(outgoing.identities.len());
451    let mut used_incoming = vec![false; incoming.identities.len()];
452    let mut elements = Vec::new();
453    let mut diagnostics = Vec::new();
454    if let Some(background) = outgoing_page.background.clone() {
455        elements.push(with_opacity(
456            background_element(outgoing_page.width, outgoing_page.height, background),
457            1.0,
458        ));
459    }
460    if let Some(background) = incoming_page.background.take() {
461        elements.push(with_opacity(
462            background_element(incoming_page.width, incoming_page.height, background),
463            progress,
464        ));
465    }
466
467    for (outgoing_index, outgoing_identity) in outgoing.identities.iter().enumerate() {
468        let Some(name) = outgoing_identity
469            .name
470            .as_deref()
471            .filter(|name| name.starts_with("!!"))
472        else {
473            if let Some(element) = outgoing_page.elements.get(outgoing_offset + outgoing_index) {
474                elements.push(with_opacity(element.clone(), 1.0 - progress));
475            }
476            continue;
477        };
478        let matching = incoming
479            .identities
480            .iter()
481            .enumerate()
482            .find(|(index, identity)| {
483                !used_incoming[*index]
484                    && identity.name.as_deref() == Some(name)
485                    && identity.source == outgoing_identity.source
486            });
487        let Some((incoming_index, _)) = matching else {
488            diagnostics.push(Diagnostic {
489                message: format!("morph shape {name} has no incoming match and uses crossfade"),
490            });
491            if let Some(element) = outgoing_page.elements.get(outgoing_offset + outgoing_index) {
492                elements.push(with_opacity(element.clone(), 1.0 - progress));
493            }
494            continue;
495        };
496        used_incoming[incoming_index] = true;
497        let resolved_pair = outgoing
498            .slide
499            .shapes
500            .get(outgoing_index)
501            .zip(incoming.slide.shapes.get(incoming_index));
502        let compatible = resolved_pair.is_some_and(geometry_compatible);
503        let pair = outgoing_page
504            .elements
505            .get(outgoing_offset + outgoing_index)
506            .zip(incoming_page.elements.get(incoming_offset + incoming_index));
507        if compatible
508            && let Some((outgoing_element, incoming_element)) = pair
509            && let (
510                PositionedElement::Group(outgoing_group),
511                PositionedElement::Group(incoming_group),
512            ) = (outgoing_element, incoming_element)
513        {
514            let (outgoing_shape, incoming_shape) = resolved_pair.expect("compatible pair exists");
515            let width = interpolate(
516                outgoing_shape.bounds.width,
517                incoming_shape.bounds.width,
518                progress,
519            );
520            let height = interpolate(
521                outgoing_shape.bounds.height,
522                incoming_shape.bounds.height,
523                progress,
524            );
525            let transform =
526                interpolate_transform(outgoing_group.transform, incoming_group.transform, progress);
527            let mut outgoing_group = outgoing_group.clone();
528            outgoing_group.transform = Transform {
529                a: width / outgoing_shape.bounds.width,
530                d: height / outgoing_shape.bounds.height,
531                ..Transform::IDENTITY
532            }
533            .then(transform);
534            let mut incoming_group = incoming_group.clone();
535            incoming_group.transform = Transform {
536                a: width / incoming_shape.bounds.width,
537                d: height / incoming_shape.bounds.height,
538                ..Transform::IDENTITY
539            }
540            .then(transform);
541            incoming_group.opacity *= progress;
542            elements.push(page_group(
543                vec![
544                    PositionedElement::Group(outgoing_group),
545                    PositionedElement::Group(incoming_group),
546                ],
547                Transform::IDENTITY,
548                1.0,
549                None,
550            ));
551            continue;
552        }
553        diagnostics.push(Diagnostic {
554            message: format!("morph shape {name} has incompatible geometry and uses crossfade"),
555        });
556        if let Some((outgoing_element, incoming_element)) = pair {
557            elements.push(with_opacity(outgoing_element.clone(), 1.0 - progress));
558            elements.push(with_opacity(incoming_element.clone(), progress));
559        }
560    }
561    for (index, used) in used_incoming.into_iter().enumerate() {
562        if !used && let Some(element) = incoming_page.elements.get(incoming_offset + index) {
563            if let Some(name) = incoming.identities[index]
564                .name
565                .as_deref()
566                .filter(|name| name.starts_with("!!"))
567            {
568                diagnostics.push(Diagnostic {
569                    message: format!("morph shape {name} has no outgoing match and uses crossfade"),
570                });
571            }
572            elements.push(with_opacity(element.clone(), progress));
573        }
574    }
575    incoming_page.elements = elements;
576    TimelinePageFrame {
577        page: incoming_page,
578        diagnostics,
579    }
580}
581
582fn rect_is_finite(rect: Rect) -> bool {
583    [rect.x, rect.y, rect.width, rect.height]
584        .into_iter()
585        .all(f64::is_finite)
586}
587
588fn geometry_compatible((outgoing, incoming): (&ResolvedShape, &ResolvedShape)) -> bool {
589    if !rect_is_finite(outgoing.bounds)
590        || !rect_is_finite(incoming.bounds)
591        || outgoing.bounds.width <= 0.0
592        || outgoing.bounds.height <= 0.0
593        || incoming.bounds.width <= 0.0
594        || incoming.bounds.height <= 0.0
595    {
596        return false;
597    }
598    match (&outgoing.geometry, &incoming.geometry) {
599        (ResolvedGeometry::Rectangle, ResolvedGeometry::Rectangle)
600        | (ResolvedGeometry::BoundsFallback, ResolvedGeometry::BoundsFallback) => true,
601        (
602            ResolvedGeometry::Custom {
603                paths: outgoing_paths,
604                text_rect: outgoing_text_rect,
605            },
606            ResolvedGeometry::Custom {
607                paths: incoming_paths,
608                text_rect: incoming_text_rect,
609            },
610        ) => {
611            outgoing_paths.len() == incoming_paths.len()
612                && outgoing_paths.iter().zip(incoming_paths).all(
613                    |(outgoing_path, incoming_path)| {
614                        outgoing_path.fill_rule == incoming_path.fill_rule
615                            && outgoing_path.commands.len() == incoming_path.commands.len()
616                            && outgoing_path
617                                .commands
618                                .iter()
619                                .zip(&incoming_path.commands)
620                                .all(|(outgoing_command, incoming_command)| {
621                                    path_command_compatible(
622                                        outgoing_command,
623                                        outgoing.bounds,
624                                        incoming_command,
625                                        incoming.bounds,
626                                    )
627                                })
628                    },
629                )
630                && optional_rect_compatible(
631                    *outgoing_text_rect,
632                    outgoing.bounds,
633                    *incoming_text_rect,
634                    incoming.bounds,
635                )
636        }
637        _ => false,
638    }
639}
640
641fn path_command_compatible(
642    outgoing: &PathCommand,
643    outgoing_bounds: Rect,
644    incoming: &PathCommand,
645    incoming_bounds: Rect,
646) -> bool {
647    match (outgoing, incoming) {
648        (PathCommand::MoveTo(outgoing), PathCommand::MoveTo(incoming))
649        | (PathCommand::LineTo(outgoing), PathCommand::LineTo(incoming)) => {
650            point_compatible(*outgoing, outgoing_bounds, *incoming, incoming_bounds)
651        }
652        (
653            PathCommand::CurveTo {
654                c1: outgoing_c1,
655                c2: outgoing_c2,
656                to: outgoing_to,
657            },
658            PathCommand::CurveTo {
659                c1: incoming_c1,
660                c2: incoming_c2,
661                to: incoming_to,
662            },
663        ) => {
664            point_compatible(*outgoing_c1, outgoing_bounds, *incoming_c1, incoming_bounds)
665                && point_compatible(*outgoing_c2, outgoing_bounds, *incoming_c2, incoming_bounds)
666                && point_compatible(*outgoing_to, outgoing_bounds, *incoming_to, incoming_bounds)
667        }
668        (PathCommand::Close, PathCommand::Close) => true,
669        _ => false,
670    }
671}
672
673fn point_compatible(
674    outgoing: oxml_layout::Point,
675    outgoing_bounds: Rect,
676    incoming: oxml_layout::Point,
677    incoming_bounds: Rect,
678) -> bool {
679    normalized_close(
680        outgoing.x / outgoing_bounds.width,
681        incoming.x / incoming_bounds.width,
682    ) && normalized_close(
683        outgoing.y / outgoing_bounds.height,
684        incoming.y / incoming_bounds.height,
685    )
686}
687
688fn optional_rect_compatible(
689    outgoing: Option<Rect>,
690    outgoing_bounds: Rect,
691    incoming: Option<Rect>,
692    incoming_bounds: Rect,
693) -> bool {
694    match (outgoing, incoming) {
695        (None, None) => true,
696        (Some(outgoing), Some(incoming)) => {
697            normalized_close(
698                outgoing.x / outgoing_bounds.width,
699                incoming.x / incoming_bounds.width,
700            ) && normalized_close(
701                outgoing.y / outgoing_bounds.height,
702                incoming.y / incoming_bounds.height,
703            ) && normalized_close(
704                outgoing.width / outgoing_bounds.width,
705                incoming.width / incoming_bounds.width,
706            ) && normalized_close(
707                outgoing.height / outgoing_bounds.height,
708                incoming.height / incoming_bounds.height,
709            )
710        }
711        _ => false,
712    }
713}
714
715fn normalized_close(left: f64, right: f64) -> bool {
716    left.is_finite() && right.is_finite() && (left - right).abs() <= 1e-9
717}
718
719fn interpolate(from: f64, to: f64, progress: f64) -> f64 {
720    from + (to - from) * progress
721}
722
723fn interpolate_transform(from: Transform, to: Transform, progress: f64) -> Transform {
724    Transform {
725        a: interpolate(from.a, to.a, progress),
726        b: interpolate(from.b, to.b, progress),
727        c: interpolate(from.c, to.c, progress),
728        d: interpolate(from.d, to.d, progress),
729        e: interpolate(from.e, to.e, progress),
730        f: interpolate(from.f, to.f, progress),
731    }
732}
733
734fn with_opacity(mut element: PositionedElement, opacity: f64) -> PositionedElement {
735    if let PositionedElement::Group(group) = &mut element {
736        group.opacity *= opacity;
737        return element;
738    }
739    page_group(vec![element], Transform::IDENTITY, opacity, None)
740}
741
742fn page_content(page: &PageFrame) -> Vec<PositionedElement> {
743    let mut elements = page
744        .background
745        .clone()
746        .map(|background| background_element(page.width, page.height, background))
747        .into_iter()
748        .collect::<Vec<_>>();
749    elements.extend(page.elements.clone());
750    elements
751}
752
753fn take_page_content(page: &mut PageFrame) -> Vec<PositionedElement> {
754    let mut elements = page
755        .background
756        .take()
757        .map(|background| background_element(page.width, page.height, background))
758        .into_iter()
759        .collect::<Vec<_>>();
760    elements.append(&mut page.elements);
761    elements
762}
763
764fn background_element(
765    width: f64,
766    height: f64,
767    background: oxml_layout::Paint,
768) -> PositionedElement {
769    PositionedElement::Path(PathElement {
770        path: Path::rect(Rect {
771            x: 0.0,
772            y: 0.0,
773            width,
774            height,
775        }),
776        fill: Some(background),
777        stroke: None,
778    })
779}
780
781fn page_group(
782    children: Vec<PositionedElement>,
783    transform: Transform,
784    opacity: f64,
785    clip: Option<Path>,
786) -> PositionedElement {
787    PositionedElement::Group(GroupElement {
788        transform,
789        clip,
790        opacity,
791        effects: Vec::new(),
792        children,
793    })
794}
795
796fn translation(x: f64, y: f64) -> Transform {
797    Transform {
798        e: x,
799        f: y,
800        ..Transform::IDENTITY
801    }
802}
803
804fn scale_about(scale: f64, center_x: f64, center_y: f64) -> Transform {
805    Transform {
806        a: scale,
807        d: scale,
808        e: center_x * (1.0 - scale),
809        f: center_y * (1.0 - scale),
810        ..Transform::IDENTITY
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use oxml_layout::{
817        FillRule, GroupElement, PageFrame, Path, PathCommand, Point, PositionedElement, Rect,
818        Transform,
819    };
820    use rpptx_layout::timeline::{
821        EvaluatedFrameState, EvaluatedShapeState, EvaluatedTransition, ResolvedShapeIdentity,
822        ResolvedTimelineSlide,
823    };
824    use rpptx_layout::{
825        FlattenedSource, ResolvedContent, ResolvedGeometry, ResolvedShape, ResolvedSlide,
826    };
827    use rpptx_oxml::timing::{TransitionEffect, TransitionParameter};
828
829    use super::{compose_morph_transition, compose_transition};
830
831    #[test]
832    fn fade_transition_keeps_outgoing_opaque_and_fades_incoming_at_exact_progress() {
833        let outgoing = PageFrame::new(
834            1,
835            100.0,
836            50.0,
837            vec![PositionedElement::Group(GroupElement {
838                transform: Transform::IDENTITY,
839                clip: None,
840                opacity: 1.0,
841                effects: Vec::new(),
842                children: Vec::new(),
843            })],
844        );
845        let incoming = PageFrame::new(2, 100.0, 50.0, Vec::new());
846        let frame = compose_transition(
847            incoming,
848            Some(&outgoing),
849            Some(&EvaluatedTransition {
850                effect: TransitionEffect::Fade,
851                progress: 0.25,
852                duration_ms: 1000,
853                parameters: Vec::new(),
854                morph_option: None,
855            }),
856        );
857
858        assert_eq!(frame.page.elements.len(), 2);
859        let PositionedElement::Group(first) = &frame.page.elements[0] else {
860            panic!("expected outgoing group")
861        };
862        let PositionedElement::Group(second) = &frame.page.elements[1] else {
863            panic!("expected incoming group")
864        };
865        assert_eq!(first.opacity, 1.0);
866        assert_eq!(second.opacity, 0.25);
867    }
868
869    #[test]
870    fn cut_wipe_push_and_zoom_have_finite_bounded_compositions() {
871        for effect in [
872            TransitionEffect::Cut,
873            TransitionEffect::Wipe,
874            TransitionEffect::Push,
875            TransitionEffect::Zoom,
876        ] {
877            let outgoing = PageFrame::new(1, 100.0, 50.0, Vec::new());
878            let incoming = PageFrame::new(2, 100.0, 50.0, Vec::new());
879            let frame = compose_transition(
880                incoming,
881                Some(&outgoing),
882                Some(&EvaluatedTransition {
883                    effect: effect.clone(),
884                    progress: 0.5,
885                    duration_ms: 1000,
886                    parameters: Vec::new(),
887                    morph_option: None,
888                }),
889            );
890            assert!(frame.diagnostics.is_empty());
891            if effect != TransitionEffect::Cut {
892                assert_eq!(frame.page.elements.len(), 2);
893            }
894        }
895    }
896
897    #[test]
898    fn fx208_split_cover_uncover_sample_every_direction_and_boundary() {
899        let outgoing = PageFrame::new(1, 100.0, 50.0, Vec::new());
900        let incoming = PageFrame::new(2, 100.0, 50.0, Vec::new());
901        for (effect, directions) in [
902            (
903                TransitionEffect::Cover,
904                &["l", "r", "u", "d", "lu", "ru", "ld", "rd"][..],
905            ),
906            (
907                TransitionEffect::Uncover,
908                &["l", "r", "u", "d", "lu", "ru", "ld", "rd"][..],
909            ),
910            (TransitionEffect::Split, &["in", "out"][..]),
911        ] {
912            for direction in directions {
913                for orient in ["horz", "vert"] {
914                    for progress in [0.0, 0.5, 1.0] {
915                        let frame = compose_transition(
916                            incoming.clone(),
917                            Some(&outgoing),
918                            Some(&EvaluatedTransition {
919                                effect: effect.clone(),
920                                progress,
921                                duration_ms: 1000,
922                                parameters: vec![
923                                    TransitionParameter {
924                                        name: "dir".to_owned(),
925                                        value: (*direction).to_owned(),
926                                    },
927                                    TransitionParameter {
928                                        name: "orient".to_owned(),
929                                        value: orient.to_owned(),
930                                    },
931                                ],
932                                morph_option: None,
933                            }),
934                        );
935                        assert!(frame.diagnostics.is_empty());
936                        assert_eq!(frame.page.page_number, 2);
937                        if progress == 1.0 {
938                            assert_eq!(frame.page.elements, incoming.elements);
939                            continue;
940                        }
941                        assert_eq!(frame.page.elements.len(), 2);
942                        let PositionedElement::Group(first) = &frame.page.elements[0] else {
943                            panic!("first page group")
944                        };
945                        let PositionedElement::Group(second) = &frame.page.elements[1] else {
946                            panic!("second page group")
947                        };
948                        assert_eq!(first.transform, Transform::IDENTITY);
949                        if effect == TransitionEffect::Split {
950                            assert!(first.clip.is_none());
951                            assert!(second.clip.is_some());
952                            assert_eq!(second.transform, Transform::IDENTITY);
953                        } else {
954                            let x = match *direction {
955                                "r" | "ru" | "rd" => -100.0,
956                                "u" | "d" => 0.0,
957                                _ => 100.0,
958                            };
959                            let y = match *direction {
960                                "u" | "lu" | "ru" => 50.0,
961                                "d" | "ld" | "rd" => -50.0,
962                                _ => 0.0,
963                            };
964                            let factor = if effect == TransitionEffect::Cover {
965                                1.0 - f64::from(progress)
966                            } else {
967                                -f64::from(progress)
968                            };
969                            assert_eq!(
970                                (second.transform.e, second.transform.f),
971                                (x * factor, y * factor)
972                            );
973                            assert!(first.clip.is_none() && second.clip.is_none());
974                        }
975                    }
976                }
977            }
978        }
979    }
980
981    #[test]
982    fn invalid_transition_directions_diagnose_without_composition() {
983        for (effect, name) in [
984            (TransitionEffect::Wipe, "wipe"),
985            (TransitionEffect::Push, "push"),
986            (TransitionEffect::Zoom, "zoom"),
987            (TransitionEffect::Split, "split"),
988            (TransitionEffect::Cover, "cover"),
989            (TransitionEffect::Uncover, "uncover"),
990        ] {
991            for progress in [0.5, 1.0] {
992                let incoming = PageFrame::new(
993                    2,
994                    100.0,
995                    50.0,
996                    vec![PositionedElement::Group(GroupElement {
997                        transform: Transform::IDENTITY,
998                        clip: None,
999                        opacity: 1.0,
1000                        effects: Vec::new(),
1001                        children: Vec::new(),
1002                    })],
1003                );
1004                let frame = compose_transition(
1005                    incoming.clone(),
1006                    Some(&PageFrame::new(1, 100.0, 50.0, Vec::new())),
1007                    Some(&EvaluatedTransition {
1008                        effect: effect.clone(),
1009                        progress,
1010                        duration_ms: 1000,
1011                        parameters: vec![TransitionParameter {
1012                            name: "dir".to_owned(),
1013                            value: "diagonal".to_owned(),
1014                        }],
1015                        morph_option: None,
1016                    }),
1017                );
1018
1019                assert_eq!(frame.page.width, incoming.width);
1020                assert_eq!(frame.page.height, incoming.height);
1021                assert_eq!(frame.page.elements, incoming.elements);
1022                assert_eq!(
1023                    frame.diagnostics[0].message,
1024                    format!("unsupported {name} transition direction diagonal")
1025                );
1026            }
1027        }
1028
1029        let other = compose_transition(
1030            PageFrame::new(2, 100.0, 50.0, Vec::new()),
1031            None,
1032            Some(&EvaluatedTransition {
1033                effect: TransitionEffect::Other("producer".to_owned()),
1034                progress: 1.0,
1035                duration_ms: 1000,
1036                parameters: Vec::new(),
1037                morph_option: None,
1038            }),
1039        );
1040        assert_eq!(other.diagnostics[0].message, "unsupported slide transition");
1041    }
1042
1043    #[test]
1044    fn explicit_name_morph_interpolates_compatible_shape_groups() {
1045        let mut outgoing_timeline = timeline_slide("!!Hero", 0.0);
1046        outgoing_timeline.slide.shapes[0].bounds.width = 20.0;
1047        outgoing_timeline.slide.shapes[0].bounds.height = 30.0;
1048        let incoming_timeline = timeline_slide("!!Hero", 100.0);
1049        let outgoing_page = page_with_translation(0.0);
1050        let incoming_page = page_with_translation(100.0);
1051        let frame = compose_morph_transition(
1052            incoming_page,
1053            &incoming_timeline,
1054            &outgoing_page,
1055            &outgoing_timeline,
1056            &EvaluatedTransition {
1057                effect: TransitionEffect::Morph,
1058                progress: 0.5,
1059                duration_ms: 1000,
1060                parameters: Vec::new(),
1061                morph_option: Some("byObject".to_owned()),
1062            },
1063        );
1064
1065        assert!(frame.diagnostics.is_empty());
1066        let PositionedElement::Group(group) = &frame.page.elements[0] else {
1067            panic!("morph pair should remain one group")
1068        };
1069        assert_eq!(group.transform, Transform::IDENTITY);
1070        assert_eq!(group.children.len(), 2);
1071        let PositionedElement::Group(outgoing_group) = &group.children[0] else {
1072            panic!("morph pair should retain outgoing content")
1073        };
1074        let PositionedElement::Group(incoming_group) = &group.children[1] else {
1075            panic!("morph pair should retain incoming content")
1076        };
1077        assert_eq!(outgoing_group.transform.e, 50.0);
1078        assert_eq!(outgoing_group.transform.a, 0.75);
1079        assert_eq!(outgoing_group.transform.d, 2.0 / 3.0);
1080        assert_eq!(outgoing_group.opacity, 1.0);
1081        assert_eq!(incoming_group.transform.e, 50.0);
1082        assert_eq!(incoming_group.transform.a, 1.5);
1083        assert_eq!(incoming_group.transform.d, 2.0);
1084        assert_eq!(incoming_group.opacity, 0.5);
1085    }
1086
1087    #[test]
1088    fn compatible_morph_keeps_outgoing_content_at_progress_zero() {
1089        let outgoing_timeline = timeline_slide("!!Hero", 0.0);
1090        let incoming_timeline = timeline_slide("!!Hero", 100.0);
1091        let outgoing_page = page_with_marker(0.0, 7.0);
1092        let incoming_page = page_with_marker(100.0, 9.0);
1093        let frame = compose_morph_transition(
1094            incoming_page,
1095            &incoming_timeline,
1096            &outgoing_page,
1097            &outgoing_timeline,
1098            &EvaluatedTransition {
1099                effect: TransitionEffect::Morph,
1100                progress: 0.0,
1101                duration_ms: 1000,
1102                parameters: Vec::new(),
1103                morph_option: Some("byObject".to_owned()),
1104            },
1105        );
1106
1107        let PositionedElement::Group(pair) = &frame.page.elements[0] else {
1108            panic!("morph pair should remain one group")
1109        };
1110        let PositionedElement::Group(outgoing) = &pair.children[0] else {
1111            panic!("morph pair should retain outgoing content")
1112        };
1113        let PositionedElement::Group(incoming) = &pair.children[1] else {
1114            panic!("morph pair should retain incoming content")
1115        };
1116        let PositionedElement::Group(outgoing_marker) = &outgoing.children[0] else {
1117            panic!("expected outgoing marker")
1118        };
1119        let PositionedElement::Group(incoming_marker) = &incoming.children[0] else {
1120            panic!("expected incoming marker")
1121        };
1122        assert_eq!(outgoing.opacity, 1.0);
1123        assert_eq!(incoming.opacity, 0.0);
1124        assert_eq!(outgoing_marker.transform.e, 7.0);
1125        assert_eq!(incoming_marker.transform.e, 9.0);
1126    }
1127
1128    #[test]
1129    fn word_and_character_morph_options_crossfade_with_diagnostics() {
1130        for option in ["byWord", "byChar"] {
1131            let outgoing_timeline = timeline_slide("!!Hero", 0.0);
1132            let incoming_timeline = timeline_slide("!!Hero", 100.0);
1133            let frame = compose_morph_transition(
1134                page_with_translation(100.0),
1135                &incoming_timeline,
1136                &page_with_translation(0.0),
1137                &outgoing_timeline,
1138                &EvaluatedTransition {
1139                    effect: TransitionEffect::Morph,
1140                    progress: 0.25,
1141                    duration_ms: 1000,
1142                    parameters: Vec::new(),
1143                    morph_option: Some(option.to_owned()),
1144                },
1145            );
1146
1147            assert_eq!(frame.page.elements.len(), 2);
1148            let PositionedElement::Group(outgoing) = &frame.page.elements[0] else {
1149                panic!("expected outgoing crossfade group")
1150            };
1151            let PositionedElement::Group(incoming) = &frame.page.elements[1] else {
1152                panic!("expected incoming crossfade group")
1153            };
1154            assert_eq!(outgoing.opacity, 1.0);
1155            assert_eq!(incoming.opacity, 0.25);
1156            assert_eq!(
1157                frame.diagnostics[0].message,
1158                format!("unsupported morph option {option} uses crossfade")
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn zoom_in_and_out_transform_opposite_pages() {
1165        let compose = |direction: &str| {
1166            compose_transition(
1167                PageFrame::new(2, 100.0, 50.0, Vec::new()),
1168                Some(&PageFrame::new(1, 100.0, 50.0, Vec::new())),
1169                Some(&EvaluatedTransition {
1170                    effect: TransitionEffect::Zoom,
1171                    progress: 0.5,
1172                    duration_ms: 1000,
1173                    parameters: vec![TransitionParameter {
1174                        name: "dir".to_owned(),
1175                        value: direction.to_owned(),
1176                    }],
1177                    morph_option: None,
1178                }),
1179            )
1180        };
1181
1182        let zoom_in = compose("in");
1183        let zoom_out = compose("out");
1184        let PositionedElement::Group(in_outgoing) = &zoom_in.page.elements[0] else {
1185            panic!("expected zoom-in outgoing group")
1186        };
1187        let PositionedElement::Group(in_incoming) = &zoom_in.page.elements[1] else {
1188            panic!("expected zoom-in incoming group")
1189        };
1190        let PositionedElement::Group(out_outgoing) = &zoom_out.page.elements[0] else {
1191            panic!("expected zoom-out outgoing group")
1192        };
1193        let PositionedElement::Group(out_incoming) = &zoom_out.page.elements[1] else {
1194            panic!("expected zoom-out incoming group")
1195        };
1196        assert_eq!(in_outgoing.transform.a, 1.0);
1197        assert_eq!(in_incoming.transform.a, 0.75);
1198        assert_eq!(out_outgoing.transform.a, 0.75);
1199        assert_eq!(out_incoming.transform.a, 1.0);
1200        let default = compose_transition(
1201            PageFrame::new(2, 100.0, 50.0, Vec::new()),
1202            Some(&PageFrame::new(1, 100.0, 50.0, Vec::new())),
1203            Some(&EvaluatedTransition {
1204                effect: TransitionEffect::Zoom,
1205                progress: 0.5,
1206                duration_ms: 1000,
1207                parameters: Vec::new(),
1208                morph_option: None,
1209            }),
1210        );
1211        assert_eq!(default.page.elements, zoom_out.page.elements);
1212    }
1213
1214    #[test]
1215    fn morph_reports_explicit_names_missing_on_either_slide() {
1216        let outgoing_timeline = timeline_slide("!!Outgoing", 0.0);
1217        let incoming_timeline = timeline_slide("!!Incoming", 100.0);
1218        let frame = compose_morph_transition(
1219            page_with_translation(100.0),
1220            &incoming_timeline,
1221            &page_with_translation(0.0),
1222            &outgoing_timeline,
1223            &EvaluatedTransition {
1224                effect: TransitionEffect::Morph,
1225                progress: 0.5,
1226                duration_ms: 1000,
1227                parameters: Vec::new(),
1228                morph_option: Some("byObject".to_owned()),
1229            },
1230        );
1231
1232        assert_eq!(frame.page.elements.len(), 2);
1233        assert_eq!(frame.diagnostics.len(), 2);
1234        assert!(
1235            frame
1236                .diagnostics
1237                .iter()
1238                .any(|diagnostic| diagnostic.message.contains("!!Outgoing")
1239                    && diagnostic.message.contains("no incoming match"))
1240        );
1241        assert!(
1242            frame
1243                .diagnostics
1244                .iter()
1245                .any(|diagnostic| diagnostic.message.contains("!!Incoming")
1246                    && diagnostic.message.contains("no outgoing match"))
1247        );
1248    }
1249
1250    #[test]
1251    fn morph_crossfades_custom_geometries_with_different_path_structure() {
1252        let mut outgoing_timeline = timeline_slide("!!Hero", 0.0);
1253        outgoing_timeline.slide.shapes[0].geometry = ResolvedGeometry::Custom {
1254            paths: vec![Path {
1255                commands: vec![
1256                    PathCommand::MoveTo(Point { x: 0.0, y: 0.0 }),
1257                    PathCommand::LineTo(Point { x: 10.0, y: 0.0 }),
1258                    PathCommand::LineTo(Point { x: 5.0, y: 10.0 }),
1259                    PathCommand::Close,
1260                ],
1261                fill_rule: FillRule::NonZero,
1262            }],
1263            text_rect: None,
1264        };
1265        let mut incoming_timeline = timeline_slide("!!Hero", 100.0);
1266        incoming_timeline.slide.shapes[0].geometry = ResolvedGeometry::Custom {
1267            paths: vec![Path::rect(Rect {
1268                x: 0.0,
1269                y: 0.0,
1270                width: 10.0,
1271                height: 10.0,
1272            })],
1273            text_rect: None,
1274        };
1275        let frame = compose_morph_transition(
1276            page_with_translation(100.0),
1277            &incoming_timeline,
1278            &page_with_translation(0.0),
1279            &outgoing_timeline,
1280            &EvaluatedTransition {
1281                effect: TransitionEffect::Morph,
1282                progress: 0.5,
1283                duration_ms: 1000,
1284                parameters: Vec::new(),
1285                morph_option: Some("byObject".to_owned()),
1286            },
1287        );
1288
1289        assert_eq!(frame.page.elements.len(), 2);
1290        assert_eq!(frame.diagnostics.len(), 1);
1291        assert!(
1292            frame.diagnostics[0]
1293                .message
1294                .contains("incompatible geometry")
1295        );
1296    }
1297
1298    fn timeline_slide(name: &str, x: f64) -> ResolvedTimelineSlide {
1299        ResolvedTimelineSlide {
1300            slide: ResolvedSlide {
1301                unresolved_member_count: 0,
1302                size: (100.0, 50.0),
1303                background: None,
1304                shapes: vec![ResolvedShape {
1305                    group_transform: Transform::IDENTITY,
1306                    bounds: Rect {
1307                        x,
1308                        y: 0.0,
1309                        width: 10.0,
1310                        height: 10.0,
1311                    },
1312                    rotation_deg: 0.0,
1313                    flip_h: false,
1314                    flip_v: false,
1315                    geometry: ResolvedGeometry::Rectangle,
1316                    fill: None,
1317                    image_fill: None,
1318                    line: None,
1319                    head_end: None,
1320                    tail_end: None,
1321                    shadow: None,
1322                    content: ResolvedContent::None,
1323                    unsupported: None,
1324                }],
1325                diagnostics: Vec::new(),
1326            },
1327            identities: vec![ResolvedShapeIdentity {
1328                source: FlattenedSource::Slide,
1329                shape_id: Some(2),
1330                containing_group_ids: Vec::new(),
1331                name: Some(name.to_owned()),
1332            }],
1333            shape_states: vec![EvaluatedShapeState::default()],
1334            state: EvaluatedFrameState::default(),
1335        }
1336    }
1337
1338    fn page_with_translation(x: f64) -> PageFrame {
1339        PageFrame::new(
1340            1,
1341            100.0,
1342            50.0,
1343            vec![PositionedElement::Group(GroupElement {
1344                transform: Transform {
1345                    e: x,
1346                    ..Transform::IDENTITY
1347                },
1348                clip: None,
1349                opacity: 1.0,
1350                effects: Vec::new(),
1351                children: Vec::new(),
1352            })],
1353        )
1354    }
1355
1356    fn page_with_marker(x: f64, marker_x: f64) -> PageFrame {
1357        let mut page = page_with_translation(x);
1358        let PositionedElement::Group(group) = &mut page.elements[0] else {
1359            unreachable!("page helper creates a group")
1360        };
1361        group.children.push(PositionedElement::Group(GroupElement {
1362            transform: Transform {
1363                e: marker_x,
1364                ..Transform::IDENTITY
1365            },
1366            clip: None,
1367            opacity: 1.0,
1368            effects: Vec::new(),
1369            children: Vec::new(),
1370        }));
1371        page
1372    }
1373
1374    #[test]
1375    fn fx206_recovery_timeline_companion_reports_count_and_owner_order_once() {
1376        for placed in [false, true] {
1377            let mut timeline = timeline_slide("failed", 0.0);
1378            if placed {
1379                timeline.slide.shapes[0].geometry = ResolvedGeometry::BoundsFallback;
1380                timeline.slide.shapes[0].unsupported = Some("unresolved shape");
1381            } else {
1382                timeline.slide.shapes.clear();
1383                timeline.identities.clear();
1384                timeline.shape_states.clear();
1385            }
1386            timeline.slide.unresolved_member_count = 1;
1387            timeline.slide.diagnostics = vec![
1388                oxml_layout::Diagnostic {
1389                    message: "valid sibling diagnostic".into(),
1390                },
1391                oxml_layout::Diagnostic {
1392                    message: "unresolved member slide shape 2 \"failed\"".into(),
1393                },
1394            ];
1395            let original = timeline.slide.diagnostics.clone();
1396            let input = crate::RenderInput {
1397                slides: vec![timeline.slide.clone(), timeline.slide.clone()],
1398                media: std::collections::HashMap::new(),
1399                fonts: Vec::new(),
1400                metadata: None,
1401            };
1402            let mut fonts = oxml_layout::FontManager::new_deterministic().unwrap();
1403            let frame = super::layout_timeline_slide_with_diagnostics(
1404                &input, 1, &timeline, None, &mut fonts,
1405            )
1406            .unwrap();
1407            assert_eq!(
1408                frame
1409                    .diagnostics
1410                    .iter()
1411                    .map(|d| d.message.as_str())
1412                    .collect::<Vec<_>>(),
1413                vec![
1414                    "slide 2 incomplete: 1 unresolved members",
1415                    "valid sibling diagnostic",
1416                    "unresolved member slide shape 2 \"failed\""
1417                ]
1418            );
1419            assert_eq!(timeline.slide.diagnostics, original);
1420            assert_eq!(frame.page.elements.is_empty(), !placed);
1421            assert!(
1422                super::layout_timeline_slide_with_diagnostics(
1423                    &input, 2, &timeline, None, &mut fonts
1424                )
1425                .is_err()
1426            );
1427        }
1428        let timeline = timeline_slide("valid", 0.0);
1429        let input = crate::RenderInput {
1430            slides: vec![timeline.slide.clone()],
1431            media: std::collections::HashMap::new(),
1432            fonts: Vec::new(),
1433            metadata: None,
1434        };
1435        let mut fonts = oxml_layout::FontManager::new_deterministic().unwrap();
1436        assert!(
1437            super::layout_timeline_slide_with_diagnostics(&input, 0, &timeline, None, &mut fonts)
1438                .unwrap()
1439                .diagnostics
1440                .is_empty()
1441        );
1442    }
1443}