返回 ppt-master
animation_config.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / animation_config.py
1 """Motion sidecar loading, SVG target scanning, and validation."""
2
3 from __future__ import annotations
4
5 import json
6 import math
7 import re
8 from dataclasses import dataclass
9 from pathlib import Path, PureWindowsPath
10 from typing import Any
11 from xml.etree import ElementTree as ET
12
13 from hyperlink_contract import SHAPE_HYPERLINK_ATTR
14
15 from pptx_animations import (
16 ANIMATIONS,
17 ANIMATION_AFTER_EFFECTS,
18 ANIMATION_MODES,
19 ANIMATION_RESTARTS,
20 ANIMATION_TIMING_OPTION_FIELDS,
21 ANIMATION_TRIGGERS,
22 animation_effect_supports_bounce_end,
23 animation_seconds_to_milliseconds,
24 normalize_animation_effect,
25 normalize_animation_effect_options,
26 normalize_animation_effect_request,
27 normalize_animation_trigger,
28 )
29 from pptx_transitions import (
30 normalize_transition_effect,
31 normalize_transition_effect_request,
32 validate_seconds,
33 )
34 from slide_roster import discover_slide_svgs
35
36 from .drawingml.utils import SVG_NS
37 from .pptx_package.narration import AUDIO_CONTENT_TYPES
38 from .semantic_markers import is_static_page_frame
39
40
41 _NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style'))
42 _INHERITANCE_SENSITIVE_ANIMATION_FIELDS = frozenset({
43 'effect',
44 'effect_options',
45 'repeat_count',
46 'repeat_duration',
47 'accelerate',
48 'decelerate',
49 'bounce_end',
50 })
51 _GROUP_EFFECT_FIELDS = frozenset({
52 'effect',
53 'effect_options',
54 'duration',
55 'delay',
56 'order',
57 'trigger',
58 'trigger_shape',
59 *ANIMATION_TIMING_OPTION_FIELDS,
60 'after_effect',
61 'sound',
62 })
63 _CHROME_ID_TOKENS = frozenset({
64 'background', 'bg',
65 'decoration', 'decorations', 'decor',
66 'header', 'footer',
67 'chrome', 'watermark',
68 'pagenumber', 'pagenum', 'slidenumber', 'slidenum',
69 'logo', 'nav', 'rule',
70 })
71
72
73 @dataclass(frozen=True)
74 class GroupTarget:
75 """Top-level SVG group available for PowerPoint animation anchoring."""
76
77 slide: str
78 group_id: str
79 order: int
80 chrome: bool = False
81 structurally_static: bool = False
82 has_hyperlink: bool = False
83
84
85 @dataclass(frozen=True)
86 class MorphPair:
87 """One explicit PowerPoint Morph identity across adjacent slides."""
88
89 source_slide: str
90 destination_slide: str
91 key: str
92 source_group_id: str
93 destination_group_id: str
94
95 @property
96 def shape_name(self) -> str:
97 """Return the Selection Pane name PowerPoint uses for forced matching."""
98 return f'!!{self.key}'
99
100
101 def _tag_name(elem: ET.Element) -> str:
102 return elem.tag.replace(f'{{{SVG_NS}}}', '')
103
104
105 def is_chrome_id(elem_id: str | None) -> bool:
106 """Return whether a group id represents static slide chrome."""
107 if not elem_id:
108 return False
109 lower = elem_id.lower()
110 compact = lower.replace('-', '').replace('_', '')
111 if compact in _CHROME_ID_TOKENS:
112 return True
113 tokens = re.split(r'[-_]', lower)
114 return any(t in _CHROME_ID_TOKENS for t in tokens if t)
115
116
117 def usable_animation_group_id(raw: str | None) -> str | None:
118 """Return one nonblank SVG animation anchor verbatim, else ``None``."""
119 return raw if raw and raw.strip() else None
120
121
122 def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]:
123 """Scan one SVG for top-level visible group ids and anonymous groups."""
124 root = ET.parse(str(svg_path)).getroot()
125 targets: list[GroupTarget] = []
126 anonymous_groups: list[str] = []
127 visual_index = 0
128
129 for child in root:
130 tag = _tag_name(child)
131 if tag in _NON_VISUAL_TAGS:
132 continue
133 visual_index += 1
134 if tag != 'g':
135 continue
136 group_id = usable_animation_group_id(child.get('id'))
137 if group_id is None:
138 anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}')
139 continue
140 role = child.get('data-pptx-role')
141 placeholder = child.get('data-pptx-placeholder')
142 has_explicit_semantics = role is not None or placeholder is not None
143 has_structural_layer = child.get('data-pptx-layer') is not None
144 semantic_static = (
145 has_explicit_semantics
146 and is_static_page_frame(role, placeholder)
147 )
148 structurally_static = has_structural_layer or semantic_static
149 if has_structural_layer:
150 chrome = True
151 elif has_explicit_semantics:
152 chrome = semantic_static
153 else:
154 chrome = is_chrome_id(group_id)
155 targets.append(
156 GroupTarget(
157 slide=svg_path.stem,
158 group_id=group_id,
159 order=visual_index,
160 chrome=chrome,
161 structurally_static=structurally_static,
162 has_hyperlink=any(
163 _tag_name(descendant) == 'a'
164 or descendant.get(SHAPE_HYPERLINK_ATTR) is not None
165 for descendant in child.iter()
166 ),
167 )
168 )
169
170 return targets, anonymous_groups
171
172
173 def _duplicate_target_ids(targets: list[GroupTarget]) -> tuple[str, ...]:
174 """Return duplicate top-level animation anchors in deterministic order."""
175 counts: dict[str, int] = {}
176 for target in targets:
177 counts[target.group_id] = counts.get(target.group_id, 0) + 1
178 return tuple(sorted(group_id for group_id, count in counts.items() if count > 1))
179
180
181 def _duplicate_target_error(slide_name: str, duplicates: tuple[str, ...]) -> str:
182 rendered = ', '.join(repr(group_id) for group_id in duplicates)
183 return (
184 f'SVG slide "{slide_name}" has duplicate top-level group id(s): '
185 f'{rendered}; animation target ids must be unique'
186 )
187
188
189 def _require_unique_target_ids(
190 slide_name: str,
191 targets: list[GroupTarget],
192 ) -> None:
193 duplicates = _duplicate_target_ids(targets)
194 if duplicates:
195 raise ValueError(_duplicate_target_error(slide_name, duplicates))
196
197
198 def scan_project_targets(
199 project_path: Path,
200 *,
201 svg_files: list[Path] | None = None,
202 ) -> tuple[dict[str, list[GroupTarget]], list[str]]:
203 """Scan selected SVG files, defaulting to ``svg_output/*.svg``."""
204 targets_by_slide: dict[str, list[GroupTarget]] = {}
205 anonymous_groups: list[str] = []
206 if svg_files is None:
207 svg_dir = project_path / 'svg_output'
208 if not svg_dir.is_dir():
209 return targets_by_slide, [f'svg_output directory not found: {svg_dir}']
210 svg_files = discover_slide_svgs(svg_dir)
211
212 for svg_path in svg_files:
213 targets, anonymous = scan_svg_targets(svg_path)
214 targets_by_slide[svg_path.stem] = targets
215 anonymous_groups.extend(anonymous)
216
217 return targets_by_slide, anonymous_groups
218
219
220 def default_config_path(project_path: Path) -> Path:
221 return project_path / 'animations.json'
222
223
224 def load_animation_config(project_path: Path, config_path: str | None = None) -> dict[str, Any] | None:
225 """Load animation config; only an absent default sidecar is optional."""
226 if config_path is not None:
227 if not config_path.strip():
228 raise ValueError('Animation config path must be non-empty')
229 path = Path(config_path)
230 else:
231 path = default_config_path(project_path)
232 if config_path is not None and not path.is_absolute():
233 path = project_path / path
234 if not path.exists():
235 if config_path is not None:
236 raise FileNotFoundError(f'Animation config does not exist: {path}')
237 return None
238
239 with open(path, 'r', encoding='utf-8') as f:
240 data = json.load(f)
241 if not isinstance(data, dict):
242 raise ValueError(f'Animation config must be a JSON object: {path}')
243 if data.get('version', 1) != 1:
244 raise ValueError(f'Unsupported animation config version: {data.get("version")}')
245 return data
246
247
248 def _valid_transition_effect(effect: str) -> bool:
249 try:
250 normalize_transition_effect(effect)
251 except ValueError:
252 return False
253 return True
254
255
256 def _animation_effect_error(effect: object, label: str) -> str | None:
257 if not isinstance(effect, str):
258 return f'animations.json {label} animation effect must be a string'
259 try:
260 normalize_animation_effect(effect)
261 except ValueError:
262 valid = ', '.join((*ANIMATIONS, *ANIMATION_MODES, 'none'))
263 return (
264 f'animations.json {label} has unknown animation effect: {effect}; '
265 f'valid effects: {valid}'
266 )
267 return None
268
269
270 def resolve_slide_animation_config(
271 default_animation: dict[str, Any],
272 slide_animation: dict[str, Any],
273 ) -> dict[str, Any]:
274 """Merge one slide animation over defaults using writer inheritance rules."""
275 resolved = dict(default_animation)
276 if 'effect' in slide_animation and 'effect_options' not in slide_animation:
277 resolved.pop('effect_options', None)
278 resolved.update(slide_animation)
279 return resolved
280
281
282 def animation_group_effect_entries(
283 group_cfg: dict[str, Any],
284 *,
285 path: str,
286 ) -> tuple[tuple[str, dict[str, Any]], ...]:
287 """Expand one legacy group block or one ordered multi-effect envelope."""
288 if 'effects' not in group_cfg:
289 return ((path, group_cfg),)
290
291 extra_fields = sorted(set(group_cfg) - {'effects'})
292 if extra_fields:
293 rendered = ', '.join(repr(field) for field in extra_fields)
294 raise ValueError(
295 f'animations.json {path} cannot combine "effects" with '
296 f'other group-level field(s): {rendered}'
297 )
298 effects = group_cfg['effects']
299 if not isinstance(effects, list):
300 raise ValueError(f'animations.json {path}.effects must be an array')
301 if not effects:
302 raise ValueError(
303 f'animations.json {path}.effects must contain at least one effect'
304 )
305
306 entries: list[tuple[str, dict[str, Any]]] = []
307 for index, effect_cfg in enumerate(effects):
308 effect_path = f'{path}.effects[{index}]'
309 if not isinstance(effect_cfg, dict):
310 raise ValueError(
311 f'animations.json {effect_path} must be an object'
312 )
313 if 'effect' not in effect_cfg:
314 raise ValueError(
315 f'animations.json {effect_path}.effect is required'
316 )
317 entries.append((effect_path, effect_cfg))
318 return tuple(entries)
319
320
321 def _animation_parameter_errors(
322 value: dict[str, Any],
323 label: str,
324 *,
325 inherited_effect: object,
326 sound_is_path: bool = True,
327 ) -> list[str]:
328 """Validate PowerPoint effect/timing parameters shared by all scopes."""
329 errors: list[str] = []
330 effect = value.get('effect', inherited_effect)
331 effect_options = value.get('effect_options')
332 if effect_options is not None and 'effect' not in value:
333 errors.append(
334 f'animations.json {label} effect_options requires an explicit effect'
335 )
336 else:
337 try:
338 normalize_animation_effect_request(
339 effect,
340 effect_options,
341 allow_none=True,
342 allow_modes=True,
343 )
344 except ValueError as exc:
345 errors.append(f'animations.json {label}: {exc}')
346
347 repeat_count = value.get('repeat_count')
348 repeat_duration = value.get('repeat_duration')
349 if repeat_count is not None:
350 if (
351 isinstance(repeat_count, bool)
352 or not isinstance(repeat_count, (int, float))
353 or not math.isfinite(float(repeat_count))
354 or float(repeat_count) <= 0
355 or float(repeat_count) * 1000 > 4_294_967_295
356 ):
357 errors.append(
358 f'animations.json {label} repeat_count must be a positive number: '
359 f'{repeat_count!r}'
360 )
361 if repeat_duration is not None:
362 try:
363 animation_seconds_to_milliseconds(
364 repeat_duration,
365 f'animations.json {label} repeat_duration',
366 allow_zero=False,
367 )
368 except ValueError as exc:
369 errors.append(str(exc))
370 if repeat_count is not None and repeat_duration is not None:
371 errors.append(
372 f'animations.json {label} repeat_count and repeat_duration '
373 'are mutually exclusive'
374 )
375
376 for field in ('auto_reverse', 'rewind'):
377 if field in value and not isinstance(value[field], bool):
378 errors.append(
379 f'animations.json {label} {field} must be a boolean: '
380 f'{value[field]!r}'
381 )
382 ratios: dict[str, float] = {}
383 for field in ('accelerate', 'decelerate', 'bounce_end'):
384 if field not in value:
385 continue
386 raw_ratio = value[field]
387 if (
388 isinstance(raw_ratio, bool)
389 or not isinstance(raw_ratio, (int, float))
390 or not math.isfinite(float(raw_ratio))
391 or not 0 <= float(raw_ratio) <= 1
392 ):
393 errors.append(
394 f'animations.json {label} {field} must be between 0 and 1: '
395 f'{raw_ratio!r}'
396 )
397 else:
398 ratios[field] = float(raw_ratio)
399 if ratios.get('accelerate', 0) + ratios.get('decelerate', 0) > 1:
400 errors.append(
401 f'animations.json {label} accelerate + decelerate must not exceed 1'
402 )
403 if ratios.get('bounce_end', 0) and ratios.get('decelerate', 0):
404 errors.append(
405 f'animations.json {label} bounce_end and decelerate are '
406 'mutually exclusive in PowerPoint'
407 )
408
409 if 'restart' in value and value['restart'] not in ANIMATION_RESTARTS:
410 errors.append(
411 f'animations.json {label} restart must be one of '
412 f'{", ".join(ANIMATION_RESTARTS)}: {value["restart"]!r}'
413 )
414
415 if 'after_effect' in value:
416 after_effect = value['after_effect']
417 if isinstance(after_effect, str):
418 after_type = after_effect
419 after_color = None
420 elif isinstance(after_effect, dict):
421 unknown = set(after_effect) - {'type', 'color'}
422 for field in sorted(unknown):
423 errors.append(
424 f'animations.json {label} after_effect has unknown field: {field}'
425 )
426 after_type = after_effect.get('type', 'none')
427 after_color = after_effect.get('color')
428 else:
429 after_type = None
430 after_color = None
431 errors.append(
432 f'animations.json {label} after_effect must be a string or object'
433 )
434 if after_type is not None and after_type not in ANIMATION_AFTER_EFFECTS:
435 errors.append(
436 f'animations.json {label} after_effect.type must be one of '
437 f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {after_type!r}'
438 )
439 elif after_type == 'dim':
440 if after_color is None:
441 errors.append(
442 f'animations.json {label} dim after_effect requires color'
443 )
444 else:
445 try:
446 normalize_animation_effect_options(
447 'emphasis_change_fill_color',
448 {'color': after_color},
449 )
450 except ValueError as exc:
451 errors.append(f'animations.json {label}: {exc}')
452 elif after_color is not None:
453 errors.append(
454 f'animations.json {label} after_effect.color is valid only '
455 'with type "dim"'
456 )
457
458 if 'sound' in value:
459 sound = value['sound']
460 if sound_is_path and (
461 not isinstance(sound, str) or not sound.strip()
462 ):
463 errors.append(
464 f'animations.json {label} sound must be a non-empty path string'
465 )
466 elif sound_is_path and Path(sound).suffix.lower() not in AUDIO_CONTENT_TYPES:
467 errors.append(
468 f'animations.json {label} sound must use .m4a, .mp3, or .wav'
469 )
470 return errors
471
472
473 def _animation_trigger_error(trigger: object, label: str) -> str | None:
474 if not isinstance(trigger, str):
475 return f'animations.json {label} animation trigger must be a string'
476 try:
477 normalize_animation_trigger(trigger)
478 except ValueError:
479 valid = ', '.join(ANIMATION_TRIGGERS)
480 return (
481 f'animations.json {label} has unknown animation trigger: {trigger}; '
482 f'valid triggers: {valid}'
483 )
484 return None
485
486
487 def _unknown_field_errors(
488 value: dict[str, Any],
489 allowed: frozenset[str],
490 label: str,
491 ) -> list[str]:
492 return [
493 f'animations.json {label} has unknown field: {field}'
494 for field in sorted(set(value) - allowed)
495 ]
496
497
498 def validate_transition_config(config: dict[str, Any]) -> list[str]:
499 """Return fatal transition-sidecar errors that must block export."""
500 errors: list[str] = []
501 defaults = config.get('defaults', {})
502 default_effect = 'fade'
503 if not isinstance(defaults, dict):
504 errors.append('animations.json field "defaults" must be an object')
505 else:
506 errors.extend(
507 _transition_scope_errors(
508 defaults,
509 'defaults',
510 inherited_effect='fade',
511 )
512 )
513 transition_defaults = defaults.get('transition', {})
514 if isinstance(transition_defaults, dict):
515 value = transition_defaults.get('effect', default_effect)
516 if isinstance(value, str) and _valid_transition_effect(value):
517 default_effect = value
518
519 slides = config.get('slides', {})
520 if not isinstance(slides, dict):
521 errors.append('animations.json field "slides" must be an object')
522 return errors
523 for slide_name, slide_cfg in slides.items():
524 if not isinstance(slide_cfg, dict):
525 errors.append(f'animations.json slide "{slide_name}" must be an object')
526 continue
527 errors.extend(
528 _transition_scope_errors(
529 slide_cfg,
530 f'slide "{slide_name}"',
531 inherited_effect=default_effect,
532 )
533 )
534 errors.extend(_morph_scope_errors(slide_name, slide_cfg))
535 return errors
536
537
538 def _transition_scope_errors(
539 scope: dict[str, Any],
540 label: str,
541 *,
542 inherited_effect: str,
543 ) -> list[str]:
544 if 'transition' not in scope:
545 return []
546 transition = scope['transition']
547 if not isinstance(transition, dict):
548 return [f'animations.json {label} field "transition" must be an object']
549
550 errors = _unknown_field_errors(
551 transition,
552 frozenset({
553 'effect',
554 'effect_options',
555 'duration',
556 'auto_advance',
557 'sound',
558 }),
559 f'{label} transition',
560 )
561 effect = transition.get('effect', inherited_effect)
562 effect_options = transition.get('effect_options')
563 if effect_options is not None and 'effect' not in transition:
564 errors.append(
565 f'animations.json {label} transition effect_options requires '
566 'an explicit effect'
567 )
568 else:
569 try:
570 normalize_transition_effect_request(effect, effect_options)
571 except ValueError as exc:
572 errors.append(f'animations.json {label} transition: {exc}')
573 try:
574 duration_allows_zero = (
575 normalize_transition_effect(effect) is None
576 )
577 except ValueError:
578 duration_allows_zero = False
579 for field, allow_zero in (
580 ('duration', duration_allows_zero),
581 ('auto_advance', True),
582 ):
583 if field not in transition:
584 continue
585 try:
586 validate_seconds(
587 transition[field],
588 f'animations.json {label} transition {field}',
589 allow_zero=allow_zero,
590 )
591 except ValueError as exc:
592 errors.append(str(exc))
593 if 'sound' in transition:
594 sound = transition['sound']
595 if sound is None:
596 return errors
597 if not isinstance(sound, str) or not sound.strip():
598 errors.append(
599 f'animations.json {label} transition sound must be a '
600 'non-empty project-relative .wav path or null'
601 )
602 elif Path(sound).is_absolute() or PureWindowsPath(sound).drive:
603 errors.append(
604 f'animations.json {label} transition sound must be '
605 f'project-relative: {sound!r}'
606 )
607 elif Path(sound).suffix.lower() != '.wav':
608 errors.append(
609 f'animations.json {label} transition sound must use .wav'
610 )
611 return errors
612
613
614 def _morph_scope_errors(
615 slide_name: object,
616 slide_cfg: dict[str, Any],
617 ) -> list[str]:
618 """Validate one destination slide's deterministic Morph declaration."""
619 if 'morph' not in slide_cfg:
620 return []
621 label = f'slide "{slide_name}" morph'
622 morph = slide_cfg['morph']
623 if not isinstance(morph, dict):
624 return [f'animations.json {label} must be an object']
625
626 errors = _unknown_field_errors(
627 morph,
628 frozenset({'from', 'pairs'}),
629 label,
630 )
631 source_slide = morph.get('from')
632 if not isinstance(source_slide, str) or not source_slide.strip():
633 errors.append(
634 f'animations.json {label} field "from" must be a non-empty slide stem'
635 )
636
637 pairs = morph.get('pairs')
638 if not isinstance(pairs, dict) or not pairs:
639 errors.append(
640 f'animations.json {label} field "pairs" must be a non-empty object'
641 )
642 else:
643 source_groups: dict[str, str] = {}
644 destination_groups: dict[str, str] = {}
645 for key, pair in pairs.items():
646 pair_label = f'{label} pair "{key}"'
647 if (
648 not isinstance(key, str)
649 or not key.strip()
650 or key != key.strip()
651 or key.startswith('!!')
652 or any(ord(char) < 32 for char in key)
653 ):
654 errors.append(
655 f'animations.json {pair_label} key must be a trimmed, '
656 'non-empty name without the !! prefix or control characters'
657 )
658 if not isinstance(pair, dict):
659 errors.append(f'animations.json {pair_label} must be an object')
660 continue
661 errors.extend(
662 _unknown_field_errors(
663 pair,
664 frozenset({'from', 'to'}),
665 pair_label,
666 )
667 )
668 for field in ('from', 'to'):
669 value = pair.get(field)
670 if not isinstance(value, str) or not value.strip():
671 errors.append(
672 f'animations.json {pair_label} field "{field}" must '
673 'be a non-empty top-level group id'
674 )
675 source_group = pair.get('from')
676 if isinstance(source_group, str) and source_group.strip():
677 previous = source_groups.setdefault(source_group, str(key))
678 if previous != str(key):
679 errors.append(
680 f'animations.json {label} source group '
681 f'"{source_group}" is assigned to both "{previous}" '
682 f'and "{key}"'
683 )
684 destination_group = pair.get('to')
685 if isinstance(destination_group, str) and destination_group.strip():
686 previous = destination_groups.setdefault(
687 destination_group,
688 str(key),
689 )
690 if previous != str(key):
691 errors.append(
692 f'animations.json {label} destination group '
693 f'"{destination_group}" is assigned to both "{previous}" '
694 f'and "{key}"'
695 )
696
697 transition = slide_cfg.get('transition')
698 if not isinstance(transition, dict) or 'effect' not in transition:
699 errors.append(
700 f'animations.json {label} requires an explicit slide transition '
701 'effect "morph"'
702 )
703 else:
704 try:
705 effect, options = normalize_transition_effect_request(
706 transition.get('effect'),
707 transition.get('effect_options'),
708 )
709 except ValueError:
710 pass
711 else:
712 if effect != 'morph':
713 errors.append(
714 f'animations.json {label} requires transition effect "morph"'
715 )
716 elif options.get('morph_by', 'object') != 'object':
717 errors.append(
718 f'animations.json {label} requires Morph by object'
719 )
720 return errors
721
722
723 def _resolve_morph_pairs(
724 slide_order: list[str],
725 config: dict[str, Any],
726 ) -> tuple[list[MorphPair], list[str]]:
727 """Resolve sidecar Morph declarations against the actual slide order."""
728 slides = config.get('slides', {})
729 if not isinstance(slides, dict):
730 return [], ['animations.json field "slides" must be an object']
731
732 order_by_slide = {slide_name: index for index, slide_name in enumerate(slide_order)}
733 pairs: list[MorphPair] = []
734 errors: list[str] = []
735 assignments: dict[str, dict[str, str]] = {}
736 keys: dict[str, dict[str, str]] = {}
737 declared_keys_by_destination: dict[str, set[str]] = {}
738
739 for destination_slide, slide_cfg in slides.items():
740 if not isinstance(slide_cfg, dict) or 'morph' not in slide_cfg:
741 continue
742 scope_errors = _morph_scope_errors(destination_slide, slide_cfg)
743 if scope_errors:
744 errors.extend(scope_errors)
745 continue
746 destination_index = order_by_slide.get(str(destination_slide))
747 if destination_index is None:
748 errors.append(
749 'animations.json morph destination slide is missing: '
750 f'{destination_slide}'
751 )
752 continue
753 if destination_index == 0:
754 errors.append(
755 'animations.json first slide cannot declare an incoming Morph: '
756 f'{destination_slide}'
757 )
758 continue
759
760 morph = slide_cfg['morph']
761 source_slide = str(morph['from'])
762 expected_source = slide_order[destination_index - 1]
763 if source_slide != expected_source:
764 errors.append(
765 f'animations.json slide "{destination_slide}" morph.from must '
766 f'reference the immediately preceding slide "{expected_source}", '
767 f'not "{source_slide}"'
768 )
769 continue
770
771 declared_keys_by_destination[str(destination_slide)] = set(
772 morph['pairs']
773 )
774 for key, pair in morph['pairs'].items():
775 resolved = MorphPair(
776 source_slide=source_slide,
777 destination_slide=str(destination_slide),
778 key=str(key),
779 source_group_id=str(pair['from']),
780 destination_group_id=str(pair['to']),
781 )
782 pair_conflict = False
783 for slide_name, group_id in (
784 (resolved.source_slide, resolved.source_group_id),
785 (resolved.destination_slide, resolved.destination_group_id),
786 ):
787 slide_assignments = assignments.setdefault(slide_name, {})
788 previous_key = slide_assignments.setdefault(group_id, resolved.key)
789 if previous_key != resolved.key:
790 errors.append(
791 f'animations.json Morph group "{slide_name}/{group_id}" '
792 f'is assigned to both "{previous_key}" and "{resolved.key}"'
793 )
794 pair_conflict = True
795 slide_keys = keys.setdefault(slide_name, {})
796 previous_group = slide_keys.setdefault(resolved.key, group_id)
797 if previous_group != group_id:
798 errors.append(
799 f'animations.json Morph key "{resolved.key}" maps to both '
800 f'"{slide_name}/{previous_group}" and '
801 f'"{slide_name}/{group_id}"'
802 )
803 pair_conflict = True
804 if not pair_conflict:
805 pairs.append(resolved)
806
807 for destination_slide, declared_keys in declared_keys_by_destination.items():
808 destination_index = order_by_slide[destination_slide]
809 source_slide = slide_order[destination_index - 1]
810 shared_keys = (
811 set(keys.get(source_slide, {}))
812 & set(keys.get(destination_slide, {}))
813 )
814 unexpected_keys = sorted(shared_keys - declared_keys)
815 if unexpected_keys:
816 errors.append(
817 f'animations.json slide "{destination_slide}" Morph would '
818 'force undeclared adjacent key(s): '
819 + ', '.join(f'"{key}"' for key in unexpected_keys)
820 )
821 return pairs, list(dict.fromkeys(errors))
822
823
824 def resolve_morph_pairs(
825 slide_order: list[str],
826 config: dict[str, Any] | None,
827 ) -> tuple[MorphPair, ...]:
828 """Return validated deterministic Morph pairs in authored order."""
829 if not config:
830 return ()
831 pairs, errors = _resolve_morph_pairs(slide_order, config)
832 if errors:
833 raise ValueError('; '.join(errors))
834 return tuple(pairs)
835
836
837 def validate_animation_config_errors(config: dict[str, Any]) -> list[str]:
838 """Return fatal object-animation errors that must block export."""
839 errors = _unknown_field_errors(
840 config,
841 frozenset({'version', 'defaults', 'slides'}),
842 'top level',
843 )
844 defaults = config.get('defaults', {})
845 if not isinstance(defaults, dict):
846 errors.append('animations.json field "defaults" must be an object')
847 else:
848 errors.extend(
849 _unknown_field_errors(
850 defaults,
851 frozenset({'transition', 'animation'}),
852 'defaults',
853 )
854 )
855 errors.extend(_animation_scope_errors(defaults, 'defaults'))
856
857 slides = config.get('slides', {})
858 if not isinstance(slides, dict):
859 errors.append('animations.json field "slides" must be an object')
860 return list(dict.fromkeys(errors))
861
862 for slide_name, slide_cfg in slides.items():
863 if not isinstance(slide_cfg, dict):
864 errors.append(f'animations.json slide "{slide_name}" must be an object')
865 continue
866 errors.extend(
867 _unknown_field_errors(
868 slide_cfg,
869 frozenset({'transition', 'animation', 'groups', 'morph'}),
870 f'slide "{slide_name}"',
871 )
872 )
873 errors.extend(
874 _animation_scope_errors(slide_cfg, f'slide "{slide_name}"')
875 )
876 errors.extend(_animation_group_errors(slide_name, slide_cfg))
877 errors.extend(_resolved_animation_parameter_errors(config))
878 return list(dict.fromkeys(errors))
879
880
881 def _animation_scope_errors(scope: dict[str, Any], label: str) -> list[str]:
882 if 'animation' not in scope:
883 return []
884 animation = scope['animation']
885 if not isinstance(animation, dict):
886 return [f'animations.json {label} field "animation" must be an object']
887
888 errors = _unknown_field_errors(
889 animation,
890 frozenset({
891 'effect',
892 'effect_options',
893 'duration',
894 'stagger',
895 'trigger',
896 *ANIMATION_TIMING_OPTION_FIELDS,
897 'after_effect',
898 'sound',
899 }),
900 f'{label} animation',
901 )
902 if 'effect' in animation:
903 effect_error = _animation_effect_error(animation['effect'], label)
904 if effect_error:
905 errors.append(effect_error)
906
907 for field, allow_zero in (('duration', False), ('stagger', True)):
908 if field not in animation:
909 continue
910 try:
911 animation_seconds_to_milliseconds(
912 animation[field],
913 f'animations.json {label} animation {field}',
914 allow_zero=allow_zero,
915 )
916 except ValueError as exc:
917 errors.append(str(exc))
918
919 if 'trigger' in animation:
920 trigger_error = _animation_trigger_error(animation['trigger'], label)
921 if trigger_error:
922 errors.append(trigger_error)
923 errors.extend(
924 _animation_parameter_errors(
925 animation,
926 f'{label} animation',
927 inherited_effect='auto',
928 )
929 )
930 return errors
931
932
933 def _animation_group_errors(
934 slide_name: object,
935 slide_cfg: dict[str, Any],
936 ) -> list[str]:
937 if 'groups' not in slide_cfg:
938 return []
939 groups = slide_cfg['groups']
940 if not isinstance(groups, dict):
941 return [
942 f'animations.json slide "{slide_name}" field "groups" must be an object'
943 ]
944
945 errors: list[str] = []
946 for group_id, group_cfg in groups.items():
947 path = (
948 f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
949 f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
950 )
951 if not isinstance(group_cfg, dict):
952 errors.append(f'animations.json {path} must be an object')
953 continue
954
955 if 'effects' not in group_cfg:
956 errors.extend(
957 _animation_effect_entry_errors(
958 group_cfg,
959 path,
960 require_effect=False,
961 target_group_id=str(group_id),
962 )
963 )
964 continue
965
966 extra_fields = sorted(set(group_cfg) - {'effects'})
967 if extra_fields:
968 rendered = ', '.join(repr(field) for field in extra_fields)
969 errors.append(
970 f'animations.json {path} cannot combine "effects" with '
971 f'other group-level field(s): {rendered}'
972 )
973 effects = group_cfg['effects']
974 if not isinstance(effects, list):
975 errors.append(f'animations.json {path}.effects must be an array')
976 continue
977 if not effects:
978 errors.append(
979 f'animations.json {path}.effects must contain at least one effect'
980 )
981 continue
982 for index, effect_cfg in enumerate(effects):
983 effect_path = f'{path}.effects[{index}]'
984 if not isinstance(effect_cfg, dict):
985 errors.append(
986 f'animations.json {effect_path} must be an object'
987 )
988 continue
989 errors.extend(
990 _animation_effect_entry_errors(
991 effect_cfg,
992 effect_path,
993 require_effect=True,
994 target_group_id=str(group_id),
995 )
996 )
997 return errors
998
999
1000 def _animation_effect_entry_errors(
1001 effect_cfg: dict[str, Any],
1002 path: str,
1003 *,
1004 require_effect: bool,
1005 target_group_id: str,
1006 ) -> list[str]:
1007 """Validate one legacy group block or one ``effects[]`` row."""
1008 errors = _unknown_field_errors(
1009 effect_cfg,
1010 _GROUP_EFFECT_FIELDS,
1011 path,
1012 )
1013 if require_effect and 'effect' not in effect_cfg:
1014 errors.append(f'animations.json {path}.effect is required')
1015 elif 'effect' in effect_cfg:
1016 effect_error = _animation_effect_error(effect_cfg['effect'], path)
1017 if effect_error:
1018 errors.append(effect_error)
1019
1020 for field, allow_zero in (('duration', False), ('delay', True)):
1021 if field not in effect_cfg:
1022 continue
1023 try:
1024 animation_seconds_to_milliseconds(
1025 effect_cfg[field],
1026 f'animations.json {path}.{field}',
1027 allow_zero=allow_zero,
1028 )
1029 except ValueError as exc:
1030 errors.append(str(exc))
1031
1032 if 'order' in effect_cfg:
1033 order = effect_cfg['order']
1034 if isinstance(order, bool) or not isinstance(order, int) or order <= 0:
1035 errors.append(
1036 f'animations.json {path}.order must be a positive integer: '
1037 f'{order!r}'
1038 )
1039
1040 if 'trigger' in effect_cfg:
1041 trigger_error = _animation_trigger_error(effect_cfg['trigger'], path)
1042 if trigger_error:
1043 errors.append(trigger_error)
1044
1045 if 'trigger_shape' in effect_cfg:
1046 trigger_shape = effect_cfg['trigger_shape']
1047 if not isinstance(trigger_shape, str) or not trigger_shape.strip():
1048 errors.append(
1049 f'animations.json {path}.trigger_shape must be a '
1050 f'non-empty group id: {trigger_shape!r}'
1051 )
1052 elif trigger_shape == target_group_id:
1053 errors.append(
1054 f'animations.json {path}.trigger_shape must reference '
1055 'a different group'
1056 )
1057 if effect_cfg.get('effect') == 'none':
1058 errors.append(
1059 f'animations.json {path}.trigger_shape cannot be used '
1060 'with effect "none"'
1061 )
1062 if (
1063 'trigger' in effect_cfg
1064 and effect_cfg.get('trigger') != 'on-click'
1065 ):
1066 errors.append(
1067 f'animations.json {path}.trigger_shape requires '
1068 'trigger "on-click" when trigger is explicit'
1069 )
1070
1071 errors.extend(
1072 _animation_parameter_errors(
1073 effect_cfg,
1074 path,
1075 inherited_effect='auto',
1076 )
1077 )
1078 return errors
1079
1080
1081 def _bounce_support_error(
1082 animation: dict[str, Any],
1083 label: str,
1084 ) -> str | None:
1085 """Return a writer-equivalent bounce support error for one resolved scope."""
1086 bounce_end = animation.get('bounce_end')
1087 if (
1088 isinstance(bounce_end, bool)
1089 or not isinstance(bounce_end, (int, float))
1090 or not math.isfinite(float(bounce_end))
1091 or float(bounce_end) <= 0
1092 ):
1093 return None
1094 try:
1095 effect, options = normalize_animation_effect_request(
1096 animation.get('effect', 'auto'),
1097 animation.get('effect_options'),
1098 allow_none=True,
1099 allow_modes=True,
1100 )
1101 except ValueError:
1102 return None
1103 if effect is None or effect in ANIMATION_MODES:
1104 return None
1105 if animation_effect_supports_bounce_end(effect, options):
1106 return None
1107 return (
1108 f'animations.json {label} effect {effect!r} has no behavior that '
1109 'supports bounce_end'
1110 )
1111
1112
1113 def _resolved_animation_parameter_errors(config: dict[str, Any]) -> list[str]:
1114 """Validate effective animation parameters after sidecar inheritance."""
1115 defaults = config.get('defaults', {})
1116 default_animation: dict[str, Any] = {'effect': 'none'}
1117 if isinstance(defaults, dict):
1118 value = defaults.get('animation', {})
1119 if isinstance(value, dict):
1120 default_animation = resolve_slide_animation_config(
1121 default_animation,
1122 value,
1123 )
1124
1125 errors: list[str] = []
1126 default_error = _bounce_support_error(default_animation, 'defaults animation')
1127 if default_error:
1128 errors.append(default_error)
1129
1130 slides = config.get('slides', {})
1131 if not isinstance(slides, dict):
1132 return errors
1133 for slide_name, slide_cfg in slides.items():
1134 if not isinstance(slide_cfg, dict):
1135 continue
1136 slide_value = slide_cfg.get('animation', {})
1137 if not isinstance(slide_value, dict):
1138 continue
1139 slide_animation = resolve_slide_animation_config(
1140 default_animation,
1141 slide_value,
1142 )
1143 if _INHERITANCE_SENSITIVE_ANIMATION_FIELDS & set(slide_value):
1144 errors.extend(
1145 _animation_parameter_errors(
1146 slide_animation,
1147 f'slide "{slide_name}" animation',
1148 inherited_effect='auto',
1149 )
1150 )
1151 error = _bounce_support_error(
1152 slide_animation,
1153 f'slide "{slide_name}" animation',
1154 )
1155 if error:
1156 errors.append(error)
1157
1158 groups = slide_cfg.get('groups', {})
1159 if not isinstance(groups, dict):
1160 continue
1161 for group_id, group_cfg in groups.items():
1162 if not isinstance(group_cfg, dict):
1163 continue
1164 path = (
1165 f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
1166 f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
1167 )
1168 try:
1169 effect_entries = animation_group_effect_entries(
1170 group_cfg,
1171 path=path,
1172 )
1173 except ValueError:
1174 continue
1175 for effect_path, effect_cfg in effect_entries:
1176 if not (
1177 _INHERITANCE_SENSITIVE_ANIMATION_FIELDS
1178 & set(effect_cfg)
1179 ):
1180 continue
1181 inherited_group_animation = {
1182 field: slide_animation[field]
1183 for field in (
1184 'effect',
1185 'effect_options',
1186 'duration',
1187 *ANIMATION_TIMING_OPTION_FIELDS,
1188 'after_effect',
1189 'sound',
1190 )
1191 if field in slide_animation
1192 }
1193 group_animation = resolve_slide_animation_config(
1194 inherited_group_animation,
1195 effect_cfg,
1196 )
1197 errors.extend(
1198 _animation_parameter_errors(
1199 group_animation,
1200 effect_path,
1201 inherited_effect='none',
1202 )
1203 )
1204 error = _bounce_support_error(
1205 group_animation,
1206 effect_path,
1207 )
1208 if error:
1209 errors.append(error)
1210 return errors
1211
1212
1213 def _declared_animation_sounds(
1214 config: dict[str, Any],
1215 ) -> tuple[tuple[str, object], ...]:
1216 """Return explicitly declared sidecar sound values with scope labels."""
1217 sounds: list[tuple[str, object]] = []
1218 defaults = config.get('defaults', {})
1219 if isinstance(defaults, dict):
1220 animation = defaults.get('animation', {})
1221 if isinstance(animation, dict) and 'sound' in animation:
1222 sounds.append(('defaults animation', animation['sound']))
1223
1224 slides = config.get('slides', {})
1225 if not isinstance(slides, dict):
1226 return tuple(sounds)
1227 for slide_name, slide_cfg in slides.items():
1228 if not isinstance(slide_cfg, dict):
1229 continue
1230 animation = slide_cfg.get('animation', {})
1231 if isinstance(animation, dict) and 'sound' in animation:
1232 sounds.append((f'slide "{slide_name}" animation', animation['sound']))
1233 groups = slide_cfg.get('groups', {})
1234 if not isinstance(groups, dict):
1235 continue
1236 for group_id, group_cfg in groups.items():
1237 if not isinstance(group_cfg, dict):
1238 continue
1239 path = (
1240 f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
1241 f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
1242 )
1243 try:
1244 effect_entries = animation_group_effect_entries(
1245 group_cfg,
1246 path=path,
1247 )
1248 except ValueError:
1249 continue
1250 for effect_path, effect_cfg in effect_entries:
1251 if 'sound' in effect_cfg:
1252 sounds.append((effect_path, effect_cfg['sound']))
1253 return tuple(sounds)
1254
1255
1256 def _animation_sound_path_errors(
1257 project_path: Path,
1258 config: dict[str, Any],
1259 ) -> list[str]:
1260 """Validate declared animation sound files against the project root."""
1261 errors: list[str] = []
1262 project_root = project_path.resolve()
1263 for label, raw_sound in _declared_animation_sounds(config):
1264 if not isinstance(raw_sound, str) or not raw_sound.strip():
1265 continue
1266 sound_path = Path(raw_sound)
1267 if sound_path.suffix.lower() not in AUDIO_CONTENT_TYPES:
1268 errors.append(
1269 f'animations.json {label} sound must use .m4a, .mp3, or .wav'
1270 )
1271 continue
1272 if not sound_path.is_absolute():
1273 sound_path = project_root / sound_path
1274 sound_path = sound_path.resolve()
1275 if not sound_path.exists():
1276 errors.append(
1277 f'animations.json {label} sound file not found: {sound_path}'
1278 )
1279 elif not sound_path.is_file():
1280 errors.append(
1281 f'animations.json {label} sound path is not a regular file: '
1282 f'{sound_path}'
1283 )
1284 return errors
1285
1286
1287 def _declared_transition_sounds(
1288 config: dict[str, Any],
1289 ) -> tuple[tuple[str, object], ...]:
1290 """Return explicitly declared non-null transition sound values."""
1291 sounds: list[tuple[str, object]] = []
1292 defaults = config.get('defaults', {})
1293 if isinstance(defaults, dict):
1294 transition = defaults.get('transition', {})
1295 if (
1296 isinstance(transition, dict)
1297 and transition.get('sound') is not None
1298 ):
1299 sounds.append(('defaults transition', transition['sound']))
1300
1301 slides = config.get('slides', {})
1302 if not isinstance(slides, dict):
1303 return tuple(sounds)
1304 for slide_name, slide_cfg in slides.items():
1305 if not isinstance(slide_cfg, dict):
1306 continue
1307 transition = slide_cfg.get('transition', {})
1308 if (
1309 isinstance(transition, dict)
1310 and transition.get('sound') is not None
1311 ):
1312 sounds.append(
1313 (f'slide "{slide_name}" transition', transition['sound'])
1314 )
1315 return tuple(sounds)
1316
1317
1318 def _transition_sound_path_errors(
1319 project_path: Path,
1320 config: dict[str, Any],
1321 ) -> list[str]:
1322 """Validate transition sounds as project-contained WAV files."""
1323 errors: list[str] = []
1324 project_root = project_path.resolve()
1325 for label, raw_sound in _declared_transition_sounds(config):
1326 if not isinstance(raw_sound, str) or not raw_sound.strip():
1327 continue
1328 sound_path = Path(raw_sound)
1329 if sound_path.is_absolute() or PureWindowsPath(raw_sound).drive:
1330 errors.append(
1331 f'animations.json {label} sound must be project-relative: '
1332 f'{raw_sound!r}'
1333 )
1334 continue
1335 if sound_path.suffix.lower() != '.wav':
1336 continue
1337 resolved_path = (project_root / sound_path).resolve()
1338 try:
1339 resolved_path.relative_to(project_root)
1340 except ValueError:
1341 errors.append(
1342 f'animations.json {label} sound escapes the project root: '
1343 f'{raw_sound!r}'
1344 )
1345 continue
1346 if not resolved_path.exists():
1347 errors.append(
1348 f'animations.json {label} sound file not found: {resolved_path}'
1349 )
1350 elif not resolved_path.is_file():
1351 errors.append(
1352 f'animations.json {label} sound path is not a regular file: '
1353 f'{resolved_path}'
1354 )
1355 return errors
1356
1357
1358 def validate_animation_config(
1359 project_path: Path,
1360 config: dict[str, Any] | None = None,
1361 config_path: str | None = None,
1362 *,
1363 svg_files: list[Path] | None = None,
1364 ) -> list[str]:
1365 """Return sidecar-reference diagnostics for the selected SVG slides.
1366
1367 Fatal field/type/value checks are owned by
1368 :func:`validate_animation_config_errors`. Anonymous groups are warnings;
1369 references to invalid sound files, missing slides/groups, and structural
1370 targets are fatal at export call sites. Slides omitted from a sparse
1371 sidecar inherit defaults.
1372 """
1373 if config is None:
1374 config = load_animation_config(project_path, config_path)
1375 if not config:
1376 return []
1377
1378 warnings = _animation_sound_path_errors(project_path, config)
1379 warnings.extend(_transition_sound_path_errors(project_path, config))
1380 targets_by_slide, anonymous_groups = scan_project_targets(
1381 project_path,
1382 svg_files=svg_files,
1383 )
1384 for item in anonymous_groups:
1385 warnings.append(f'{item} has no id and cannot be customized in animations.json')
1386
1387 duplicates_by_slide: dict[str, tuple[str, ...]] = {}
1388 for slide_name, targets in targets_by_slide.items():
1389 duplicates = _duplicate_target_ids(targets)
1390 if duplicates:
1391 duplicates_by_slide[slide_name] = duplicates
1392 for slide_name, duplicates in duplicates_by_slide.items():
1393 warnings.append(_duplicate_target_error(slide_name, duplicates))
1394
1395 known_slides = set(targets_by_slide)
1396 known_groups_by_slide: dict[str, dict[str, GroupTarget]] = {}
1397 for slide_name, slide_targets in targets_by_slide.items():
1398 ambiguous_ids = set(duplicates_by_slide.get(slide_name, ()))
1399 known_groups_by_slide[slide_name] = {
1400 target.group_id: target
1401 for target in slide_targets
1402 if target.group_id not in ambiguous_ids
1403 }
1404 default_animation: dict[str, Any] = {'effect': 'none'}
1405 defaults = config.get('defaults', {})
1406 if isinstance(defaults, dict):
1407 animation_value = defaults.get('animation', {})
1408 if isinstance(animation_value, dict):
1409 default_animation = resolve_slide_animation_config(
1410 default_animation,
1411 animation_value,
1412 )
1413 slides = config.get('slides', {})
1414 if not isinstance(slides, dict):
1415 return list(dict.fromkeys(warnings))
1416 for slide_name, slide_cfg in slides.items():
1417 if slide_name not in known_slides:
1418 warnings.append(f'animations.json references missing slide: {slide_name}')
1419 continue
1420 if not isinstance(slide_cfg, dict):
1421 continue
1422
1423 slide_animation = default_animation
1424 animation_value = slide_cfg.get('animation', {})
1425 if isinstance(animation_value, dict):
1426 slide_animation = resolve_slide_animation_config(
1427 default_animation,
1428 animation_value,
1429 )
1430 slide_targets = targets_by_slide.get(slide_name, [])
1431 duplicate_ids = duplicates_by_slide.get(slide_name, ())
1432 ambiguous_ids = set(duplicate_ids)
1433 known_groups = known_groups_by_slide.get(slide_name, {})
1434 groups = slide_cfg.get('groups', {})
1435 if not isinstance(groups, dict):
1436 continue
1437 for group_id, group_cfg in groups.items():
1438 path = (
1439 f'slides[{json.dumps(str(slide_name), ensure_ascii=False)}]'
1440 f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
1441 )
1442 if group_id in ambiguous_ids:
1443 continue
1444 if group_id not in known_groups:
1445 warnings.append(
1446 f'animations.json {path} references a missing group'
1447 )
1448 continue
1449 target = known_groups[group_id]
1450 if not isinstance(group_cfg, dict):
1451 continue
1452 try:
1453 effect_entries = animation_group_effect_entries(
1454 group_cfg,
1455 path=path,
1456 )
1457 except ValueError:
1458 continue
1459 if (
1460 target.structurally_static
1461 and any(
1462 normalize_animation_effect(
1463 effect_cfg.get(
1464 'effect',
1465 slide_animation.get('effect', 'none'),
1466 ),
1467 allow_none=True,
1468 allow_modes=True,
1469 )
1470 is not None
1471 for _effect_path, effect_cfg in effect_entries
1472 )
1473 ):
1474 warnings.append(
1475 f'animations.json {path} references a non-animatable '
1476 'structural group'
1477 )
1478 for effect_path, effect_cfg in effect_entries:
1479 trigger_shape = effect_cfg.get('trigger_shape')
1480 if (
1481 not isinstance(trigger_shape, str)
1482 or not trigger_shape.strip()
1483 ):
1484 continue
1485 if trigger_shape in ambiguous_ids:
1486 warnings.append(
1487 f'animations.json {effect_path}.trigger_shape '
1488 f'references ambiguous group {trigger_shape!r}'
1489 )
1490 continue
1491 trigger_target = known_groups.get(trigger_shape)
1492 if trigger_target is None:
1493 warnings.append(
1494 f'animations.json {effect_path}.trigger_shape '
1495 f'references missing group {trigger_shape!r}'
1496 )
1497 elif trigger_target.structurally_static:
1498 warnings.append(
1499 f'animations.json {effect_path}.trigger_shape '
1500 f'references non-triggerable structural group '
1501 f'{trigger_shape!r}'
1502 )
1503 elif trigger_target.has_hyperlink:
1504 warnings.append(
1505 f'animations.json {effect_path}.trigger_shape '
1506 f'references hyperlink-bearing group {trigger_shape!r}; '
1507 'use an ordinary animation or a separate trigger'
1508 )
1509
1510 morph_pairs, morph_errors = _resolve_morph_pairs(
1511 list(targets_by_slide),
1512 config,
1513 )
1514 warnings.extend(morph_errors)
1515 for pair in morph_pairs:
1516 for slide_name, group_id in (
1517 (pair.source_slide, pair.source_group_id),
1518 (pair.destination_slide, pair.destination_group_id),
1519 ):
1520 target = known_groups_by_slide.get(slide_name, {}).get(group_id)
1521 if target is None:
1522 warnings.append(
1523 'animations.json Morph references missing or ambiguous group: '
1524 f'{slide_name}/{group_id}'
1525 )
1526 elif target.structurally_static:
1527 warnings.append(
1528 'animations.json Morph references structural group: '
1529 f'{slide_name}/{group_id}'
1530 )
1531 return list(dict.fromkeys(warnings))
1532
1533
1534 def build_scaffold(project_path: Path) -> dict[str, Any]:
1535 """Build an editable animation override scaffold from current SVGs.
1536
1537 Chrome groups are omitted — layer/slide-number placeholder semantics are
1538 authoritative, followed by an explicit structural role. ``is_chrome_id``
1539 remains only for marker-free legacy SVGs. Listing static page framing in
1540 the scaffold would be pure noise. A ``defaults`` stub is emitted up front
1541 to remind the editor that deck-wide overrides exist and most pages should
1542 inherit them.
1543 """
1544 transition_defaults = {
1545 'effect': 'fade',
1546 'duration': 0.4,
1547 'sound': None,
1548 }
1549 animation_defaults = {
1550 'effect': 'none',
1551 'duration': 0.4,
1552 'stagger': 0.5,
1553 'trigger': 'after-previous',
1554 }
1555 targets_by_slide, _anonymous = scan_project_targets(project_path)
1556 slides: dict[str, Any] = {}
1557 for slide_name, targets in targets_by_slide.items():
1558 _require_unique_target_ids(slide_name, targets)
1559 groups: dict[str, Any] = {}
1560 for target in targets:
1561 if target.chrome:
1562 continue
1563 groups[target.group_id] = {}
1564 slides[slide_name] = {
1565 'transition': {
1566 'effect': transition_defaults['effect'],
1567 'duration': transition_defaults['duration'],
1568 },
1569 'animation': dict(animation_defaults),
1570 'groups': groups,
1571 }
1572 return {
1573 'version': 1,
1574 'defaults': {
1575 'transition': transition_defaults,
1576 'animation': animation_defaults,
1577 },
1578 'slides': slides,
1579 }
1580
1581
1582 def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]:
1583 """Return one compact line per slide: ``<slide>: id1, id2, id3``.
1584
1585 Chrome groups are excluded — matches ``build_scaffold``'s policy so the
1586 listing reflects exactly what an editor can override. Returns
1587 ``(lines, anonymous_warnings)``.
1588 """
1589 targets_by_slide, anonymous = scan_project_targets(project_path)
1590 lines: list[str] = []
1591 for slide_name, targets in targets_by_slide.items():
1592 _require_unique_target_ids(slide_name, targets)
1593 ids = [t.group_id for t in targets if not t.chrome]
1594 if not ids:
1595 lines.append(f'{slide_name}: (no animatable groups)')
1596 else:
1597 lines.append(f'{slide_name}: {", ".join(ids)}')
1598 return lines, anonymous
1599
1600
1601 def write_scaffold(
1602 project_path: Path,
1603 output_path: str | None = None,
1604 *,
1605 force: bool = False,
1606 ) -> Path:
1607 """Write ``animations.json`` scaffold and return its path."""
1608 if output_path:
1609 path = Path(output_path)
1610 else:
1611 path = default_config_path(project_path)
1612 if output_path and not path.is_absolute():
1613 path = project_path / path
1614 if path.exists() and not force:
1615 raise FileExistsError(f'Animation config already exists: {path}')
1616
1617 scaffold = build_scaffold(project_path)
1618 path.write_text(
1619 json.dumps(scaffold, ensure_ascii=False, indent=2) + '\n',
1620 encoding='utf-8',
1621 )
1622 return path
1623
1623 lines PYTHON