| 1 | """Import the finite PPT Master-owned object-animation contract. |
| 2 | |
| 3 | The importer deliberately projects only canonical effect identity, pane order, |
| 4 | Start trigger, duration, relative delay, and one top-level SVG group target. |
| 5 | Everything else remains an explicit source-preservation boundary. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | from dataclasses import dataclass |
| 11 | from xml.etree import ElementTree as ET |
| 12 | |
| 13 | from pptx_animations import ( |
| 14 | AnimationRowSummary, |
| 15 | effective_animation_effect_options, |
| 16 | read_slide_animation_sequence, |
| 17 | validate_generated_animation_xml, |
| 18 | ) |
| 19 | |
| 20 | from .ooxml_loader import OoxmlPackage, SlideRef |
| 21 | |
| 22 | |
| 23 | _SVG_NS = "http://www.w3.org/2000/svg" |
| 24 | _UNSUPPORTED_TIMING_TAGS = frozenset( |
| 25 | { |
| 26 | "audio", |
| 27 | "bldDgm", |
| 28 | "bldGraphic", |
| 29 | "bldLst", |
| 30 | "bldOleChart", |
| 31 | "bldP", |
| 32 | "cmd", |
| 33 | "video", |
| 34 | } |
| 35 | ) |
| 36 | |
| 37 | |
| 38 | class AnimationImportError(ValueError): |
| 39 | """Raised when source timing exceeds the finite reversible contract.""" |
| 40 | |
| 41 | |
| 42 | @dataclass(frozen=True) |
| 43 | class AnimationImport: |
| 44 | """Canonical per-group animation rows for one slide.""" |
| 45 | |
| 46 | groups: dict[str, dict[str, object]] |
| 47 | |
| 48 | |
| 49 | def import_slide_animation( |
| 50 | pkg: OoxmlPackage, |
| 51 | slide: SlideRef, |
| 52 | *, |
| 53 | slide_svg: str, |
| 54 | ) -> AnimationImport | None: |
| 55 | """Read one exact generated object-animation sequence into sidecar rows.""" |
| 56 | slide_xml = pkg.read_part_bytes(slide.part.path) |
| 57 | if slide_xml is None: |
| 58 | raise AnimationImportError( |
| 59 | f"source slide part is missing: {slide.part.path}" |
| 60 | ) |
| 61 | return read_animation_config(slide_xml, slide_svg) |
| 62 | |
| 63 | |
| 64 | def read_animation_config( |
| 65 | slide_xml: str | bytes, |
| 66 | slide_svg: str, |
| 67 | ) -> AnimationImport | None: |
| 68 | """Read one exact generated object-animation sequence from XML and SVG.""" |
| 69 | data = slide_xml.encode("utf-8") if isinstance(slide_xml, str) else slide_xml |
| 70 | if not _contains_timing(data): |
| 71 | return None |
| 72 | |
| 73 | try: |
| 74 | summary = read_slide_animation_sequence( |
| 75 | data, |
| 76 | require_supported_effects=True, |
| 77 | ) |
| 78 | except ValueError as exc: |
| 79 | raise AnimationImportError(str(exc)) from exc |
| 80 | if summary.timing_count != 1: |
| 81 | raise AnimationImportError( |
| 82 | "object animation requires exactly one effective root p:timing" |
| 83 | ) |
| 84 | if not summary.rows: |
| 85 | raise AnimationImportError( |
| 86 | "p:timing contains no supported object-animation rows" |
| 87 | ) |
| 88 | if summary.audio_target_ids: |
| 89 | raise AnimationImportError( |
| 90 | "media playback and animation sounds are outside the finite " |
| 91 | "object-animation read-back contract" |
| 92 | ) |
| 93 | unsupported_tags = _unsupported_timing_tags(data) |
| 94 | if unsupported_tags: |
| 95 | raise AnimationImportError( |
| 96 | "unsupported timing feature(s): " + ", ".join(unsupported_tags) |
| 97 | ) |
| 98 | |
| 99 | group_id_by_shape_id = _top_level_shape_group_index(slide_svg) |
| 100 | delays = _relative_delays(summary.rows) |
| 101 | expected_targets: list[dict[str, object]] = [] |
| 102 | rows_by_group: dict[str, list[dict[str, object]]] = {} |
| 103 | |
| 104 | for order, (row, delay_ms) in enumerate( |
| 105 | zip(summary.rows, delays), |
| 106 | 1, |
| 107 | ): |
| 108 | if row.effect is None or row.duration_ms is None: |
| 109 | raise AnimationImportError( |
| 110 | f"animation row {order} has no exact registry effect or duration" |
| 111 | ) |
| 112 | group_id = _resolve_group_id( |
| 113 | group_id_by_shape_id, |
| 114 | row.shape_id, |
| 115 | label=f"animation row {order} target", |
| 116 | ) |
| 117 | trigger_group_id = None |
| 118 | if row.trigger_shape_id is not None: |
| 119 | trigger_group_id = _resolve_group_id( |
| 120 | group_id_by_shape_id, |
| 121 | row.trigger_shape_id, |
| 122 | label=f"animation row {order} trigger", |
| 123 | ) |
| 124 | |
| 125 | sidecar_row: dict[str, object] = { |
| 126 | "effect": row.effect, |
| 127 | "duration": row.duration_ms / 1000.0, |
| 128 | "delay": delay_ms / 1000.0, |
| 129 | "order": order, |
| 130 | "trigger": row.trigger, |
| 131 | } |
| 132 | expected_target: dict[str, object] = { |
| 133 | "shape_id": row.shape_id, |
| 134 | "delay_ms": delay_ms, |
| 135 | "effect": row.effect, |
| 136 | "duration": row.duration_ms / 1000.0, |
| 137 | "trigger": row.trigger, |
| 138 | } |
| 139 | effect_options = dict(row.effect_options) |
| 140 | try: |
| 141 | default_options = effective_animation_effect_options(row.effect) |
| 142 | except ValueError: |
| 143 | default_options = None |
| 144 | if effect_options != default_options: |
| 145 | sidecar_row["effect_options"] = effect_options |
| 146 | expected_target["effect_options"] = effect_options |
| 147 | if row.trigger_shape_id is not None: |
| 148 | expected_target["trigger_shape_id"] = row.trigger_shape_id |
| 149 | sidecar_row["trigger_shape"] = trigger_group_id |
| 150 | expected_targets.append(expected_target) |
| 151 | rows_by_group.setdefault(group_id, []).append(sidecar_row) |
| 152 | |
| 153 | try: |
| 154 | validate_generated_animation_xml(data, expected_targets) |
| 155 | except ValueError as exc: |
| 156 | raise AnimationImportError(str(exc)) from exc |
| 157 | |
| 158 | groups: dict[str, dict[str, object]] = {} |
| 159 | for group_id, rows in rows_by_group.items(): |
| 160 | groups[group_id] = ( |
| 161 | rows[0] |
| 162 | if len(rows) == 1 |
| 163 | else {"effects": rows} |
| 164 | ) |
| 165 | return AnimationImport(groups=groups) |
| 166 | |
| 167 | |
| 168 | def _contains_timing(slide_xml: bytes) -> bool: |
| 169 | """Return whether any selected or fallback branch contains p:timing.""" |
| 170 | try: |
| 171 | root = ET.fromstring(slide_xml) |
| 172 | except ET.ParseError as exc: |
| 173 | raise AnimationImportError(f"invalid slide XML: {exc}") from exc |
| 174 | return any(_local_name(node.tag) == "timing" for node in root.iter()) |
| 175 | |
| 176 | |
| 177 | def _unsupported_timing_tags(slide_xml: bytes) -> tuple[str, ...]: |
| 178 | """Return timing features that the finite sidecar projection cannot own.""" |
| 179 | root = ET.fromstring(slide_xml) |
| 180 | found = { |
| 181 | _local_name(node.tag) |
| 182 | for node in root.iter() |
| 183 | if _local_name(node.tag) in _UNSUPPORTED_TIMING_TAGS |
| 184 | } |
| 185 | return tuple(sorted(found)) |
| 186 | |
| 187 | |
| 188 | def _top_level_shape_group_index(slide_svg: str) -> dict[int, tuple[str, ...]]: |
| 189 | """Index direct slide-local SVG group anchors by source shape id.""" |
| 190 | try: |
| 191 | root = ET.fromstring(slide_svg) |
| 192 | except ET.ParseError as exc: |
| 193 | raise AnimationImportError(f"invalid reconstructed slide SVG: {exc}") from exc |
| 194 | |
| 195 | groups: dict[int, list[str]] = {} |
| 196 | group_id_counts: dict[str, int] = {} |
| 197 | for child in list(root): |
| 198 | if child.tag != f"{{{_SVG_NS}}}g": |
| 199 | continue |
| 200 | if child.get("data-pptx-shape-scope") != "slide": |
| 201 | continue |
| 202 | raw_shape_id = child.get("data-pptx-shape-id") or "" |
| 203 | if not raw_shape_id.isdigit() or int(raw_shape_id) <= 0: |
| 204 | continue |
| 205 | group_id = child.get("id") or "" |
| 206 | if not group_id.strip(): |
| 207 | continue |
| 208 | shape_id = int(raw_shape_id) |
| 209 | groups.setdefault(shape_id, []).append(group_id) |
| 210 | group_id_counts[group_id] = group_id_counts.get(group_id, 0) + 1 |
| 211 | |
| 212 | return { |
| 213 | shape_id: ( |
| 214 | tuple(group_ids) |
| 215 | if ( |
| 216 | len(group_ids) == 1 |
| 217 | and group_id_counts.get(group_ids[0]) == 1 |
| 218 | ) |
| 219 | else () |
| 220 | ) |
| 221 | for shape_id, group_ids in groups.items() |
| 222 | } |
| 223 | |
| 224 | |
| 225 | def _resolve_group_id( |
| 226 | group_id_by_shape_id: dict[int, tuple[str, ...]], |
| 227 | shape_id: int, |
| 228 | *, |
| 229 | label: str, |
| 230 | ) -> str: |
| 231 | group_ids = group_id_by_shape_id.get(shape_id, ()) |
| 232 | if len(group_ids) != 1: |
| 233 | raise AnimationImportError( |
| 234 | f"{label} shape {shape_id} must map to exactly one unique " |
| 235 | f"top-level slide SVG group; found {group_ids or 'none'}" |
| 236 | ) |
| 237 | return group_ids[0] |
| 238 | |
| 239 | |
| 240 | def _relative_delays(rows: tuple[AnimationRowSummary, ...]) -> tuple[int, ...]: |
| 241 | """Invert the native writer's absolute main-sequence offsets.""" |
| 242 | delays: list[int] = [] |
| 243 | previous_start_ms = 0 |
| 244 | previous_duration_ms = 0 |
| 245 | has_previous = False |
| 246 | for index, row in enumerate(rows, 1): |
| 247 | if row.playback_duration_ms is None: |
| 248 | raise AnimationImportError( |
| 249 | f"animation row {index} has no exact playback duration" |
| 250 | ) |
| 251 | if row.trigger_shape_id is not None or row.trigger == "on-click": |
| 252 | base_ms = 0 |
| 253 | elif row.trigger == "with-previous": |
| 254 | base_ms = previous_start_ms if has_previous else 0 |
| 255 | elif row.trigger == "after-previous": |
| 256 | base_ms = ( |
| 257 | previous_start_ms + previous_duration_ms |
| 258 | if has_previous |
| 259 | else 0 |
| 260 | ) |
| 261 | else: |
| 262 | raise AnimationImportError( |
| 263 | f"animation row {index} has unsupported trigger {row.trigger!r}" |
| 264 | ) |
| 265 | delay_ms = row.offset_ms - base_ms |
| 266 | if delay_ms < 0: |
| 267 | raise AnimationImportError( |
| 268 | f"animation row {index} has a negative reconstructed delay" |
| 269 | ) |
| 270 | delays.append(delay_ms) |
| 271 | if row.trigger_shape_id is None: |
| 272 | previous_start_ms = row.offset_ms |
| 273 | previous_duration_ms = row.playback_duration_ms |
| 274 | has_previous = True |
| 275 | return tuple(delays) |
| 276 | |
| 277 | |
| 278 | def _local_name(tag: str) -> str: |
| 279 | return tag.rsplit("}", 1)[-1] |
| 280 | |
| 281 | |
| 282 | __all__ = [ |
| 283 | "AnimationImport", |
| 284 | "AnimationImportError", |
| 285 | "import_slide_animation", |
| 286 | "read_animation_config", |
| 287 | ] |
| 288 |