"""Animation sidecar loading, SVG target scanning, and validation.""" from __future__ import annotations import json import re from dataclasses import dataclass from pathlib import Path from typing import Any from xml.etree import ElementTree as ET from .drawingml_utils import SVG_NS try: from pptx_animations import ANIMATIONS, TRANSITIONS except ImportError: ANIMATIONS = {} TRANSITIONS = {} _NON_VISUAL_TAGS = frozenset(('defs', 'title', 'desc', 'metadata', 'style')) _CHROME_ID_TOKENS = frozenset({ 'background', 'bg', 'decoration', 'decorations', 'decor', 'header', 'footer', 'chrome', 'watermark', 'pagenumber', 'pagenum', 'page-number', }) @dataclass(frozen=True) class GroupTarget: """Top-level SVG group available for PowerPoint animation anchoring.""" slide: str group_id: str order: int chrome: bool = False def _tag_name(elem: ET.Element) -> str: return elem.tag.replace(f'{{{SVG_NS}}}', '') def is_chrome_id(elem_id: str | None) -> bool: """Return whether a group id represents static slide chrome.""" if not elem_id: return False lower = elem_id.lower() compact = lower.replace('-', '').replace('_', '') if compact in _CHROME_ID_TOKENS: return True tokens = re.split(r'[-_]', lower) return any(t in _CHROME_ID_TOKENS for t in tokens if t) def scan_svg_targets(svg_path: Path) -> tuple[list[GroupTarget], list[str]]: """Scan one SVG for top-level visible group ids and anonymous groups.""" root = ET.parse(str(svg_path)).getroot() targets: list[GroupTarget] = [] anonymous_groups: list[str] = [] visual_index = 0 for child in root: tag = _tag_name(child) if tag in _NON_VISUAL_TAGS: continue visual_index += 1 if tag != 'g': continue group_id = child.get('id') if not group_id: anonymous_groups.append(f'{svg_path.stem}: top-level group #{visual_index}') continue targets.append( GroupTarget( slide=svg_path.stem, group_id=group_id, order=visual_index, chrome=is_chrome_id(group_id), ) ) return targets, anonymous_groups def scan_project_targets(project_path: Path) -> tuple[dict[str, list[GroupTarget]], list[str]]: """Scan ``svg_output/*.svg`` for animation targets.""" svg_dir = project_path / 'svg_output' targets_by_slide: dict[str, list[GroupTarget]] = {} anonymous_groups: list[str] = [] if not svg_dir.is_dir(): return targets_by_slide, [f'svg_output directory not found: {svg_dir}'] for svg_path in sorted(svg_dir.glob('*.svg')): targets, anonymous = scan_svg_targets(svg_path) targets_by_slide[svg_path.stem] = targets anonymous_groups.extend(anonymous) return targets_by_slide, anonymous_groups def default_config_path(project_path: Path) -> Path: return project_path / 'animations.json' def load_animation_config(project_path: Path, config_path: str | None = None) -> dict[str, Any] | None: """Load optional animation config; return ``None`` when absent.""" if config_path: path = Path(config_path) else: path = default_config_path(project_path) if config_path and not path.is_absolute(): path = project_path / path if not path.exists(): return None with open(path, 'r', encoding='utf-8') as f: data = json.load(f) if not isinstance(data, dict): raise ValueError(f'Animation config must be a JSON object: {path}') if data.get('version', 1) != 1: raise ValueError(f'Unsupported animation config version: {data.get("version")}') return data def _valid_animation_effect(effect: str) -> bool: return effect == 'none' or effect in ANIMATIONS or effect in ('auto', 'mixed', 'random') def _valid_transition_effect(effect: str) -> bool: return effect == 'none' or effect in TRANSITIONS def validate_animation_config( project_path: Path, config: dict[str, Any] | None = None, config_path: str | None = None, ) -> list[str]: """Validate sidecar references against current ``svg_output``.""" if config is None: config = load_animation_config(project_path, config_path) if not config: return [] warnings: list[str] = [] targets_by_slide, anonymous_groups = scan_project_targets(project_path) for item in anonymous_groups: warnings.append(f'{item} has no id and cannot be customized in animations.json') known_slides = set(targets_by_slide) slides = config.get('slides', {}) if slides and not isinstance(slides, dict): return ['animations.json field "slides" must be an object'] defaults = config.get('defaults', {}) if isinstance(defaults, dict): _validate_scope_effects(defaults, 'defaults', warnings) for slide_name, slide_cfg in (slides or {}).items(): if slide_name not in known_slides: warnings.append(f'animations.json references missing slide: {slide_name}') continue if not isinstance(slide_cfg, dict): warnings.append(f'animations.json slide "{slide_name}" must be an object') continue _validate_scope_effects(slide_cfg, f'slide "{slide_name}"', warnings) known_groups = {target.group_id for target in targets_by_slide[slide_name]} groups = slide_cfg.get('groups', {}) if groups and not isinstance(groups, dict): warnings.append(f'animations.json slide "{slide_name}" field "groups" must be an object') continue for group_id, group_cfg in (groups or {}).items(): if group_id not in known_groups: warnings.append( f'animations.json references missing group: {slide_name}/{group_id}' ) if not isinstance(group_cfg, dict): warnings.append(f'animations.json group "{slide_name}/{group_id}" must be an object') continue effect = group_cfg.get('effect') if effect is not None and not _valid_animation_effect(str(effect)): warnings.append( f'animations.json group "{slide_name}/{group_id}" has unknown effect: {effect}' ) return warnings def _validate_scope_effects(scope: dict[str, Any], label: str, warnings: list[str]) -> None: transition = scope.get('transition', {}) if isinstance(transition, dict): effect = transition.get('effect') if effect is not None and not _valid_transition_effect(str(effect)): warnings.append(f'animations.json {label} has unknown transition effect: {effect}') animation = scope.get('animation', {}) if isinstance(animation, dict): effect = animation.get('effect') if effect is not None and not _valid_animation_effect(str(effect)): warnings.append(f'animations.json {label} has unknown animation effect: {effect}') def build_scaffold(project_path: Path) -> dict[str, Any]: """Build an editable animation override scaffold from current SVGs. Chrome groups are omitted — exporter auto-detects them as ``none`` via ``is_chrome_id`` at render time, so listing them in the scaffold is pure noise. A ``defaults`` stub is emitted up front to remind the editor that deck-wide overrides exist and most pages should inherit them. """ targets_by_slide, _anonymous = scan_project_targets(project_path) slides: dict[str, Any] = {} for slide_name, targets in targets_by_slide.items(): groups: dict[str, Any] = {} for target in targets: if target.chrome: continue groups[target.group_id] = {} slides[slide_name] = {'groups': groups} return { 'version': 1, 'defaults': { 'transition': {'effect': 'fade', 'duration': 0.4}, 'animation': { 'effect': 'auto', 'duration': 0.4, 'stagger': 0.5, 'trigger': 'after-previous', }, }, 'slides': slides, } def build_group_listing(project_path: Path) -> tuple[list[str], list[str]]: """Return one compact line per slide: ``: id1, id2, id3``. Chrome groups are excluded — matches ``build_scaffold``'s policy so the listing reflects exactly what an editor can override. Returns ``(lines, anonymous_warnings)``. """ targets_by_slide, anonymous = scan_project_targets(project_path) lines: list[str] = [] for slide_name, targets in targets_by_slide.items(): ids = [t.group_id for t in targets if not t.chrome] if not ids: lines.append(f'{slide_name}: (no animatable groups)') else: lines.append(f'{slide_name}: {", ".join(ids)}') return lines, anonymous def write_scaffold( project_path: Path, output_path: str | None = None, *, force: bool = False, ) -> Path: """Write ``animations.json`` scaffold and return its path.""" if output_path: path = Path(output_path) else: path = default_config_path(project_path) if output_path and not path.is_absolute(): path = project_path / path if path.exists() and not force: raise FileExistsError(f'Animation config already exists: {path}') scaffold = build_scaffold(project_path) path.write_text( json.dumps(scaffold, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', ) return path