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::{RenderInput, RenderInputError, layout_resolved_slide_with_diagnostics};
12
13#[derive(Clone, Debug)]
15pub struct TimelinePageFrame {
16 pub page: PageFrame,
17 pub diagnostics: Vec<Diagnostic>,
18}
19
20pub 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#[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#[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
64pub 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 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
118pub 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
418pub 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}