1use std::collections::HashMap;
4
5use oxml_layout::{Diagnostic, Path, PathCommand, Point, Rect, Transform};
6use rpptx_oxml::timing::{
7 CT_SlideTransition, CT_Timing, CommonTimeNode, MediaCommandKind, TimingAnimate,
8 TimingCondition, TimingContainer, TimingDuration, TimingEffect, TimingEvent, TimingFill,
9 TimingMotionPath, TimingNode, TimingNodeType, TimingSequence, TimingSet, TimingTarget,
10 TransitionEffect, TransitionParameter, TransitionSpeed,
11};
12
13use crate::{FlattenedSource, ResolveError, ResolvedSlide};
14
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub struct TimelinePosition {
18 pub elapsed_ms: u64,
19 pub click_count: u32,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum MediaPlaybackPhase {
25 Stopped,
26 Playing,
27 Paused,
28}
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct EvaluatedMediaState {
33 pub shape_id: u32,
34 pub phase: MediaPlaybackPhase,
35 pub source_position_ms: u64,
36 pub volume: f32,
37 pub looping: bool,
38}
39
40#[derive(Clone, Debug, PartialEq)]
42pub struct EvaluatedShapeState {
43 pub visible: bool,
44 pub opacity: f32,
45 pub transform: Transform,
46 pub clip: Option<Rect>,
48 animation: EvaluatedAnimationTransform,
49 oriented_clips: Vec<[Point; 4]>,
50}
51
52impl Default for EvaluatedShapeState {
53 fn default() -> Self {
54 Self {
55 visible: true,
56 opacity: 1.0,
57 transform: Transform::IDENTITY,
58 clip: None,
59 animation: EvaluatedAnimationTransform::default(),
60 oriented_clips: Vec::new(),
61 }
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66struct EvaluatedAnimationTransform {
67 scale_x: f64,
68 scale_y: f64,
69 rotation_deg: f64,
70 motion: Option<EvaluatedMotion>,
71}
72
73impl Default for EvaluatedAnimationTransform {
74 fn default() -> Self {
75 Self {
76 scale_x: 1.0,
77 scale_y: 1.0,
78 rotation_deg: 0.0,
79 motion: None,
80 }
81 }
82}
83
84#[derive(Clone, Copy, Debug, PartialEq)]
85struct EvaluatedMotion {
86 x: f64,
87 y: f64,
88 origin: MotionOrigin,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq)]
92enum MotionOrigin {
93 Parent,
94 Layout,
95}
96
97impl EvaluatedShapeState {
98 pub(crate) fn push_oriented_clip(&mut self, points: [Point; 4]) {
99 self.oriented_clips.push(points);
100 }
101
102 pub fn local_clip_paths(&self, size: (f64, f64)) -> Vec<Path> {
104 let scale = |point: Point| Point {
105 x: point.x * size.0,
106 y: point.y * size.1,
107 };
108 let mut paths = Vec::new();
109 if let Some(clip) = self.clip {
110 paths.push(Path::rect(Rect {
111 x: clip.x * size.0,
112 y: clip.y * size.1,
113 width: clip.width * size.0,
114 height: clip.height * size.1,
115 }));
116 }
117 for points in &self.oriented_clips {
118 paths.push(Path {
119 commands: vec![
120 PathCommand::MoveTo(scale(points[0])),
121 PathCommand::LineTo(scale(points[1])),
122 PathCommand::LineTo(scale(points[2])),
123 PathCommand::LineTo(scale(points[3])),
124 PathCommand::Close,
125 ],
126 fill_rule: oxml_layout::FillRule::NonZero,
127 });
128 }
129 paths
130 }
131
132 fn rebuild_public_transform(&mut self) {
133 let scale = scale_about(self.animation.scale_x, self.animation.scale_y, 0.5, 0.5);
134 let rotation = Transform::rotate_about(self.animation.rotation_deg, 0.5, 0.5);
135 let motion = self.animation.motion.map_or(Transform::IDENTITY, |motion| {
136 translation(motion.x, motion.y)
137 });
138 self.transform = scale.then(rotation).then(motion);
139 }
140
141 pub(crate) fn resolved_shape_geometry(&self) -> (f64, f64, f64) {
142 (
143 self.animation.scale_x,
144 self.animation.scale_y,
145 self.animation.rotation_deg,
146 )
147 }
148
149 pub(crate) fn resolved_page_transform(
150 &self,
151 center: (f64, f64),
152 slide_size: (f64, f64),
153 ) -> Transform {
154 let scale = scale_about(
155 self.animation.scale_x,
156 self.animation.scale_y,
157 center.0,
158 center.1,
159 );
160 let rotation = Transform::rotate_about(self.animation.rotation_deg, center.0, center.1);
161 let motion = self.resolved_motion_translation(center, slide_size);
162 scale.then(rotation).then(motion)
163 }
164
165 pub(crate) fn resolved_motion_translation(
166 &self,
167 center: (f64, f64),
168 slide_size: (f64, f64),
169 ) -> Transform {
170 self.animation.motion.map_or(Transform::IDENTITY, |motion| {
171 let (x, y) = match motion.origin {
172 MotionOrigin::Parent => (
173 motion.x * slide_size.0 - center.0,
174 motion.y * slide_size.1 - center.1,
175 ),
176 MotionOrigin::Layout => (motion.x * slide_size.0, motion.y * slide_size.1),
177 };
178 translation(x, y)
179 })
180 }
181
182 pub(crate) fn is_finite(&self) -> bool {
183 self.opacity.is_finite()
184 && transform_is_finite(self.transform)
185 && self.clip.is_none_or(rect_is_finite)
186 && self.oriented_clips.iter().all(|points| {
187 points
188 .iter()
189 .all(|point| point.x.is_finite() && point.y.is_finite())
190 })
191 && [
192 self.animation.scale_x,
193 self.animation.scale_y,
194 self.animation.rotation_deg,
195 ]
196 .into_iter()
197 .all(f64::is_finite)
198 && self
199 .animation
200 .motion
201 .is_none_or(|motion| motion.x.is_finite() && motion.y.is_finite())
202 }
203}
204
205#[derive(Clone, Debug, PartialEq)]
207pub struct EvaluatedTransition {
208 pub effect: TransitionEffect,
209 pub progress: f32,
210 pub duration_ms: u64,
211 pub parameters: Vec<TransitionParameter>,
212 pub morph_option: Option<String>,
213}
214
215#[derive(Clone, Debug, Default, PartialEq)]
217pub struct EvaluatedFrameState {
218 pub shapes: HashMap<u32, EvaluatedShapeState>,
219 pub transition: Option<EvaluatedTransition>,
220 pub diagnostics: Vec<Diagnostic>,
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct ResolvedShapeIdentity {
226 pub source: FlattenedSource,
227 pub shape_id: Option<u32>,
228 pub containing_group_ids: Vec<u32>,
229 pub name: Option<String>,
230}
231
232#[derive(Clone, Debug, PartialEq)]
234pub struct ResolvedTimelineSlide {
235 pub slide: ResolvedSlide,
236 pub identities: Vec<ResolvedShapeIdentity>,
237 pub shape_states: Vec<EvaluatedShapeState>,
238 pub state: EvaluatedFrameState,
239}
240
241pub fn evaluate_timeline(
243 timing: Option<&CT_Timing>,
244 transition: Option<&CT_SlideTransition>,
245 position: TimelinePosition,
246) -> Result<EvaluatedFrameState, ResolveError> {
247 let mut state = EvaluatedFrameState {
248 transition: transition.and_then(|transition| evaluate_transition(transition, position)),
249 ..EvaluatedFrameState::default()
250 };
251 if let Some(timing) = timing {
252 let click_schedule = ClickSchedule::new(timing);
253 let mut context = EvaluationContext {
254 timing,
255 position,
256 click_schedule: &click_schedule,
257 state: &mut state,
258 };
259 evaluate_nodes(
260 timing.nodes(),
261 0,
262 u64::MAX,
263 Scheduling::Parallel,
264 &mut context,
265 );
266 }
267 let invalid_targets = state
268 .shapes
269 .iter()
270 .filter_map(|(target, evaluated)| (!evaluated.is_finite()).then_some(*target))
271 .collect::<Vec<_>>();
272 for target in invalid_targets {
273 state.shapes.insert(target, EvaluatedShapeState::default());
274 state.diagnostics.push(Diagnostic {
275 message: format!("non-finite timeline state ignored for target {target}"),
276 });
277 }
278 Ok(state)
279}
280
281pub fn evaluate_media_playback(
286 timing: Option<&CT_Timing>,
287 shape_id: u32,
288 trim_start_ms: Option<u64>,
289 trim_end_ms: Option<u64>,
290 position: TimelinePosition,
291) -> (EvaluatedMediaState, Vec<Diagnostic>) {
292 let trim_start = trim_start_ms.unwrap_or(0);
293 let mut diagnostics = Vec::new();
294 let mut volume = 1.0;
295 let mut looping = false;
296 let mut known_duration = None;
297 if let Some(media) = timing.and_then(|timing| timing.media_for_shape(shape_id)) {
298 volume = media.common.volume.unwrap_or(100_000).min(100_000) as f32 / 100_000.0;
299 looping = media.common.looped;
300 if let TimingDuration::Finite(duration) = media.common.common.duration {
301 known_duration = Some(duration);
302 }
303 }
304 let trim_end = trim_end_ms.or(known_duration);
305 let interval_end = trim_end.filter(|end| *end > trim_start);
306 if trim_end.is_some() && interval_end.is_none() {
307 diagnostics.push(Diagnostic {
308 message: format!(
309 "media shape {shape_id} has a trim end that is not after its trim start"
310 ),
311 });
312 }
313 if looping && interval_end.is_none() {
314 diagnostics.push(Diagnostic {
315 message: format!(
316 "looping media shape {shape_id} has no finite positive playback interval"
317 ),
318 });
319 }
320
321 let mut events = Vec::new();
322 if let Some(timing) = timing {
323 let click_schedule = ClickSchedule::new(timing);
324 collect_media_events(
325 timing,
326 timing.nodes(),
327 0,
328 u64::MAX,
329 Scheduling::Parallel,
330 shape_id,
331 position,
332 &click_schedule,
333 &mut events,
334 &mut diagnostics,
335 );
336 }
337 events.sort_by_key(|event| (event.start_ms, event.source_order));
338
339 let mut phase = MediaPlaybackPhase::Stopped;
340 let mut source_position_ms = trim_start;
341 let mut seeked_while_stopped = false;
342 let mut anchor_ms = 0;
343 for event in events
344 .into_iter()
345 .filter(|event| event.start_ms <= position.elapsed_ms)
346 {
347 let completed = advance_media_position(
348 &mut source_position_ms,
349 &mut phase,
350 anchor_ms,
351 event.start_ms,
352 trim_start,
353 interval_end,
354 looping,
355 shape_id,
356 &mut diagnostics,
357 );
358 if completed {
359 seeked_while_stopped = false;
360 }
361 anchor_ms = event.start_ms;
362 match event.command {
363 MediaCommandKind::Play { from_ms } => {
364 if let Some(from_ms) = from_ms {
365 source_position_ms =
366 clamp_media_position(*from_ms, trim_start, interval_end, looping);
367 } else if matches!(phase, MediaPlaybackPhase::Stopped) && !seeked_while_stopped {
368 source_position_ms = trim_start;
369 }
370 phase = MediaPlaybackPhase::Playing;
371 seeked_while_stopped = false;
372 }
373 MediaCommandKind::Pause => {
374 if matches!(phase, MediaPlaybackPhase::Playing) {
375 phase = MediaPlaybackPhase::Paused;
376 }
377 }
378 MediaCommandKind::Stop => {
379 phase = MediaPlaybackPhase::Stopped;
380 source_position_ms = trim_start;
381 seeked_while_stopped = false;
382 }
383 MediaCommandKind::Seek { position_ms } => {
384 source_position_ms =
385 clamp_media_position(*position_ms, trim_start, interval_end, looping);
386 seeked_while_stopped = matches!(phase, MediaPlaybackPhase::Stopped);
387 }
388 MediaCommandKind::Other(command) => diagnostics.push(Diagnostic {
389 message: format!(
390 "unsupported media command `{command}` for media shape {shape_id}"
391 ),
392 }),
393 }
394 }
395 advance_media_position(
396 &mut source_position_ms,
397 &mut phase,
398 anchor_ms,
399 position.elapsed_ms,
400 trim_start,
401 interval_end,
402 looping,
403 shape_id,
404 &mut diagnostics,
405 );
406 (
407 EvaluatedMediaState {
408 shape_id,
409 phase,
410 source_position_ms,
411 volume,
412 looping,
413 },
414 diagnostics,
415 )
416}
417
418#[derive(Clone, Copy)]
419struct ScheduledMediaEvent<'a> {
420 start_ms: u64,
421 source_order: usize,
422 command: &'a MediaCommandKind,
423}
424
425#[allow(clippy::too_many_arguments)]
426fn collect_media_events<'a>(
427 timing: &'a CT_Timing,
428 nodes: &'a [TimingNode],
429 parent_start: u64,
430 parent_end: u64,
431 scheduling: Scheduling,
432 shape_id: u32,
433 position: TimelinePosition,
434 click_schedule: &ClickSchedule,
435 events: &mut Vec<ScheduledMediaEvent<'a>>,
436 diagnostics: &mut Vec<Diagnostic>,
437) -> u64 {
438 let mut cursor = parent_start;
439 let mut previous = None;
440 let mut latest_end = parent_start;
441 for node in nodes {
442 let Some(common) = media_schedule_common(node) else {
443 continue;
444 };
445 if matches!(node, TimingNode::Audio(_) | TimingNode::Video(_)) {
446 continue;
447 }
448 let suggested = match (scheduling, common.node_type.as_ref(), previous) {
449 (Scheduling::Sequence, Some(TimingNodeType::WithEffect), Some((start, _))) => start,
450 (Scheduling::Sequence, Some(TimingNodeType::AfterEffect), Some((_, end))) => end,
451 (Scheduling::Sequence, _, _) => cursor,
452 (Scheduling::Parallel, _, _) => parent_start,
453 };
454 let diagnose = timing_node_contains_media_command(node, shape_id);
455 let start = scheduled_media_start(
456 timing,
457 common,
458 suggested,
459 shape_id,
460 position,
461 click_schedule,
462 diagnose,
463 diagnostics,
464 );
465 let natural_end = match common.duration {
466 TimingDuration::Finite(duration) => start.saturating_add(duration),
467 TimingDuration::Indefinite => u64::MAX,
468 }
469 .min(parent_end);
470 let end = match node {
471 TimingNode::Parallel(container) => collect_media_events(
472 timing,
473 &container.common.children,
474 start,
475 natural_end,
476 Scheduling::Parallel,
477 shape_id,
478 position,
479 click_schedule,
480 events,
481 diagnostics,
482 )
483 .min(natural_end),
484 TimingNode::Sequence(sequence) => collect_media_events(
485 timing,
486 &sequence.common.children,
487 start,
488 natural_end,
489 Scheduling::Sequence,
490 shape_id,
491 position,
492 click_schedule,
493 events,
494 diagnostics,
495 )
496 .min(natural_end),
497 TimingNode::MediaCommand(command)
498 if command.target == TimingTarget::Shape(shape_id) && start != u64::MAX =>
499 {
500 events.push(ScheduledMediaEvent {
501 start_ms: start,
502 source_order: events.len(),
503 command: &command.command,
504 });
505 natural_end
506 }
507 _ => natural_end,
508 };
509 previous = Some((start, end));
510 latest_end = latest_end.max(end);
511 if matches!(scheduling, Scheduling::Sequence)
512 && !matches!(common.node_type, Some(TimingNodeType::WithEffect))
513 {
514 cursor = end;
515 }
516 }
517 latest_end.min(parent_end)
518}
519
520fn media_schedule_common(node: &TimingNode) -> Option<&CommonTimeNode> {
521 match node {
522 TimingNode::Parallel(node) => Some(&node.common),
523 TimingNode::Sequence(node) => Some(&node.common),
524 TimingNode::Set(node) => Some(&node.common),
525 TimingNode::Animate(node) => Some(&node.common),
526 TimingNode::Effect(node) => Some(&node.common),
527 TimingNode::Motion(node) => Some(&node.common),
528 TimingNode::Audio(node) | TimingNode::Video(node) => Some(&node.common.common),
529 TimingNode::MediaCommand(node) => Some(&node.common),
530 TimingNode::Unsupported(_) => None,
531 }
532}
533
534fn timing_node_contains_media_command(node: &TimingNode, shape_id: u32) -> bool {
535 if matches!(node, TimingNode::MediaCommand(command) if command.target == TimingTarget::Shape(shape_id))
536 {
537 return true;
538 }
539 media_schedule_common(node).is_some_and(|common| {
540 common
541 .children
542 .iter()
543 .any(|child| timing_node_contains_media_command(child, shape_id))
544 })
545}
546
547#[allow(clippy::too_many_arguments)]
548fn scheduled_media_start(
549 timing: &CT_Timing,
550 common: &CommonTimeNode,
551 suggested: u64,
552 shape_id: u32,
553 position: TimelinePosition,
554 click_schedule: &ClickSchedule,
555 diagnose: bool,
556 diagnostics: &mut Vec<Diagnostic>,
557) -> u64 {
558 let click_effect = matches!(common.node_type, Some(TimingNodeType::ClickEffect));
559 let click_available = click_schedule.is_available(common, position.click_count);
560 if click_effect && !click_available {
561 return u64::MAX;
562 }
563 if common.start_conditions.is_empty() {
564 return suggested;
565 }
566 let start = common
567 .start_conditions
568 .iter()
569 .enumerate()
570 .filter(|(_, condition)| {
571 !matches!(
572 condition.event,
573 Some(TimingEvent::OnClick | TimingEvent::OnNext)
574 ) || click_available
575 })
576 .filter_map(|(index, condition)| {
577 let supported_event = matches!(
578 condition.event,
579 None | Some(TimingEvent::OnBegin)
580 | Some(TimingEvent::OnClick)
581 | Some(TimingEvent::OnNext)
582 );
583 let supported_target = matches!(condition.target, TimingTarget::Slide)
584 || matches!(condition.target, TimingTarget::Shape(id) if id == shape_id)
585 || matches!(condition.target, TimingTarget::Unsupported)
586 && timing.condition_has_explicit_target(common.id, false, index) == Some(false);
587 if !supported_event || !supported_target {
588 if diagnose {
589 diagnostics.push(Diagnostic {
590 message: format!("unsupported playback trigger for media shape {shape_id}"),
591 });
592 }
593 return None;
594 }
595 match condition.delay {
596 TimingDuration::Finite(delay) => suggested.checked_add(delay).or_else(|| {
597 if diagnose {
598 diagnostics.push(Diagnostic {
599 message: format!(
600 "playback trigger overflow for media shape {shape_id}"
601 ),
602 });
603 }
604 None
605 }),
606 TimingDuration::Indefinite => None,
607 }
608 })
609 .min();
610 start.unwrap_or(u64::MAX)
611}
612
613#[allow(clippy::too_many_arguments)]
614fn advance_media_position(
615 source_position_ms: &mut u64,
616 phase: &mut MediaPlaybackPhase,
617 anchor_ms: u64,
618 target_ms: u64,
619 trim_start: u64,
620 interval_end: Option<u64>,
621 looping: bool,
622 shape_id: u32,
623 diagnostics: &mut Vec<Diagnostic>,
624) -> bool {
625 if !matches!(phase, MediaPlaybackPhase::Playing) || target_ms < anchor_ms {
626 return false;
627 }
628 let delta = target_ms - anchor_ms;
629 let advanced = source_position_ms.checked_add(delta).unwrap_or_else(|| {
630 diagnostics.push(Diagnostic {
631 message: format!("media position overflow clamped for media shape {shape_id}"),
632 });
633 u64::MAX
634 });
635 *source_position_ms = clamp_media_position(advanced, trim_start, interval_end, looping);
636 if !looping && interval_end.is_some_and(|end| advanced >= end) {
637 *phase = MediaPlaybackPhase::Stopped;
638 return true;
639 }
640 false
641}
642
643fn clamp_media_position(
644 position: u64,
645 trim_start: u64,
646 interval_end: Option<u64>,
647 looping: bool,
648) -> u64 {
649 let position = position.max(trim_start);
650 let Some(end) = interval_end else {
651 return position;
652 };
653 if looping && position >= end {
654 return trim_start + (position - trim_start) % (end - trim_start);
655 }
656 position.min(end)
657}
658
659#[derive(Clone, Copy)]
660enum Scheduling {
661 Parallel,
662 Sequence,
663}
664
665#[derive(Default)]
666struct ClickSchedule {
667 ordinals: HashMap<*const CommonTimeNode, u32>,
668}
669
670impl ClickSchedule {
671 fn new(timing: &CT_Timing) -> Self {
672 let mut schedule = Self::default();
673 let mut ordinal = 0;
674 collect_click_schedule(timing.nodes(), &mut ordinal, &mut schedule.ordinals);
675 schedule
676 }
677
678 fn is_available(&self, common: &CommonTimeNode, click_count: u32) -> bool {
679 self.ordinals
680 .get(&std::ptr::from_ref(common))
681 .is_some_and(|ordinal| click_count >= *ordinal)
682 }
683}
684
685fn collect_click_schedule(
686 nodes: &[TimingNode],
687 ordinal: &mut u32,
688 ordinals: &mut HashMap<*const CommonTimeNode, u32>,
689) {
690 for node in nodes {
691 if matches!(node, TimingNode::Audio(_) | TimingNode::Video(_)) {
692 continue;
693 }
694 let Some(common) = media_schedule_common(node) else {
695 continue;
696 };
697 if common_uses_click(common) {
698 *ordinal = ordinal.saturating_add(1);
699 ordinals.insert(std::ptr::from_ref(common), *ordinal);
700 }
701 if matches!(node, TimingNode::Parallel(_) | TimingNode::Sequence(_)) {
702 collect_click_schedule(&common.children, ordinal, ordinals);
703 }
704 }
705}
706
707fn common_uses_click(common: &CommonTimeNode) -> bool {
708 matches!(common.node_type, Some(TimingNodeType::ClickEffect))
709 || common.start_conditions.iter().any(|condition| {
710 matches!(
711 condition.event,
712 Some(TimingEvent::OnClick | TimingEvent::OnNext)
713 )
714 })
715}
716
717struct EvaluationContext<'a> {
718 timing: &'a CT_Timing,
719 position: TimelinePosition,
720 click_schedule: &'a ClickSchedule,
721 state: &'a mut EvaluatedFrameState,
722}
723
724fn evaluate_nodes(
725 nodes: &[TimingNode],
726 parent_start: u64,
727 parent_end: u64,
728 scheduling: Scheduling,
729 context: &mut EvaluationContext<'_>,
730) -> u64 {
731 let mut cursor = parent_start;
732 let mut previous = None;
733 let mut latest_end = parent_start;
734 for node in nodes {
735 if let TimingNode::Unsupported(node) = node {
736 context.state.diagnostics.push(Diagnostic {
737 message: format!("unsupported timing node {}", node.local_name),
738 });
739 continue;
740 }
741 if matches!(
742 node,
743 TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_)
744 ) {
745 context.state.diagnostics.push(Diagnostic {
746 message: "media timing is retained for synchronized playback".to_owned(),
747 });
748 continue;
749 }
750 let common = common(node);
751 let suggested = match (scheduling, common.node_type.as_ref(), previous) {
752 (Scheduling::Sequence, Some(TimingNodeType::WithEffect), Some((start, _))) => start,
753 (Scheduling::Sequence, Some(TimingNodeType::AfterEffect), Some((_, end))) => end,
754 (Scheduling::Sequence, _, _) => cursor,
755 (Scheduling::Parallel, _, _) => parent_start,
756 };
757 let start = scheduled_start(common, suggested, context);
758 let end = evaluate_node(node, start, parent_end, context);
759 previous = Some((start, end));
760 latest_end = latest_end.max(end);
761 if matches!(scheduling, Scheduling::Sequence)
762 && !matches!(common.node_type, Some(TimingNodeType::WithEffect))
763 {
764 cursor = end;
765 }
766 }
767 latest_end.min(parent_end)
768}
769
770fn common(node: &TimingNode) -> &CommonTimeNode {
771 match node {
772 TimingNode::Parallel(node) => &node.common,
773 TimingNode::Sequence(node) => &node.common,
774 TimingNode::Set(node) => &node.common,
775 TimingNode::Animate(node) => &node.common,
776 TimingNode::Effect(node) => &node.common,
777 TimingNode::Motion(node) => &node.common,
778 TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_) => {
779 unreachable!("media nodes are diagnosed by the caller")
780 }
781 TimingNode::Unsupported(_) => unreachable!("unsupported nodes are diagnosed by the caller"),
782 }
783}
784
785fn scheduled_start(
786 common: &CommonTimeNode,
787 suggested: u64,
788 context: &mut EvaluationContext<'_>,
789) -> u64 {
790 let click_effect = matches!(common.node_type, Some(TimingNodeType::ClickEffect));
791 let click_available = context
792 .click_schedule
793 .is_available(common, context.position.click_count);
794 if click_effect && !click_available {
795 return u64::MAX;
796 }
797 if common.start_conditions.is_empty() {
798 return suggested;
799 }
800 common
801 .start_conditions
802 .iter()
803 .enumerate()
804 .filter(|(_, condition)| {
805 !matches!(
806 condition.event,
807 Some(TimingEvent::OnClick | TimingEvent::OnNext)
808 ) || click_available
809 })
810 .filter_map(|(index, condition)| {
811 condition_start(
812 condition,
813 suggested,
814 context
815 .timing
816 .condition_has_explicit_target(common.id, false, index),
817 context.state,
818 )
819 })
820 .min()
821 .unwrap_or(u64::MAX)
822}
823
824fn condition_start(
825 condition: &TimingCondition,
826 suggested: u64,
827 explicit_target: Option<bool>,
828 state: &mut EvaluatedFrameState,
829) -> Option<u64> {
830 let supported_event = matches!(
831 condition.event,
832 None | Some(TimingEvent::OnBegin) | Some(TimingEvent::OnClick) | Some(TimingEvent::OnNext)
833 );
834 let supported_target = matches!(condition.target, TimingTarget::Slide)
835 || matches!(condition.target, TimingTarget::Unsupported) && explicit_target == Some(false);
836 if !supported_event || !supported_target {
837 state.diagnostics.push(Diagnostic {
838 message: "unsupported timing condition trigger".to_owned(),
839 });
840 return None;
841 }
842 match condition.delay {
843 TimingDuration::Finite(delay) => Some(suggested.saturating_add(delay)),
844 TimingDuration::Indefinite => None,
845 }
846}
847
848fn evaluate_node(
849 node: &TimingNode,
850 start: u64,
851 parent_end: u64,
852 context: &mut EvaluationContext<'_>,
853) -> u64 {
854 let common = common(node);
855 let condition_end = declared_condition_end(common, start, context);
856 let container_end = match common.duration {
857 TimingDuration::Finite(duration) => start.saturating_add(duration),
858 TimingDuration::Indefinite => u64::MAX,
859 }
860 .min(condition_end.unwrap_or(u64::MAX))
861 .min(parent_end);
862 match node {
863 TimingNode::Parallel(TimingContainer { common }) => {
864 let child_end = evaluate_nodes(
865 &common.children,
866 start,
867 container_end,
868 Scheduling::Parallel,
869 context,
870 );
871 declared_end(common, start, child_end, parent_end, condition_end)
872 }
873 TimingNode::Sequence(TimingSequence { common, .. }) => {
874 let child_end = evaluate_nodes(
875 &common.children,
876 start,
877 container_end,
878 Scheduling::Sequence,
879 context,
880 );
881 declared_end(common, start, child_end, parent_end, condition_end)
882 }
883 TimingNode::Set(node) => {
884 let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
885 let end = phase_end.min(parent_end);
886 if start < parent_end && context.position.elapsed_ms <= parent_end {
887 evaluate_set(node, start, phase_end, context);
888 }
889 end
890 }
891 TimingNode::Animate(node) => {
892 let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
893 let end = phase_end.min(parent_end);
894 if start < parent_end && context.position.elapsed_ms <= parent_end {
895 evaluate_animate(node, start, phase_end, context);
896 }
897 end
898 }
899 TimingNode::Effect(node) => {
900 let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
901 let end = phase_end.min(parent_end);
902 if start < parent_end && context.position.elapsed_ms <= parent_end {
903 evaluate_effect(node, start, phase_end, context);
904 }
905 end
906 }
907 TimingNode::Motion(node) => {
908 let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
909 let end = phase_end.min(parent_end);
910 if start < parent_end && context.position.elapsed_ms <= parent_end {
911 evaluate_motion(node, start, phase_end, context);
912 }
913 end
914 }
915 TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_) => start,
916 TimingNode::Unsupported(_) => start,
917 }
918}
919
920fn declared_end(
921 common: &CommonTimeNode,
922 start: u64,
923 child_end: u64,
924 parent_end: u64,
925 condition_end: Option<u64>,
926) -> u64 {
927 let natural_end = match common.duration {
928 TimingDuration::Finite(duration) => start.saturating_add(duration),
929 TimingDuration::Indefinite if child_end > start => child_end,
930 TimingDuration::Indefinite => u64::MAX,
931 };
932 natural_end
933 .min(condition_end.unwrap_or(u64::MAX))
934 .min(parent_end)
935}
936
937fn declared_condition_end(
938 common: &CommonTimeNode,
939 start: u64,
940 context: &mut EvaluationContext<'_>,
941) -> Option<u64> {
942 common
943 .end_conditions
944 .iter()
945 .enumerate()
946 .filter_map(|(index, condition)| {
947 end_condition_time(
948 condition,
949 start,
950 context
951 .timing
952 .condition_has_explicit_target(common.id, true, index),
953 context.state,
954 )
955 })
956 .min()
957}
958
959fn end_condition_time(
960 condition: &TimingCondition,
961 start: u64,
962 explicit_target: Option<bool>,
963 state: &mut EvaluatedFrameState,
964) -> Option<u64> {
965 if !matches!(condition.event, None | Some(TimingEvent::OnBegin))
966 || !(matches!(condition.target, TimingTarget::Slide)
967 || matches!(condition.target, TimingTarget::Unsupported)
968 && explicit_target == Some(false))
969 {
970 state.diagnostics.push(Diagnostic {
971 message: "unsupported timing condition trigger".to_owned(),
972 });
973 return None;
974 }
975 match condition.delay {
976 TimingDuration::Finite(delay) => Some(start.saturating_add(delay)),
977 TimingDuration::Indefinite => None,
978 }
979}
980
981#[derive(Clone, Copy)]
982struct Phase {
983 progress: f64,
984 applies: bool,
985 before: bool,
986}
987
988fn phase(common: &CommonTimeNode, start: u64, end: u64, elapsed: u64) -> Phase {
989 if start == u64::MAX || elapsed < start {
990 return Phase {
991 progress: 0.0,
992 applies: false,
993 before: true,
994 };
995 }
996 if end == u64::MAX {
997 return Phase {
998 progress: 0.0,
999 applies: true,
1000 before: false,
1001 };
1002 }
1003 let duration = end.saturating_sub(start);
1004 if elapsed <= end {
1005 return Phase {
1006 progress: if duration == 0 {
1007 1.0
1008 } else {
1009 (elapsed - start) as f64 / duration as f64
1010 }
1011 .clamp(0.0, 1.0),
1012 applies: true,
1013 before: false,
1014 };
1015 }
1016 Phase {
1017 progress: 1.0,
1018 applies: matches!(common.fill, Some(TimingFill::Hold | TimingFill::Freeze)),
1019 before: false,
1020 }
1021}
1022
1023fn target_shape(target: &TimingTarget, state: &mut EvaluatedFrameState) -> Option<u32> {
1024 match target {
1025 TimingTarget::Shape(id) => Some(*id),
1026 _ => {
1027 state.diagnostics.push(Diagnostic {
1028 message: "unsupported non-shape timing target".to_owned(),
1029 });
1030 None
1031 }
1032 }
1033}
1034
1035fn shape_state(state: &mut EvaluatedFrameState, id: u32) -> &mut EvaluatedShapeState {
1036 state.shapes.entry(id).or_default()
1037}
1038
1039fn evaluate_set(node: &TimingSet, start: u64, end: u64, context: &mut EvaluationContext<'_>) {
1040 let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1041 if !phase.applies {
1042 return;
1043 }
1044 let Some(id) = target_shape(&node.target, context.state) else {
1045 return;
1046 };
1047 let value = node.value.as_deref().unwrap_or_default();
1048 match node.attribute_name.as_deref().unwrap_or_default() {
1049 "style.visibility" | "visibility" => {
1050 shape_state(context.state, id).visible = !matches!(value, "hidden" | "false" | "0")
1051 }
1052 "style.opacity" | "opacity" => {
1053 if let Some(value) = parse_number(value) {
1054 shape_state(context.state, id).opacity = normalize_fraction(value) as f32;
1055 } else {
1056 context.state.diagnostics.push(Diagnostic {
1057 message: "invalid non-finite set value ignored".to_owned(),
1058 });
1059 }
1060 }
1061 attribute => context.state.diagnostics.push(Diagnostic {
1062 message: format!("unsupported set attribute {attribute}"),
1063 }),
1064 }
1065}
1066
1067fn evaluate_animate(
1068 node: &TimingAnimate,
1069 start: u64,
1070 end: u64,
1071 context: &mut EvaluationContext<'_>,
1072) {
1073 let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1074 if !phase.applies {
1075 return;
1076 }
1077 let Some(id) = target_shape(&node.target, context.state) else {
1078 return;
1079 };
1080 if node
1081 .from
1082 .iter()
1083 .chain(&node.to)
1084 .chain(&node.by)
1085 .any(|value| parse_number(value).is_none())
1086 {
1087 context.state.diagnostics.push(Diagnostic {
1088 message: "invalid non-finite animate value ignored".to_owned(),
1089 });
1090 return;
1091 }
1092 let from = node.from.as_deref().and_then(parse_number).unwrap_or(0.0);
1093 let to = if let Some(to) = node.to.as_deref() {
1094 parse_number(to)
1095 } else if let Some(by) = node.by.as_deref() {
1096 parse_number(by)
1097 .map(|by| from + by)
1098 .filter(|value| value.is_finite())
1099 } else {
1100 Some(from)
1101 };
1102 let Some(value) = to.and_then(|to| checked_interpolate(from, to, phase.progress)) else {
1103 context.state.diagnostics.push(Diagnostic {
1104 message: "non-finite animate arithmetic ignored".to_owned(),
1105 });
1106 return;
1107 };
1108 match node.attribute_name.as_deref().unwrap_or_default() {
1109 "style.opacity" | "opacity" => {
1110 shape_state(context.state, id).opacity = normalize_fraction(value) as f32
1111 }
1112 "scale" => {
1113 let state = shape_state(context.state, id);
1114 let value = normalize_percentage(value);
1115 state.animation.scale_x = value;
1116 state.animation.scale_y = value;
1117 state.rebuild_public_transform();
1118 }
1119 "ppt_x" | "ppt_y" => context.state.diagnostics.push(Diagnostic {
1120 message: format!(
1121 "unsupported animate attribute {}",
1122 node.attribute_name.as_deref().unwrap_or_default()
1123 ),
1124 }),
1125 "rotation" | "spin" => {
1126 let state = shape_state(context.state, id);
1127 let degrees = if value.abs() > 360.0 {
1128 value / 60_000.0
1129 } else {
1130 value
1131 };
1132 state.animation.rotation_deg = degrees;
1133 state.rebuild_public_transform();
1134 }
1135 attribute => context.state.diagnostics.push(Diagnostic {
1136 message: format!("unsupported animate attribute {attribute}"),
1137 }),
1138 }
1139}
1140
1141fn evaluate_effect(node: &TimingEffect, start: u64, end: u64, context: &mut EvaluationContext<'_>) {
1142 let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1143 let Some(id) = target_shape(&node.target, context.state) else {
1144 return;
1145 };
1146 let transition = node.transition.as_deref().unwrap_or("in");
1147 if !matches!(transition, "in" | "out") {
1148 context.state.diagnostics.push(Diagnostic {
1149 message: format!("unsupported timing effect transition {transition}"),
1150 });
1151 return;
1152 }
1153 let filter = node.filter.as_deref().unwrap_or("fade");
1154 let Some(effect) = shape_effect(filter) else {
1155 context.state.diagnostics.push(Diagnostic {
1156 message: format!("unsupported timing effect {filter}"),
1157 });
1158 return;
1159 };
1160 let state = shape_state(context.state, id);
1161 if phase.before {
1162 if transition == "in" {
1163 state.visible = false;
1164 state.opacity = 0.0;
1165 }
1166 return;
1167 }
1168 if !phase.applies {
1169 return;
1170 }
1171 let progress = if matches!(effect, ShapeEffect::Appear) {
1172 1.0
1173 } else {
1174 phase.progress
1175 };
1176 match (effect, transition == "out") {
1177 (ShapeEffect::Appear, true) => {
1178 state.opacity = 0.0;
1179 state.visible = false;
1180 }
1181 (ShapeEffect::Appear, false) => {
1182 state.visible = true;
1183 state.opacity = 1.0;
1184 }
1185 (ShapeEffect::Fade, true) => {
1186 state.opacity = (1.0 - progress) as f32;
1187 state.visible = progress < 1.0;
1188 }
1189 (ShapeEffect::Fade, false) => {
1190 state.visible = true;
1191 state.opacity = progress as f32;
1192 }
1193 (ShapeEffect::Wipe(direction), exiting) => {
1194 state.visible = !exiting || progress < 1.0;
1195 state.opacity = 1.0;
1196 state.clip = Some(wipe_clip(direction, progress, exiting));
1197 }
1198 }
1199}
1200
1201#[derive(Clone, Copy)]
1202enum ShapeEffect<'a> {
1203 Appear,
1204 Fade,
1205 Wipe(&'a str),
1206}
1207
1208fn shape_effect(filter: &str) -> Option<ShapeEffect<'_>> {
1209 match filter.trim() {
1210 "appear" => Some(ShapeEffect::Appear),
1211 "fade" => Some(ShapeEffect::Fade),
1212 filter => filter
1213 .strip_prefix("wipe(")
1214 .and_then(|direction| direction.strip_suffix(')'))
1215 .filter(|direction| matches!(*direction, "left" | "right" | "up" | "down"))
1216 .map(ShapeEffect::Wipe),
1217 }
1218}
1219
1220fn wipe_clip(direction: &str, progress: f64, exiting: bool) -> Rect {
1221 let progress = progress.clamp(0.0, 1.0);
1222 match (direction, exiting) {
1223 ("left", false) => Rect {
1224 x: 1.0 - progress,
1225 y: 0.0,
1226 width: progress,
1227 height: 1.0,
1228 },
1229 ("up", false) => Rect {
1230 x: 0.0,
1231 y: 1.0 - progress,
1232 width: 1.0,
1233 height: progress,
1234 },
1235 ("down", false) => Rect {
1236 x: 0.0,
1237 y: 0.0,
1238 width: 1.0,
1239 height: progress,
1240 },
1241 ("right", false) => Rect {
1242 x: 0.0,
1243 y: 0.0,
1244 width: progress,
1245 height: 1.0,
1246 },
1247 ("left", true) => Rect {
1248 x: 0.0,
1249 y: 0.0,
1250 width: 1.0 - progress,
1251 height: 1.0,
1252 },
1253 ("up", true) => Rect {
1254 x: 0.0,
1255 y: 0.0,
1256 width: 1.0,
1257 height: 1.0 - progress,
1258 },
1259 ("down", true) => Rect {
1260 x: 0.0,
1261 y: progress,
1262 width: 1.0,
1263 height: 1.0 - progress,
1264 },
1265 _ => Rect {
1266 x: progress,
1267 y: 0.0,
1268 width: 1.0 - progress,
1269 height: 1.0,
1270 },
1271 }
1272}
1273
1274fn evaluate_motion(
1275 node: &TimingMotionPath,
1276 start: u64,
1277 end: u64,
1278 context: &mut EvaluationContext<'_>,
1279) {
1280 let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1281 if !phase.applies {
1282 return;
1283 }
1284 let Some(id) = target_shape(&node.target, context.state) else {
1285 return;
1286 };
1287 let Some(points) = node.path.as_deref().and_then(parse_motion_path) else {
1288 context.state.diagnostics.push(Diagnostic {
1289 message: "unsupported motion path".to_owned(),
1290 });
1291 return;
1292 };
1293 let (x, y) = point_on_polyline(&points, phase.progress);
1294 if !x.is_finite() || !y.is_finite() {
1295 context.state.diagnostics.push(Diagnostic {
1296 message: "non-finite motion path ignored".to_owned(),
1297 });
1298 return;
1299 }
1300 let origin = match node.origin.as_deref().unwrap_or("parent") {
1301 "parent" => MotionOrigin::Parent,
1302 "layout" => MotionOrigin::Layout,
1303 value => {
1304 context.state.diagnostics.push(Diagnostic {
1305 message: format!("unsupported motion path origin {value}"),
1306 });
1307 return;
1308 }
1309 };
1310 let state = shape_state(context.state, id);
1311 state.animation.motion = Some(EvaluatedMotion { x, y, origin });
1312 state.rebuild_public_transform();
1313}
1314
1315fn parse_number(value: &str) -> Option<f64> {
1316 value
1317 .trim()
1318 .parse::<f64>()
1319 .ok()
1320 .filter(|value| value.is_finite())
1321}
1322
1323fn normalize_fraction(value: f64) -> f64 {
1324 normalize_percentage(value).clamp(0.0, 1.0)
1325}
1326
1327fn normalize_percentage(value: f64) -> f64 {
1328 if value.abs() > 10.0 {
1329 value / 100_000.0
1330 } else {
1331 value
1332 }
1333}
1334
1335fn interpolate(from: f64, to: f64, progress: f64) -> f64 {
1336 from + (to - from) * progress
1337}
1338
1339fn checked_interpolate(from: f64, to: f64, progress: f64) -> Option<f64> {
1340 let value = from + (to - from) * progress;
1341 value.is_finite().then_some(value)
1342}
1343
1344fn parse_motion_path(path: &str) -> Option<Vec<(f64, f64)>> {
1345 #[derive(Clone, Copy)]
1346 enum Command {
1347 MoveAbsolute,
1348 LineAbsolute,
1349 MoveRelative,
1350 LineRelative,
1351 }
1352
1353 let tokens = path
1354 .split(|character: char| character.is_ascii_whitespace() || character == ',')
1355 .filter(|token| !token.is_empty())
1356 .collect::<Vec<_>>();
1357 let mut index = 0;
1358 let mut command = None;
1359 let mut current = (0.0, 0.0);
1360 let mut points = Vec::new();
1361 while index < tokens.len() {
1362 command = match tokens[index] {
1363 "M" => Some(Command::MoveAbsolute),
1364 "L" => Some(Command::LineAbsolute),
1365 "m" => Some(Command::MoveRelative),
1366 "l" => Some(Command::LineRelative),
1367 "E" | "e" => break,
1368 _ => command,
1369 };
1370 if matches!(tokens[index], "M" | "L" | "m" | "l") {
1371 index += 1;
1372 continue;
1373 }
1374 let active = command?;
1375 let x = parse_number(tokens[index])?;
1376 let y = parse_number(tokens.get(index + 1)?)?;
1377 current = match active {
1378 Command::MoveAbsolute | Command::LineAbsolute => (x, y),
1379 Command::MoveRelative | Command::LineRelative => (current.0 + x, current.1 + y),
1380 };
1381 if !current.0.is_finite() || !current.1.is_finite() {
1382 return None;
1383 }
1384 points.push(current);
1385 command = Some(match active {
1386 Command::MoveAbsolute => Command::LineAbsolute,
1387 Command::MoveRelative => Command::LineRelative,
1388 command => command,
1389 });
1390 index += 2;
1391 }
1392 if points.len() < 2 {
1393 return None;
1394 }
1395 Some(points)
1396}
1397
1398fn point_on_polyline(points: &[(f64, f64)], progress: f64) -> (f64, f64) {
1399 let lengths = points
1400 .windows(2)
1401 .map(|pair| (pair[1].0 - pair[0].0).hypot(pair[1].1 - pair[0].1))
1402 .collect::<Vec<_>>();
1403 let total = lengths.iter().sum::<f64>();
1404 if total <= f64::EPSILON {
1405 return points[0];
1406 }
1407 let mut remaining = total * progress.clamp(0.0, 1.0);
1408 for (index, length) in lengths.iter().copied().enumerate() {
1409 if remaining <= length || index + 1 == lengths.len() {
1410 let local = if length <= f64::EPSILON {
1411 0.0
1412 } else {
1413 remaining / length
1414 };
1415 return (
1416 interpolate(points[index].0, points[index + 1].0, local),
1417 interpolate(points[index].1, points[index + 1].1, local),
1418 );
1419 }
1420 remaining -= length;
1421 }
1422 *points
1423 .last()
1424 .expect("motion paths have at least two points")
1425}
1426
1427fn scale_about(scale_x: f64, scale_y: f64, center_x: f64, center_y: f64) -> Transform {
1428 Transform {
1429 a: scale_x,
1430 d: scale_y,
1431 e: center_x * (1.0 - scale_x),
1432 f: center_y * (1.0 - scale_y),
1433 ..Transform::IDENTITY
1434 }
1435}
1436
1437fn translation(x: f64, y: f64) -> Transform {
1438 Transform {
1439 e: x,
1440 f: y,
1441 ..Transform::IDENTITY
1442 }
1443}
1444
1445fn transform_is_finite(transform: Transform) -> bool {
1446 [
1447 transform.a,
1448 transform.b,
1449 transform.c,
1450 transform.d,
1451 transform.e,
1452 transform.f,
1453 ]
1454 .into_iter()
1455 .all(f64::is_finite)
1456}
1457
1458fn rect_is_finite(rect: Rect) -> bool {
1459 [rect.x, rect.y, rect.width, rect.height]
1460 .into_iter()
1461 .all(f64::is_finite)
1462}
1463
1464fn evaluate_transition(
1465 transition: &CT_SlideTransition,
1466 position: TimelinePosition,
1467) -> Option<EvaluatedTransition> {
1468 let effect = transition.effect.clone()?;
1469 let duration_ms = transition.duration_ms.unwrap_or(match transition.speed {
1470 Some(TransitionSpeed::Slow) => 1_000,
1471 Some(TransitionSpeed::Fast) => 500,
1472 _ => 750,
1473 });
1474 let progress = if duration_ms == 0 {
1475 1.0
1476 } else {
1477 position.elapsed_ms as f64 / duration_ms as f64
1478 }
1479 .clamp(0.0, 1.0) as f32;
1480 Some(EvaluatedTransition {
1481 effect,
1482 progress,
1483 duration_ms,
1484 parameters: transition.effect_parameters.clone(),
1485 morph_option: transition
1486 .morph
1487 .as_ref()
1488 .and_then(|morph| morph.option.clone()),
1489 })
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::{MediaPlaybackPhase, TimelinePosition, evaluate_media_playback, evaluate_timeline};
1495 use oxml_layout::Point;
1496 use rpptx_oxml::timing::CT_Timing;
1497
1498 const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
1499
1500 fn timing(nodes: &str) -> CT_Timing {
1501 CT_Timing::from_xml(
1502 format!(r#"<p:timing xmlns:p="{P_NS}"><p:tnLst>{nodes}</p:tnLst></p:timing>"#)
1503 .as_bytes(),
1504 )
1505 .unwrap()
1506 }
1507
1508 #[test]
1509 fn media_playback_state_clamps_trim_volume_and_exact_boundaries() {
1510 let timing = timing(
1511 r#"<p:video><p:cMediaNode vol="25000"><p:cTn id="1" dur="1000" repeatCount="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:video>
1512 <p:cmd type="call" cmd="playFrom(0.0)"><p:cBhvr><p:cTn id="2" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1513 <p:cmd type="call" cmd="pause"><p:cBhvr><p:cTn id="3" dur="1"><p:stCondLst><p:cond delay="300"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1514 <p:cmd type="call" cmd="playFrom(0.2)"><p:cBhvr><p:cTn id="4" dur="1"><p:stCondLst><p:cond delay="400"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1515 <p:cmd type="call" cmd="stop"><p:cBhvr><p:cTn id="5" dur="1"><p:stCondLst><p:cond delay="700"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1516 );
1517 let at = |elapsed_ms| {
1518 evaluate_media_playback(
1519 Some(&timing),
1520 7,
1521 Some(50),
1522 Some(250),
1523 TimelinePosition {
1524 elapsed_ms,
1525 click_count: 0,
1526 },
1527 )
1528 };
1529
1530 assert_eq!(at(99).0.phase, MediaPlaybackPhase::Stopped);
1531 assert_eq!(at(100).0.source_position_ms, 50);
1532 assert_eq!(at(300).0.phase, MediaPlaybackPhase::Paused);
1533 assert_eq!(at(300).0.source_position_ms, 50);
1534 assert_eq!(at(400).0.source_position_ms, 200);
1535 assert_eq!(at(450).0.source_position_ms, 50);
1536 assert_eq!(at(700).0.phase, MediaPlaybackPhase::Stopped);
1537 assert_eq!(at(700).0.source_position_ms, 50);
1538 assert_eq!(at(450).0.volume, 0.25);
1539 assert!(at(450).0.looping);
1540 assert!(at(450).1.is_empty());
1541 }
1542
1543 #[test]
1544 fn seek_while_stopped_is_preserved_by_a_following_offset_free_play() {
1545 let timing = timing(
1546 r#"<p:cmd type="call" cmd="seek(0.2)"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1547 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"><p:stCondLst><p:cond delay="200"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1548 <p:cmd type="call" cmd="stop"><p:cBhvr><p:cTn id="3" dur="1"><p:stCondLst><p:cond delay="300"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1549 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="4" dur="1"><p:stCondLst><p:cond delay="400"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1550 );
1551 let at = |elapsed_ms| {
1552 evaluate_media_playback(
1553 Some(&timing),
1554 7,
1555 Some(50),
1556 Some(500),
1557 TimelinePosition {
1558 elapsed_ms,
1559 click_count: 0,
1560 },
1561 )
1562 .0
1563 };
1564
1565 assert_eq!(at(99).source_position_ms, 50);
1566 assert_eq!(at(100).source_position_ms, 200);
1567 assert_eq!(at(200).phase, MediaPlaybackPhase::Playing);
1568 assert_eq!(at(200).source_position_ms, 200);
1569 assert_eq!(at(250).source_position_ms, 250);
1570 assert_eq!(at(300).phase, MediaPlaybackPhase::Stopped);
1571 assert_eq!(at(300).source_position_ms, 50);
1572 assert_eq!(at(400).phase, MediaPlaybackPhase::Playing);
1573 assert_eq!(at(400).source_position_ms, 50);
1574 assert_eq!(at(450).source_position_ms, 100);
1575 }
1576
1577 #[test]
1578 fn finite_non_looping_playback_stops_at_exact_end_boundaries() {
1579 let trimmed = timing(
1580 r#"<p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1581 );
1582 let trimmed_at = |elapsed_ms| {
1583 evaluate_media_playback(
1584 Some(&trimmed),
1585 7,
1586 Some(50),
1587 Some(250),
1588 TimelinePosition {
1589 elapsed_ms,
1590 click_count: 0,
1591 },
1592 )
1593 .0
1594 };
1595
1596 assert_eq!(trimmed_at(299).phase, MediaPlaybackPhase::Playing);
1597 assert_eq!(trimmed_at(299).source_position_ms, 249);
1598 assert_eq!(trimmed_at(300).phase, MediaPlaybackPhase::Stopped);
1599 assert_eq!(trimmed_at(300).source_position_ms, 250);
1600 assert_eq!(trimmed_at(301).phase, MediaPlaybackPhase::Stopped);
1601 assert_eq!(trimmed_at(301).source_position_ms, 250);
1602
1603 let known_duration = timing(
1604 r#"<p:video><p:cMediaNode vol="100000"><p:cTn id="1" dur="250"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:video>
1605 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1606 );
1607 let duration_at = |elapsed_ms| {
1608 evaluate_media_playback(
1609 Some(&known_duration),
1610 7,
1611 None,
1612 None,
1613 TimelinePosition {
1614 elapsed_ms,
1615 click_count: 0,
1616 },
1617 )
1618 .0
1619 };
1620
1621 assert_eq!(duration_at(249).phase, MediaPlaybackPhase::Playing);
1622 assert_eq!(duration_at(249).source_position_ms, 249);
1623 assert_eq!(duration_at(250).phase, MediaPlaybackPhase::Stopped);
1624 assert_eq!(duration_at(250).source_position_ms, 250);
1625 assert_eq!(duration_at(251).phase, MediaPlaybackPhase::Stopped);
1626 assert_eq!(duration_at(251).source_position_ms, 250);
1627 }
1628
1629 #[test]
1630 fn click_triggered_media_uses_the_existing_timeline_click_count() {
1631 let timing = timing(
1632 r#"<p:set><p:cBhvr><p:cTn id="1" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1633 <p:audio><p:cMediaNode vol="100000"><p:cTn id="2" dur="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1634 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="3" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1635 );
1636 let at = |click_count| {
1637 let media = evaluate_media_playback(
1638 Some(&timing),
1639 7,
1640 None,
1641 None,
1642 TimelinePosition {
1643 elapsed_ms: 0,
1644 click_count,
1645 },
1646 )
1647 .0;
1648 let page = evaluate_timeline(
1649 Some(&timing),
1650 None,
1651 TimelinePosition {
1652 elapsed_ms: 0,
1653 click_count,
1654 },
1655 )
1656 .unwrap();
1657 let visible = page.shapes.get(&8).is_none_or(|shape| shape.visible);
1658 (visible, media.phase)
1659 };
1660
1661 assert_eq!(at(0), (true, MediaPlaybackPhase::Stopped));
1662 assert_eq!(at(1), (false, MediaPlaybackPhase::Stopped));
1663 assert_eq!(at(2), (false, MediaPlaybackPhase::Playing));
1664 }
1665
1666 #[test]
1667 fn media_click_before_shape_click_consumes_the_first_shared_click_ordinal() {
1668 let timing = timing(
1669 r#"<p:audio><p:cMediaNode vol="100000"><p:cTn id="1" dur="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1670 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1671 <p:set><p:cBhvr><p:cTn id="3" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1672 );
1673 let at = |click_count| {
1674 let media = evaluate_media_playback(
1675 Some(&timing),
1676 7,
1677 None,
1678 None,
1679 TimelinePosition {
1680 elapsed_ms: 0,
1681 click_count,
1682 },
1683 )
1684 .0;
1685 let page = evaluate_timeline(
1686 Some(&timing),
1687 None,
1688 TimelinePosition {
1689 elapsed_ms: 0,
1690 click_count,
1691 },
1692 )
1693 .unwrap();
1694 let visible = page.shapes.get(&8).is_none_or(|shape| shape.visible);
1695 (visible, media.phase)
1696 };
1697
1698 assert_eq!(at(0), (true, MediaPlaybackPhase::Stopped));
1699 assert_eq!(at(1), (true, MediaPlaybackPhase::Playing));
1700 assert_eq!(at(2), (false, MediaPlaybackPhase::Playing));
1701 }
1702
1703 #[test]
1704 fn looping_without_a_known_duration_is_diagnostic_and_does_not_wrap() {
1705 let timing = timing(
1706 r#"<p:audio><p:cMediaNode vol="50000"><p:cTn id="1" dur="indefinite" repeatCount="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1707 <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1708 );
1709 let (state, diagnostics) = evaluate_media_playback(
1710 Some(&timing),
1711 7,
1712 None,
1713 None,
1714 TimelinePosition {
1715 elapsed_ms: 500,
1716 click_count: 0,
1717 },
1718 );
1719
1720 assert_eq!(state.phase, MediaPlaybackPhase::Playing);
1721 assert_eq!(state.source_position_ms, 500);
1722 assert!(state.looping);
1723 assert_eq!(state.volume, 0.5);
1724 assert_eq!(diagnostics.len(), 1);
1725 assert!(
1726 diagnostics[0]
1727 .message
1728 .contains("no finite positive playback interval")
1729 );
1730 }
1731
1732 #[test]
1733 fn unsupported_media_triggers_diagnose_only_the_target_media_object() {
1734 let timing = timing(
1735 r#"<p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond evt="onPrev" delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1736 );
1737 let (target, diagnostics) = evaluate_media_playback(
1738 Some(&timing),
1739 7,
1740 None,
1741 None,
1742 TimelinePosition {
1743 elapsed_ms: 500,
1744 click_count: 1,
1745 },
1746 );
1747 let (_, unrelated_diagnostics) = evaluate_media_playback(
1748 Some(&timing),
1749 8,
1750 None,
1751 None,
1752 TimelinePosition {
1753 elapsed_ms: 500,
1754 click_count: 1,
1755 },
1756 );
1757
1758 assert_eq!(target.phase, MediaPlaybackPhase::Stopped);
1759 assert_eq!(diagnostics.len(), 1);
1760 assert!(diagnostics[0].message.contains("media shape 7"));
1761 assert!(unrelated_diagnostics.is_empty());
1762 }
1763
1764 #[test]
1765 fn explicit_timestamps_evaluate_sequences_parallel_groups_and_clicks_deterministically() {
1766 let timing = timing(
1767 r#"<p:seq><p:cTn id="1" nodeType="mainSeq"><p:childTnLst>
1768 <p:set><p:cBhvr><p:cTn id="2" dur="100" fill="hold" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="0"/></p:to></p:set>
1769 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="3" dur="200" fill="hold" nodeType="withEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1770 <p:set><p:cBhvr><p:cTn id="4" dur="100" fill="hold" nodeType="afterEffect"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1771 <p:par><p:cTn id="5" dur="100"><p:childTnLst>
1772 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="6" dur="100"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1773 <p:anim from="100000" to="0"><p:cBhvr><p:cTn id="7" dur="100"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1774 </p:childTnLst></p:cTn></p:par>
1775 </p:childTnLst></p:cTn></p:seq>
1776 <p:set><p:cBhvr><p:cTn id="8" dur="1" fill="hold"><p:stCondLst><p:cond delay="100"/><p:cond delay="200"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="12"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1777 );
1778
1779 let before_click = evaluate_timeline(
1780 Some(&timing),
1781 None,
1782 TimelinePosition {
1783 elapsed_ms: 50,
1784 click_count: 0,
1785 },
1786 )
1787 .unwrap();
1788 let after_click = evaluate_timeline(
1789 Some(&timing),
1790 None,
1791 TimelinePosition {
1792 elapsed_ms: 150,
1793 click_count: 1,
1794 },
1795 )
1796 .unwrap();
1797 let after_previous = evaluate_timeline(
1798 Some(&timing),
1799 None,
1800 TimelinePosition {
1801 elapsed_ms: 250,
1802 click_count: 1,
1803 },
1804 )
1805 .unwrap();
1806 let parallel = evaluate_timeline(
1807 Some(&timing),
1808 None,
1809 TimelinePosition {
1810 elapsed_ms: 350,
1811 click_count: 1,
1812 },
1813 )
1814 .unwrap();
1815
1816 assert!(!before_click.shapes.contains_key(&7));
1817 assert_eq!(after_click.shapes[&7].opacity, 0.0);
1818 assert_eq!(after_click.shapes[&8].opacity, 0.75);
1819 assert!(!after_click.shapes.contains_key(&9));
1820 assert!(!after_click.shapes[&12].visible);
1821 assert!(!after_previous.shapes[&9].visible);
1822 assert_eq!(parallel.shapes[&10].opacity, 0.5);
1823 assert_eq!(parallel.shapes[&11].opacity, 0.5);
1824 }
1825
1826 #[test]
1827 fn start_condition_alternatives_select_the_first_available_trigger() {
1828 let timing = timing(
1829 r#"<p:set><p:cBhvr><p:cTn id="1" dur="1" fill="hold"><p:stCondLst><p:cond delay="100"/><p:cond evt="onClick" delay="10"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1830 );
1831
1832 let timed = evaluate_timeline(
1833 Some(&timing),
1834 None,
1835 TimelinePosition {
1836 elapsed_ms: 150,
1837 click_count: 0,
1838 },
1839 )
1840 .unwrap();
1841 let clicked = evaluate_timeline(
1842 Some(&timing),
1843 None,
1844 TimelinePosition {
1845 elapsed_ms: 50,
1846 click_count: 1,
1847 },
1848 )
1849 .unwrap();
1850
1851 assert!(!timed.shapes[&7].visible);
1852 assert!(!clicked.shapes[&7].visible);
1853 }
1854
1855 #[test]
1856 fn end_conditions_bound_intervals_and_indefinite_leaves_block_sequences() {
1857 let timing = timing(
1858 r#"<p:seq><p:cTn id="1"><p:childTnLst>
1859 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="2" dur="indefinite" fill="hold"><p:endCondLst><p:cond delay="200"/></p:endCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1860 <p:set><p:cBhvr><p:cTn id="3" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1861 </p:childTnLst></p:cTn></p:seq>
1862 <p:seq><p:cTn id="4"><p:childTnLst>
1863 <p:set><p:cBhvr><p:cTn id="5" dur="indefinite" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1864 <p:set><p:cBhvr><p:cTn id="6" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1865 </p:childTnLst></p:cTn></p:seq>
1866 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="7" dur="1000"><p:endCondLst><p:cond delay="100"><p:rtn val="all"/></p:cond></p:endCondLst></p:cTn><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>"#,
1867 );
1868
1869 let before_end = evaluate_timeline(
1870 Some(&timing),
1871 None,
1872 TimelinePosition {
1873 elapsed_ms: 150,
1874 click_count: 0,
1875 },
1876 )
1877 .unwrap();
1878 let after_end = evaluate_timeline(
1879 Some(&timing),
1880 None,
1881 TimelinePosition {
1882 elapsed_ms: 250,
1883 click_count: 0,
1884 },
1885 )
1886 .unwrap();
1887
1888 assert_eq!(before_end.shapes[&7].opacity, 0.75);
1889 assert!(!before_end.shapes.contains_key(&8));
1890 assert!(!after_end.shapes[&8].visible);
1891 assert!(!after_end.shapes[&9].visible);
1892 assert!(!after_end.shapes.contains_key(&10));
1893 assert!(after_end.diagnostics.iter().any(|diagnostic| {
1894 diagnostic
1895 .message
1896 .contains("unsupported timing condition trigger")
1897 }));
1898 }
1899
1900 #[test]
1901 fn container_end_boundaries_stop_parallel_and_sequence_children() {
1902 let timing = timing(
1903 r#"<p:par><p:cTn id="1" dur="200"><p:childTnLst>
1904 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1905 </p:childTnLst></p:cTn></p:par>
1906 <p:seq><p:cTn id="3" dur="200"><p:childTnLst>
1907 <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="4" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1908 <p:set><p:cBhvr><p:cTn id="5" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1909 </p:childTnLst></p:cTn></p:seq>"#,
1910 );
1911
1912 let active = evaluate_timeline(
1913 Some(&timing),
1914 None,
1915 TimelinePosition {
1916 elapsed_ms: 150,
1917 click_count: 0,
1918 },
1919 )
1920 .unwrap();
1921 let ended = evaluate_timeline(
1922 Some(&timing),
1923 None,
1924 TimelinePosition {
1925 elapsed_ms: 500,
1926 click_count: 0,
1927 },
1928 )
1929 .unwrap();
1930
1931 assert_eq!(active.shapes[&7].opacity, 0.15);
1932 assert_eq!(active.shapes[&8].opacity, 0.15);
1933 assert!(!active.shapes.contains_key(&9));
1934 assert!(!ended.shapes.contains_key(&7));
1935 assert!(!ended.shapes.contains_key(&8));
1936 assert!(!ended.shapes.contains_key(&9));
1937 }
1938
1939 #[test]
1940 fn entrance_exit_emphasis_and_motion_states_clamp_at_boundaries() {
1941 let timing = timing(
1942 r#"<p:par><p:cTn id="1"><p:childTnLst>
1943 <p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:animEffect>
1944 <p:animMotion origin="layout" path="M 0 0 L .5 0 L .5 1 E"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl></p:cBhvr></p:animMotion>
1945 <p:animEffect transition="out" filter="fade"><p:cBhvr><p:cTn id="4" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl></p:cBhvr></p:animEffect>
1946 <p:animEffect transition="in" filter="appear"><p:cBhvr><p:cTn id="5" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl></p:cBhvr></p:animEffect>
1947 <p:anim from="100000" to="200000"><p:cBhvr><p:cTn id="6" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1948 <p:anim from="0" to="5400000"><p:cBhvr><p:cTn id="7" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>spin</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1949 <p:animEffect transition="in" filter="wipe(left)"><p:cBhvr><p:cTn id="8" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="12"/></p:tgtEl></p:cBhvr></p:animEffect>
1950 <p:set><p:cBhvr><p:cTn id="9" dur="100" fill="remove"/><p:tgtEl><p:spTgt spid="13"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1951 <p:animEffect transition="out" filter="wipe(down)"><p:cBhvr><p:cTn id="10" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="14"/></p:tgtEl></p:cBhvr></p:animEffect>
1952 <p:animEffect transition="out" filter="appear"><p:cBhvr><p:cTn id="11" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="15"/></p:tgtEl></p:cBhvr></p:animEffect>
1953 <p:anim from="0" to="NaN"><p:cBhvr><p:cTn id="12" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="16"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1954 </p:childTnLst></p:cTn></p:par>"#,
1955 );
1956 let before_start = evaluate_timeline(
1957 Some(&timing),
1958 None,
1959 TimelinePosition {
1960 elapsed_ms: 99,
1961 click_count: 0,
1962 },
1963 )
1964 .unwrap();
1965 let at_start = evaluate_timeline(
1966 Some(&timing),
1967 None,
1968 TimelinePosition {
1969 elapsed_ms: 100,
1970 click_count: 0,
1971 },
1972 )
1973 .unwrap();
1974 let at_zero = evaluate_timeline(
1975 Some(&timing),
1976 None,
1977 TimelinePosition {
1978 elapsed_ms: 0,
1979 click_count: 0,
1980 },
1981 )
1982 .unwrap();
1983 let at_end = evaluate_timeline(
1984 Some(&timing),
1985 None,
1986 TimelinePosition {
1987 elapsed_ms: 1000,
1988 click_count: 0,
1989 },
1990 )
1991 .unwrap();
1992 let after_end = evaluate_timeline(
1993 Some(&timing),
1994 None,
1995 TimelinePosition {
1996 elapsed_ms: 1101,
1997 click_count: 0,
1998 },
1999 )
2000 .unwrap();
2001 let delayed_end = evaluate_timeline(
2002 Some(&timing),
2003 None,
2004 TimelinePosition {
2005 elapsed_ms: 1100,
2006 click_count: 0,
2007 },
2008 )
2009 .unwrap();
2010
2011 assert!(!before_start.shapes[&7].visible);
2012 assert!(at_start.shapes[&7].visible);
2013 assert_eq!(at_start.shapes[&7].opacity, 0.0);
2014 assert_eq!(at_end.shapes[&7].opacity, 0.9);
2015 assert_eq!(delayed_end.shapes[&7].opacity, 1.0);
2016 assert_eq!(after_end.shapes[&7].opacity, 1.0);
2017 assert_eq!(at_end.shapes[&8].transform.e, 0.5);
2018 assert_eq!(at_end.shapes[&8].transform.f, 1.0);
2019 assert!(!at_end.shapes[&9].visible);
2020 assert_eq!(at_zero.shapes[&10].opacity, 1.0);
2021 assert!(!at_zero.shapes[&15].visible);
2022 let centre = at_end.shapes[&11].transform.apply(Point { x: 0.5, y: 0.5 });
2023 assert!((centre.x - 0.5).abs() < 1.0e-10);
2024 assert!((centre.y - 0.5).abs() < 1.0e-10);
2025 assert_eq!(at_start.shapes[&12].clip.unwrap().x, 0.9);
2026 assert_eq!(at_end.shapes[&12].clip.unwrap().width, 1.0);
2027 assert!(!at_end.shapes.contains_key(&13));
2028 assert_eq!(at_end.shapes[&14].clip.unwrap().height, 0.0);
2029 assert!(!at_start.shapes[&15].visible);
2030 assert!(!at_end.shapes.contains_key(&16));
2031 assert!(
2032 at_end
2033 .diagnostics
2034 .iter()
2035 .any(|diagnostic| diagnostic.message.contains("non-finite"))
2036 );
2037 }
2038
2039 #[test]
2040 fn unsupported_timing_nodes_do_not_hide_supported_siblings() {
2041 let timing = timing(
2042 r#"<p:par><p:cTn id="1"><p:childTnLst><p:cmd type="call" cmd="x"/><p:set><p:cBhvr><p:cTn id="2" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set></p:childTnLst></p:cTn></p:par>"#,
2043 );
2044 let state = evaluate_timeline(
2045 Some(&timing),
2046 None,
2047 TimelinePosition {
2048 elapsed_ms: 1,
2049 click_count: 0,
2050 },
2051 )
2052 .unwrap();
2053
2054 assert!(!state.shapes[&9].visible);
2055 assert_eq!(state.diagnostics.len(), 1);
2056 }
2057
2058 #[test]
2059 fn relative_motion_commands_accumulate_from_the_current_point() {
2060 let timing = timing(
2061 r#"<p:animMotion origin="parent" path="M 0 0 l .2 .1 l .2 .1 E"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:animMotion>"#,
2062 );
2063 let state = evaluate_timeline(
2064 Some(&timing),
2065 None,
2066 TimelinePosition {
2067 elapsed_ms: 1000,
2068 click_count: 0,
2069 },
2070 )
2071 .unwrap();
2072
2073 assert_eq!(state.shapes[&7].transform.e, 0.4);
2074 assert_eq!(state.shapes[&7].transform.f, 0.2);
2075 }
2076
2077 #[test]
2078 fn unsupported_semantics_and_overflow_are_diagnostic_and_finite() {
2079 let timing = timing(
2080 r#"<p:anim from="0" to="100000"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>ppt_x</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
2081 <p:anim from="-1.7976931348623157e308" to="1.7976931348623157e308"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
2082 <p:animEffect transition="none" filter="fade"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl></p:cBhvr></p:animEffect>
2083 <p:set><p:cBhvr><p:cTn id="4" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"><p:rtn val="all"/></p:cond></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
2084 <p:set><p:cBhvr><p:cTn id="5" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
2085 <p:animEffect transition="in" filter="producer-fade-token"><p:cBhvr><p:cTn id="6" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="12"/></p:tgtEl></p:cBhvr></p:animEffect>
2086 <p:animEffect transition="in" filter="wipe(diagonal)"><p:cBhvr><p:cTn id="7" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="13"/></p:tgtEl></p:cBhvr></p:animEffect>"#,
2087 );
2088 let state = evaluate_timeline(
2089 Some(&timing),
2090 None,
2091 TimelinePosition {
2092 elapsed_ms: 1000,
2093 click_count: 0,
2094 },
2095 )
2096 .unwrap();
2097
2098 assert!(!state.shapes.contains_key(&7));
2099 assert!(!state.shapes.contains_key(&8));
2100 assert!(!state.shapes.contains_key(&9));
2101 assert!(!state.shapes.contains_key(&10));
2102 assert!(!state.shapes[&11].visible);
2103 assert!(!state.shapes.contains_key(&12));
2104 assert!(!state.shapes.contains_key(&13));
2105 assert!(
2106 state
2107 .shapes
2108 .values()
2109 .all(super::EvaluatedShapeState::is_finite)
2110 );
2111 assert!(
2112 state
2113 .diagnostics
2114 .iter()
2115 .any(|diagnostic| diagnostic.message.contains("ppt_x"))
2116 );
2117 assert!(
2118 state
2119 .diagnostics
2120 .iter()
2121 .any(|diagnostic| diagnostic.message.contains("arithmetic"))
2122 );
2123 assert!(state.diagnostics.iter().any(|diagnostic| {
2124 diagnostic
2125 .message
2126 .contains("unsupported timing condition trigger")
2127 }));
2128 assert!(state.diagnostics.iter().any(|diagnostic| {
2129 diagnostic
2130 .message
2131 .contains("unsupported timing effect transition none")
2132 }));
2133 assert!(state.diagnostics.iter().any(|diagnostic| {
2134 diagnostic
2135 .message
2136 .contains("unsupported timing effect producer-fade-token")
2137 }));
2138 assert!(state.diagnostics.iter().any(|diagnostic| {
2139 diagnostic
2140 .message
2141 .contains("unsupported timing effect wipe(diagonal)")
2142 }));
2143 }
2144}