1use 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#[derive(Clone, Debug)]
17pub struct TimelinePageFrame {
18 pub page: PageFrame,
19 pub diagnostics: Vec<Diagnostic>,
20}
21
22pub 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#[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
66pub 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
276pub 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}