276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
"""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: ``<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
|