返回 ppt-master
transition_import.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / transition_import.py
1 """Import PPT Master-owned slide transitions into animations.json rows.
2
3 The reverse contract accepts only source XML that passes the existing
4 generated-transition read-back validator. Unknown third-party carriers remain
5 outside the reconstruction claim and are handled by the caller's diagnostics.
6 """
7
8 from __future__ import annotations
9
10 import hashlib
11 from dataclasses import dataclass
12 from pathlib import PurePosixPath
13
14 from pptx_transitions import (
15 TransitionSummary,
16 read_slide_transition_xml,
17 validate_generated_transition_xml,
18 )
19
20 from .ooxml_loader import OoxmlPackage, SlideRef
21
22
23 _AUDIO_RELATIONSHIP_TYPE = (
24 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio"
25 )
26
27
28 class TransitionImportError(ValueError):
29 """Raised when a source transition is outside the reversible contract."""
30
31
32 @dataclass(frozen=True)
33 class TransitionImport:
34 """One canonical sidecar row plus any extracted transition sound."""
35
36 config: dict[str, object]
37 media_files: dict[str, bytes]
38
39
40 @dataclass(frozen=True)
41 class TransitionReadback:
42 """One validated transition before its sound relationship is resolved."""
43
44 config: dict[str, object]
45 summary: TransitionSummary
46
47
48 def read_transition_config(
49 slide_xml: str | bytes,
50 ) -> TransitionReadback | None:
51 """Read one exact generated-transition contract from slide XML."""
52 summary = read_slide_transition_xml(slide_xml)
53 if summary.logical_count == 0:
54 return None
55 if summary.speed is not None:
56 raise TransitionImportError(
57 "legacy p:transition@spd cannot be represented exactly by "
58 "animations.json duration"
59 )
60 if summary.effect is not None and summary.canonical_effect is None:
61 raise TransitionImportError(
62 "transition effect is outside the canonical native registry"
63 )
64 if summary.canonical_effect is not None and summary.duration_ms is None:
65 raise TransitionImportError(
66 "visual transition is missing the exact p14:dur duration"
67 )
68
69 sound = _sound_expectation(summary)
70 duration = (
71 summary.duration_ms / 1000.0
72 if summary.duration_ms is not None
73 else 0.0
74 )
75 advance_after = (
76 summary.advance_after_ms / 1000.0
77 if summary.advance_after_ms is not None
78 else None
79 )
80 try:
81 validate_generated_transition_xml(
82 slide_xml,
83 effect=summary.canonical_effect,
84 effect_options=summary.effect_options,
85 duration=duration,
86 advance_on_click=summary.advance_on_click,
87 advance_after=advance_after,
88 sound=sound,
89 )
90 except ValueError as exc:
91 raise TransitionImportError(str(exc)) from exc
92 if summary.advance_on_click is not True:
93 raise TransitionImportError(
94 "advance_on_click=false is outside the animations.json "
95 "transition contract"
96 )
97
98 config: dict[str, object] = {
99 "effect": summary.canonical_effect or "none",
100 }
101 if summary.effect_options:
102 config["effect_options"] = dict(summary.effect_options)
103 if summary.canonical_effect is not None:
104 config["duration"] = duration
105 if advance_after is not None:
106 config["auto_advance"] = advance_after
107 return TransitionReadback(config=config, summary=summary)
108
109
110 def import_slide_transition(
111 pkg: OoxmlPackage,
112 slide: SlideRef,
113 *,
114 media_subdir: str,
115 ) -> TransitionImport | None:
116 """Read one slide transition and resolve its optional WAV relationship."""
117 slide_xml = pkg.read_part_bytes(slide.part.path)
118 if slide_xml is None:
119 raise TransitionImportError(
120 f"source slide part is missing: {slide.part.path}"
121 )
122 readback = read_transition_config(slide_xml)
123 if readback is None:
124 return None
125
126 config = dict(readback.config)
127 media_files: dict[str, bytes] = {}
128 relationship_id = readback.summary.sound_relationship_id
129 if relationship_id is not None:
130 sound_path, sound_bytes = _resolve_transition_sound(
131 pkg,
132 slide,
133 relationship_id,
134 )
135 media_files[sound_path] = sound_bytes
136 config["sound"] = (
137 PurePosixPath(media_subdir) / sound_path
138 ).as_posix()
139 return TransitionImport(config=config, media_files=media_files)
140
141
142 def _sound_expectation(
143 summary: TransitionSummary,
144 ) -> dict[str, str] | None:
145 relationship_id = summary.sound_relationship_id
146 name = summary.sound_name
147 if relationship_id is None and name is None:
148 return None
149 if not relationship_id or not name:
150 raise TransitionImportError(
151 "transition sound requires both relationship id and display name"
152 )
153 return {
154 "relationship_id": relationship_id,
155 "name": name,
156 }
157
158
159 def _resolve_transition_sound(
160 pkg: OoxmlPackage,
161 slide: SlideRef,
162 relationship_id: str,
163 ) -> tuple[str, bytes]:
164 relationship = slide.part.rels.get(relationship_id)
165 if relationship is None:
166 raise TransitionImportError(
167 f"transition sound relationship is missing: {relationship_id}"
168 )
169 if relationship.get("external"):
170 raise TransitionImportError("transition sound relationship must be internal")
171 if relationship.get("type") != _AUDIO_RELATIONSHIP_TYPE:
172 raise TransitionImportError(
173 "transition sound relationship must use the OOXML audio type"
174 )
175 target = relationship.get("target") or ""
176 if PurePosixPath(target).suffix.lower() != ".wav":
177 raise TransitionImportError("transition sound part must use .wav")
178 payload = pkg.read_part_bytes(target)
179 if payload is None:
180 raise TransitionImportError(
181 f"transition sound part is missing: {target}"
182 )
183 if not (
184 len(payload) >= 12
185 and payload[:4] in {b"RIFF", b"RF64"}
186 and payload[8:12] == b"WAVE"
187 ):
188 raise TransitionImportError(
189 f"transition sound part is not a valid WAV file: {target}"
190 )
191 digest = hashlib.sha256(payload).hexdigest()[:16]
192 return f"transition_sound_{digest}.wav", payload
193
194
195 __all__ = [
196 "TransitionImport",
197 "TransitionImportError",
198 "TransitionReadback",
199 "import_slide_transition",
200 "read_transition_config",
201 ]
202
202 lines PYTHON