返回 ppt-master
animation_import.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / animation_import.py
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
288 lines PYTHON