| 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 |