| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Native Existing PPTX Enhancer |
| 4 | |
| 5 | Implementation core for the public native enhancement CLI and its legacy |
| 6 | narration compatibility entrypoint. It enhances an existing PPTX without |
| 7 | entering the SVG generation pipeline or modifying the original file. |
| 8 | |
| 9 | Enhancement modules: read-only delivery checks, speaker notes, narration audio, |
| 10 | slide auto-advance timings, and optional global or per-slide page transitions. |
| 11 | |
| 12 | Usage: |
| 13 | python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name] |
| 14 | python3 scripts/native_enhance_pptx.py apply <project_path> [--output output.pptx] |
| 15 | python3 scripts/native_enhance_pptx.py validate <project_path> [--materials {all,notes}] |
| 16 | |
| 17 | Examples: |
| 18 | python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station |
| 19 | python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626 |
| 20 | python3 scripts/native_enhance_pptx.py validate projects/fire_station_native_enhance_20260626 |
| 21 | |
| 22 | Dependencies: |
| 23 | ffprobe for narration decodability and audio-duration validation. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import hashlib |
| 30 | import json |
| 31 | import posixpath |
| 32 | import re |
| 33 | import shutil |
| 34 | import subprocess |
| 35 | import sys |
| 36 | import tempfile |
| 37 | import zipfile |
| 38 | from collections.abc import Mapping |
| 39 | from dataclasses import dataclass |
| 40 | from datetime import datetime |
| 41 | from pathlib import Path |
| 42 | from xml.etree import ElementTree as ET |
| 43 | |
| 44 | _SCRIPTS_DIR = Path(__file__).resolve().parent |
| 45 | if str(_SCRIPTS_DIR) not in sys.path: |
| 46 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 47 | |
| 48 | from attribution_guard import require_skill_integrity # noqa: E402 |
| 49 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 50 | from pptx_delivery_check import audit_pptx_delivery # noqa: E402 |
| 51 | from pptx_animations import ( # noqa: E402 |
| 52 | object_animation_fingerprint, |
| 53 | validate_pptx_animation_package, |
| 54 | ) |
| 55 | from pptx_transitions import ( # noqa: E402 |
| 56 | AdvanceUpdate, |
| 57 | EnterUpdate, |
| 58 | LEGACY_TRANSITION_KEYS, |
| 59 | NATIVE_TRANSITION_KEYS, |
| 60 | apply_slide_motion_xml, |
| 61 | normalize_transition_effect_request, |
| 62 | set_directory_use_timings, |
| 63 | validate_pptx_transition_package, |
| 64 | validate_seconds, |
| 65 | ) |
| 66 | from svg_to_pptx.pptx_package.builder import ( # noqa: E402 |
| 67 | _add_default_content_type, |
| 68 | _append_relationship, |
| 69 | _ensure_notes_master, |
| 70 | ) |
| 71 | from svg_to_pptx.pptx_package.narration import ( # noqa: E402 |
| 72 | AUDIO_CONTENT_TYPES, |
| 73 | AUDIO_MARKER_PNG_BYTES, |
| 74 | AUDIO_REL_TYPE, |
| 75 | IMAGE_REL_TYPE, |
| 76 | MEDIA_REL_TYPE, |
| 77 | NARRATION_EXTENSIONS, |
| 78 | inject_narration, |
| 79 | next_shape_id, |
| 80 | probe_audio_duration, |
| 81 | ) |
| 82 | from svg_to_pptx.pptx_package.notes import ( # noqa: E402 |
| 83 | create_notes_slide_rels_xml, |
| 84 | create_notes_slide_xml, |
| 85 | markdown_to_plain_text, |
| 86 | ) |
| 87 | |
| 88 | configure_utf8_stdio() |
| 89 | |
| 90 | |
| 91 | PROJECT_SCHEMA = "native_pptx_enhancement_project.v1" |
| 92 | PLAN_SCHEMA = "native_pptx_enhancement_plan.v1" |
| 93 | VALIDATION_SCHEMA = "native_pptx_enhancement_validation.v1" |
| 94 | LEGACY_PROJECT_SCHEMAS = {"native_narration_pptx_project.v1"} |
| 95 | _WRITABLE_MODULES = ("notes", "audio", "timings", "transitions") |
| 96 | NOTES_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" |
| 97 | PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" |
| 98 | PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 99 | REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" |
| 100 | CONTENT_TYPE_NOTES_SLIDE = ( |
| 101 | "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml" |
| 102 | ) |
| 103 | CONTENT_TYPE_NOTES_MASTER = ( |
| 104 | "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml" |
| 105 | ) |
| 106 | CONTENT_TYPE_THEME = "application/vnd.openxmlformats-officedocument.theme+xml" |
| 107 | _NOTES_SLIDE_PART_RE = re.compile( |
| 108 | r"^ppt/notesSlides/notesSlide([1-9]\d*)\.xml$" |
| 109 | ) |
| 110 | _LEGACY_MISSING_NOTES_MASTER_RE = re.compile( |
| 111 | r"^ppt/notesSlides/_rels/notesSlide[1-9]\d*\.xml\.rels" |
| 112 | r" -> ppt/notesmasters/notesmaster[1-9]\d*\.xml$", |
| 113 | re.IGNORECASE, |
| 114 | ) |
| 115 | _TRANSITION_MODULE_FIELDS = frozenset( |
| 116 | { |
| 117 | "enabled", |
| 118 | "requires_confirmation", |
| 119 | "status", |
| 120 | "effect", |
| 121 | "duration", |
| 122 | "effect_options", |
| 123 | "apply_without_audio", |
| 124 | "slides", |
| 125 | } |
| 126 | ) |
| 127 | _TRANSITION_OVERRIDE_FIELDS = frozenset( |
| 128 | {"effect", "duration", "effect_options"} |
| 129 | ) |
| 130 | @dataclass(frozen=True) |
| 131 | class SlidePart: |
| 132 | index: int |
| 133 | part_name: str |
| 134 | slide_number: int |
| 135 | |
| 136 | |
| 137 | @dataclass |
| 138 | class MaterialReadiness: |
| 139 | note_paths: dict[int, Path] |
| 140 | audio_paths: dict[int, Path] |
| 141 | audio_durations: dict[int, float] |
| 142 | notes_count: int |
| 143 | audio_count: int |
| 144 | missing_notes: list[int] |
| 145 | invalid_notes: dict[int, str] |
| 146 | missing_audio: list[int] |
| 147 | invalid_audio: dict[int, str] |
| 148 | module_errors: list[str] |
| 149 | |
| 150 | @property |
| 151 | def ready(self) -> bool: |
| 152 | return not ( |
| 153 | self.missing_notes |
| 154 | or self.invalid_notes |
| 155 | or self.missing_audio |
| 156 | or self.invalid_audio |
| 157 | or self.module_errors |
| 158 | ) |
| 159 | |
| 160 | |
| 161 | @dataclass(frozen=True) |
| 162 | class ResolvedTransitionPlan: |
| 163 | global_enter: EnterUpdate |
| 164 | slide_enters: Mapping[int, EnterUpdate] |
| 165 | apply_without_audio: bool |
| 166 | |
| 167 | |
| 168 | def _sanitize_slug(value: str) -> str: |
| 169 | slug = re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_") |
| 170 | return slug or "native_enhance" |
| 171 | |
| 172 | |
| 173 | def _positive_seconds_arg(value: str) -> float: |
| 174 | try: |
| 175 | return validate_seconds(value, "transition duration", allow_zero=False) |
| 176 | except ValueError as exc: |
| 177 | raise argparse.ArgumentTypeError(str(exc)) from exc |
| 178 | |
| 179 | |
| 180 | def _non_negative_seconds_arg(value: str) -> float: |
| 181 | try: |
| 182 | return validate_seconds(value, "narration padding", allow_zero=True) |
| 183 | except ValueError as exc: |
| 184 | raise argparse.ArgumentTypeError(str(exc)) from exc |
| 185 | |
| 186 | |
| 187 | def _read_json(path: Path) -> dict: |
| 188 | data = json.loads(path.read_text(encoding="utf-8")) |
| 189 | if not isinstance(data, dict): |
| 190 | raise ValueError(f"JSON root must be an object: {path}") |
| 191 | return data |
| 192 | |
| 193 | |
| 194 | def _write_json(path: Path, data: dict) -> None: |
| 195 | path.write_text( |
| 196 | json.dumps(data, ensure_ascii=False, indent=2) + "\n", |
| 197 | encoding="utf-8", |
| 198 | ) |
| 199 | |
| 200 | |
| 201 | def _write_preflight_report( |
| 202 | project_path: Path, |
| 203 | plan: dict, |
| 204 | modules: set[str], |
| 205 | *, |
| 206 | status: str, |
| 207 | **details: object, |
| 208 | ) -> dict: |
| 209 | report = { |
| 210 | "schema": VALIDATION_SCHEMA, |
| 211 | "status": status, |
| 212 | "phase": "preflight", |
| 213 | "plan_status": plan.get("status") or "missing", |
| 214 | "enabled_modules": sorted(modules), |
| 215 | **details, |
| 216 | } |
| 217 | validation_dir = project_path / "validation" |
| 218 | validation_dir.mkdir(exist_ok=True) |
| 219 | _write_json(validation_dir / "report.json", report) |
| 220 | return report |
| 221 | |
| 222 | |
| 223 | def _delivery_issues(report: dict, field: str) -> list[dict]: |
| 224 | issues = report.get(field) |
| 225 | if not isinstance(issues, list): |
| 226 | return [] |
| 227 | return [issue for issue in issues if isinstance(issue, dict)] |
| 228 | |
| 229 | |
| 230 | def _fatal_source_delivery_messages(report: dict) -> list[str]: |
| 231 | fatal = [ |
| 232 | issue |
| 233 | for issue in _delivery_issues(report, "errors") |
| 234 | if not ( |
| 235 | issue.get("code") == "dangling_internal_relationship" |
| 236 | and isinstance(issue.get("message"), str) |
| 237 | and _LEGACY_MISSING_NOTES_MASTER_RE.fullmatch( |
| 238 | issue["message"] |
| 239 | ) |
| 240 | is not None |
| 241 | ) |
| 242 | ] |
| 243 | if fatal: |
| 244 | return [ |
| 245 | str(issue.get("message") or issue) |
| 246 | for issue in fatal |
| 247 | ] |
| 248 | if report.get("status") == "failed" and not _delivery_issues( |
| 249 | report, |
| 250 | "errors", |
| 251 | ): |
| 252 | return ["delivery check failed without structured error details"] |
| 253 | return [] |
| 254 | |
| 255 | |
| 256 | def _new_delivery_errors(source: dict, candidate: dict) -> list[dict]: |
| 257 | source_keys = { |
| 258 | json.dumps(issue, ensure_ascii=False, sort_keys=True) |
| 259 | for issue in _delivery_issues(source, "errors") |
| 260 | } |
| 261 | return [ |
| 262 | issue |
| 263 | for issue in _delivery_issues(candidate, "errors") |
| 264 | if json.dumps(issue, ensure_ascii=False, sort_keys=True) |
| 265 | not in source_keys |
| 266 | ] |
| 267 | |
| 268 | |
| 269 | def _delivery_has_findings(report: dict) -> bool: |
| 270 | return bool( |
| 271 | _delivery_issues(report, "errors") |
| 272 | or _delivery_issues(report, "advisories") |
| 273 | ) |
| 274 | |
| 275 | |
| 276 | def _delivery_hidden_slide_indices( |
| 277 | report: dict, |
| 278 | ) -> tuple[int, ...] | None: |
| 279 | slides = report.get("slides") |
| 280 | hidden = slides.get("hidden") if isinstance(slides, dict) else None |
| 281 | if not isinstance(hidden, list): |
| 282 | return None |
| 283 | indices: list[int] = [] |
| 284 | for item in hidden: |
| 285 | index = item.get("index") if isinstance(item, dict) else None |
| 286 | if isinstance(index, bool) or not isinstance(index, int): |
| 287 | return None |
| 288 | indices.append(index) |
| 289 | return tuple(indices) |
| 290 | |
| 291 | |
| 292 | def _file_sha256(path: Path) -> str: |
| 293 | digest = hashlib.sha256() |
| 294 | with path.open("rb") as handle: |
| 295 | for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 296 | digest.update(chunk) |
| 297 | return digest.hexdigest() |
| 298 | |
| 299 | |
| 300 | def _is_relative_to(path: Path, parent: Path) -> bool: |
| 301 | try: |
| 302 | path.resolve().relative_to(parent.resolve()) |
| 303 | return True |
| 304 | except ValueError: |
| 305 | return False |
| 306 | |
| 307 | |
| 308 | def _archive_source_pptx(source_pptx: Path, archived_pptx: Path, projects_root: Path) -> str: |
| 309 | """Move project-local sources into the project; copy external sources.""" |
| 310 | archived_pptx.parent.mkdir(parents=True, exist_ok=True) |
| 311 | if source_pptx.resolve() == archived_pptx.resolve(): |
| 312 | return "reuse" |
| 313 | if _is_relative_to(source_pptx, projects_root): |
| 314 | shutil.move(str(source_pptx), str(archived_pptx)) |
| 315 | return "move" |
| 316 | shutil.copy2(source_pptx, archived_pptx) |
| 317 | return "copy" |
| 318 | |
| 319 | |
| 320 | def _relationship_file_for_part(extract_dir: Path, part_name: str) -> Path: |
| 321 | part = Path(part_name) |
| 322 | return extract_dir / part.parent / "_rels" / f"{part.name}.rels" |
| 323 | |
| 324 | |
| 325 | def _ensure_rels_file(path: Path) -> None: |
| 326 | if path.exists(): |
| 327 | return |
| 328 | path.parent.mkdir(parents=True, exist_ok=True) |
| 329 | path.write_text( |
| 330 | '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' |
| 331 | f'<Relationships xmlns="{PACKAGE_REL_NS}">\n</Relationships>', |
| 332 | encoding="utf-8", |
| 333 | ) |
| 334 | |
| 335 | |
| 336 | def _target_to_part(target: str) -> str: |
| 337 | target = target.lstrip("/") |
| 338 | if target.startswith("ppt/"): |
| 339 | return target |
| 340 | return f"ppt/{target}" |
| 341 | |
| 342 | |
| 343 | def _slide_number_from_part(part_name: str) -> int: |
| 344 | match = re.search(r"slide(\d+)\.xml$", part_name) |
| 345 | if not match: |
| 346 | raise ValueError(f"Unsupported slide part name: {part_name}") |
| 347 | return int(match.group(1)) |
| 348 | |
| 349 | |
| 350 | def _resolve_relationship_part(source_part: str, target: str) -> str: |
| 351 | """Resolve an internal relationship target to a package part name.""" |
| 352 | target_path = target.split("#", 1)[0] |
| 353 | if target_path.startswith("/"): |
| 354 | return posixpath.normpath(target_path.lstrip("/")) |
| 355 | return posixpath.normpath( |
| 356 | posixpath.join(posixpath.dirname(source_part), target_path) |
| 357 | ) |
| 358 | |
| 359 | |
| 360 | def _notes_slide_index(part_name: str) -> int | None: |
| 361 | match = _NOTES_SLIDE_PART_RE.fullmatch(part_name) |
| 362 | return int(match.group(1)) if match else None |
| 363 | |
| 364 | |
| 365 | def _is_notes_slide_part(part_name: str) -> bool: |
| 366 | """Return whether a relationship target stays in the notesSlides folder.""" |
| 367 | return ( |
| 368 | posixpath.dirname(part_name) == "ppt/notesSlides" |
| 369 | and posixpath.basename(part_name).endswith(".xml") |
| 370 | and posixpath.basename(part_name) != ".xml" |
| 371 | ) |
| 372 | |
| 373 | |
| 374 | def _notes_slide_part_for_slide( |
| 375 | extract_dir: Path, |
| 376 | slide: SlidePart, |
| 377 | ) -> str | None: |
| 378 | """Return the notes part currently related to a slide, if present.""" |
| 379 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 380 | if not slide_rels.exists(): |
| 381 | return None |
| 382 | |
| 383 | related_parts: list[str] = [] |
| 384 | for rel in ET.parse(slide_rels).getroot(): |
| 385 | if rel.attrib.get("Type") != NOTES_REL_TYPE: |
| 386 | continue |
| 387 | if rel.attrib.get("TargetMode", "").lower() == "external": |
| 388 | raise RuntimeError( |
| 389 | f"Slide {slide.index} has an external notesSlide relationship" |
| 390 | ) |
| 391 | target = rel.attrib.get("Target") |
| 392 | if not target: |
| 393 | raise RuntimeError( |
| 394 | f"Slide {slide.index} notesSlide relationship has no Target" |
| 395 | ) |
| 396 | part_name = _resolve_relationship_part(slide.part_name, target) |
| 397 | if not _is_notes_slide_part(part_name): |
| 398 | raise RuntimeError( |
| 399 | f"Slide {slide.index} has an unsupported notesSlide target: {target}" |
| 400 | ) |
| 401 | related_parts.append(part_name) |
| 402 | |
| 403 | if len(related_parts) > 1: |
| 404 | raise RuntimeError( |
| 405 | f"Slide {slide.index} has multiple notesSlide relationships" |
| 406 | ) |
| 407 | return related_parts[0] if related_parts else None |
| 408 | |
| 409 | |
| 410 | def _used_notes_slide_indices(extract_dir: Path) -> set[int]: |
| 411 | """Collect every notesSlide number already reserved in the package.""" |
| 412 | used: set[int] = set() |
| 413 | notes_dir = extract_dir / "ppt" / "notesSlides" |
| 414 | for path in notes_dir.glob("notesSlide*.xml"): |
| 415 | index = _notes_slide_index(f"ppt/notesSlides/{path.name}") |
| 416 | if index is not None: |
| 417 | used.add(index) |
| 418 | for path in (notes_dir / "_rels").glob("notesSlide*.xml.rels"): |
| 419 | index = _notes_slide_index( |
| 420 | f"ppt/notesSlides/{path.name.removesuffix('.rels')}" |
| 421 | ) |
| 422 | if index is not None: |
| 423 | used.add(index) |
| 424 | |
| 425 | content_types_path = extract_dir / "[Content_Types].xml" |
| 426 | if content_types_path.exists(): |
| 427 | content_types = content_types_path.read_text(encoding="utf-8") |
| 428 | for match in re.finditer( |
| 429 | r'PartName="/(ppt/notesSlides/notesSlide[1-9]\d*\.xml)"', |
| 430 | content_types, |
| 431 | ): |
| 432 | index = _notes_slide_index(match.group(1)) |
| 433 | if index is not None: |
| 434 | used.add(index) |
| 435 | |
| 436 | slides_rels_dir = extract_dir / "ppt" / "slides" / "_rels" |
| 437 | for rels_path in slides_rels_dir.glob("slide*.xml.rels"): |
| 438 | source_part = f"ppt/slides/{rels_path.name.removesuffix('.rels')}" |
| 439 | for rel in ET.parse(rels_path).getroot(): |
| 440 | if ( |
| 441 | rel.attrib.get("Type") != NOTES_REL_TYPE |
| 442 | or rel.attrib.get("TargetMode", "").lower() == "external" |
| 443 | ): |
| 444 | continue |
| 445 | target = rel.attrib.get("Target") |
| 446 | if not target: |
| 447 | continue |
| 448 | index = _notes_slide_index( |
| 449 | _resolve_relationship_part(source_part, target) |
| 450 | ) |
| 451 | if index is not None: |
| 452 | used.add(index) |
| 453 | return used |
| 454 | |
| 455 | |
| 456 | def _allocate_notes_slide_part(extract_dir: Path) -> str: |
| 457 | used = _used_notes_slide_indices(extract_dir) |
| 458 | index = max(used, default=0) + 1 |
| 459 | return f"ppt/notesSlides/notesSlide{index}.xml" |
| 460 | |
| 461 | |
| 462 | def read_slide_parts(extract_dir: Path) -> list[SlidePart]: |
| 463 | presentation_path = extract_dir / "ppt" / "presentation.xml" |
| 464 | rels_path = extract_dir / "ppt" / "_rels" / "presentation.xml.rels" |
| 465 | if not presentation_path.exists() or not rels_path.exists(): |
| 466 | raise RuntimeError("PPTX package is missing presentation.xml or its relationships") |
| 467 | |
| 468 | rels_root = ET.parse(rels_path).getroot() |
| 469 | rels: dict[str, str] = {} |
| 470 | for rel in rels_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"): |
| 471 | rel_id = rel.attrib.get("Id") |
| 472 | target = rel.attrib.get("Target") |
| 473 | if rel_id and target: |
| 474 | rels[rel_id] = target |
| 475 | |
| 476 | presentation_root = ET.parse(presentation_path).getroot() |
| 477 | slide_parts: list[SlidePart] = [] |
| 478 | for index, slide_id in enumerate( |
| 479 | presentation_root.findall(f".//{{{PRESENTATION_NS}}}sldId"), |
| 480 | 1, |
| 481 | ): |
| 482 | rel_id = slide_id.attrib.get(f"{{{REL_NS}}}id") |
| 483 | if not rel_id or rel_id not in rels: |
| 484 | continue |
| 485 | part_name = _target_to_part(rels[rel_id]) |
| 486 | slide_parts.append( |
| 487 | SlidePart( |
| 488 | index=index, |
| 489 | part_name=part_name, |
| 490 | slide_number=_slide_number_from_part(part_name), |
| 491 | ) |
| 492 | ) |
| 493 | if not slide_parts: |
| 494 | raise RuntimeError("No slides found in presentation.xml") |
| 495 | return slide_parts |
| 496 | |
| 497 | |
| 498 | def _source_state_errors( |
| 499 | project_path: Path, |
| 500 | project: dict, |
| 501 | source_pptx: Path, |
| 502 | slides: list[SlidePart], |
| 503 | ) -> list[str]: |
| 504 | """Return source-drift and slide-index consistency errors.""" |
| 505 | errors: list[str] = [] |
| 506 | actual_count = len(slides) |
| 507 | actual_roster = [slide.part_name for slide in slides] |
| 508 | |
| 509 | expected_sha256 = project.get("source_sha256") |
| 510 | if expected_sha256 is not None: |
| 511 | if ( |
| 512 | not isinstance(expected_sha256, str) |
| 513 | or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None |
| 514 | ): |
| 515 | errors.append("project.json source_sha256 is not a lowercase SHA-256 digest") |
| 516 | elif _file_sha256(source_pptx) != expected_sha256: |
| 517 | errors.append("archived source PPTX SHA-256 no longer matches project.json") |
| 518 | |
| 519 | expected_project_count = project.get("slide_count") |
| 520 | if isinstance(expected_project_count, bool) or not isinstance( |
| 521 | expected_project_count, |
| 522 | int, |
| 523 | ): |
| 524 | errors.append("project.json slide_count is not an integer") |
| 525 | elif expected_project_count != actual_count: |
| 526 | errors.append( |
| 527 | "archived source slide count no longer matches project.json: " |
| 528 | f"{actual_count} != {expected_project_count}" |
| 529 | ) |
| 530 | |
| 531 | expected_project_roster = project.get("slide_part_roster") |
| 532 | if expected_project_roster is not None: |
| 533 | if ( |
| 534 | not isinstance(expected_project_roster, list) |
| 535 | or any( |
| 536 | not isinstance(part_name, str) or not part_name |
| 537 | for part_name in expected_project_roster |
| 538 | ) |
| 539 | ): |
| 540 | errors.append("project.json slide_part_roster is not an array of part names") |
| 541 | elif expected_project_roster != actual_roster: |
| 542 | errors.append( |
| 543 | "archived source ordered slide-part roster no longer matches " |
| 544 | "project.json" |
| 545 | ) |
| 546 | |
| 547 | slide_index_path = project_path / "analysis" / "slide_index.json" |
| 548 | if not slide_index_path.is_file(): |
| 549 | errors.append(f"slide index is missing: {slide_index_path}") |
| 550 | return errors |
| 551 | try: |
| 552 | slide_index = _read_json(slide_index_path) |
| 553 | except (OSError, json.JSONDecodeError) as exc: |
| 554 | errors.append(f"unable to read slide index: {exc}") |
| 555 | return errors |
| 556 | |
| 557 | expected_index_count = slide_index.get("slide_count") |
| 558 | if isinstance(expected_index_count, bool) or not isinstance( |
| 559 | expected_index_count, |
| 560 | int, |
| 561 | ): |
| 562 | errors.append("slide_index.json slide_count is not an integer") |
| 563 | elif expected_index_count != actual_count: |
| 564 | errors.append( |
| 565 | "archived source slide count no longer matches slide_index.json: " |
| 566 | f"{actual_count} != {expected_index_count}" |
| 567 | ) |
| 568 | |
| 569 | indexed_slides = slide_index.get("slides") |
| 570 | if not isinstance(indexed_slides, list): |
| 571 | errors.append("slide_index.json slides is not an array") |
| 572 | return errors |
| 573 | expected_roster: list[str] = [] |
| 574 | for index, item in enumerate(indexed_slides, 1): |
| 575 | part_name = item.get("part_name") if isinstance(item, dict) else None |
| 576 | if not isinstance(part_name, str) or not part_name: |
| 577 | errors.append( |
| 578 | f"slide_index.json slides[{index - 1}].part_name is invalid" |
| 579 | ) |
| 580 | continue |
| 581 | expected_roster.append(part_name) |
| 582 | if len(expected_roster) == len(indexed_slides) and expected_roster != actual_roster: |
| 583 | errors.append( |
| 584 | "archived source ordered slide-part roster no longer matches " |
| 585 | "slide_index.json" |
| 586 | ) |
| 587 | return errors |
| 588 | |
| 589 | |
| 590 | def _zip_dir(source_dir: Path, output_path: Path) -> None: |
| 591 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 592 | with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: |
| 593 | for path in sorted(source_dir.rglob("*")): |
| 594 | if path.is_file(): |
| 595 | zf.write(path, path.relative_to(source_dir).as_posix()) |
| 596 | |
| 597 | |
| 598 | def _extract_pptx(source_pptx: Path, extract_dir: Path) -> None: |
| 599 | with zipfile.ZipFile(source_pptx, "r") as zf: |
| 600 | zf.extractall(extract_dir) |
| 601 | |
| 602 | |
| 603 | def _note_path(notes_dir: Path, index: int) -> Path | None: |
| 604 | candidates = [ |
| 605 | notes_dir / f"{index:03d}.md", |
| 606 | notes_dir / f"{index:02d}.md", |
| 607 | notes_dir / f"{index}.md", |
| 608 | notes_dir / f"slide{index:03d}.md", |
| 609 | notes_dir / f"slide{index:02d}.md", |
| 610 | notes_dir / f"slide{index}.md", |
| 611 | ] |
| 612 | for candidate in candidates: |
| 613 | if candidate.exists(): |
| 614 | return candidate |
| 615 | return None |
| 616 | |
| 617 | |
| 618 | def _audio_path(audio_dir: Path, index: int) -> Path | None: |
| 619 | stems = [ |
| 620 | f"{index:03d}", |
| 621 | f"{index:02d}", |
| 622 | str(index), |
| 623 | f"slide{index:03d}", |
| 624 | f"slide{index:02d}", |
| 625 | f"slide{index}", |
| 626 | ] |
| 627 | for stem in stems: |
| 628 | matches = [ |
| 629 | audio_dir / f"{stem}{ext}" |
| 630 | for ext in NARRATION_EXTENSIONS |
| 631 | if (audio_dir / f"{stem}{ext}").exists() |
| 632 | ] |
| 633 | if len(matches) > 1: |
| 634 | names = ", ".join(path.name for path in matches) |
| 635 | raise ValueError( |
| 636 | f"ambiguous audio stem {stem!r}: {names}; " |
| 637 | "keep exactly one supported extension" |
| 638 | ) |
| 639 | if matches: |
| 640 | return matches[0] |
| 641 | return None |
| 642 | |
| 643 | |
| 644 | def _collect_material_readiness( |
| 645 | slides: list[SlidePart], |
| 646 | notes_dir: Path, |
| 647 | audio_dir: Path, |
| 648 | modules: set[str], |
| 649 | *, |
| 650 | required_modules: set[str] | None = None, |
| 651 | ) -> MaterialReadiness: |
| 652 | """Inspect enabled-module inputs once for both validation and application.""" |
| 653 | material_modules = modules if required_modules is None else required_modules |
| 654 | notes_required = "notes" in material_modules |
| 655 | audio_required = ( |
| 656 | "audio" in material_modules or "timings" in material_modules |
| 657 | ) |
| 658 | timings_enabled = "timings" in material_modules |
| 659 | note_paths: dict[int, Path] = {} |
| 660 | audio_paths: dict[int, Path] = {} |
| 661 | audio_durations: dict[int, float] = {} |
| 662 | missing_notes: list[int] = [] |
| 663 | invalid_notes: dict[int, str] = {} |
| 664 | missing_audio: list[int] = [] |
| 665 | invalid_audio: dict[int, str] = {} |
| 666 | |
| 667 | for slide in slides: |
| 668 | note = _note_path(notes_dir, slide.index) |
| 669 | if note is None: |
| 670 | if notes_required: |
| 671 | missing_notes.append(slide.index) |
| 672 | else: |
| 673 | try: |
| 674 | note_text = markdown_to_plain_text( |
| 675 | note.read_text(encoding="utf-8") |
| 676 | ) |
| 677 | except (OSError, UnicodeError) as exc: |
| 678 | if notes_required: |
| 679 | invalid_notes[slide.index] = f"unable to read {note.name}: {exc}" |
| 680 | else: |
| 681 | if note_text: |
| 682 | note_paths[slide.index] = note |
| 683 | elif notes_required: |
| 684 | invalid_notes[slide.index] = f"{note.name} has no spoken text" |
| 685 | |
| 686 | try: |
| 687 | audio = _audio_path(audio_dir, slide.index) |
| 688 | except ValueError as exc: |
| 689 | if audio_required: |
| 690 | invalid_audio[slide.index] = str(exc) |
| 691 | continue |
| 692 | if audio is None: |
| 693 | if audio_required: |
| 694 | missing_audio.append(slide.index) |
| 695 | continue |
| 696 | try: |
| 697 | if not audio.is_file() or audio.stat().st_size <= 0: |
| 698 | raise ValueError(f"{audio.name} is not a non-empty file") |
| 699 | except (OSError, ValueError) as exc: |
| 700 | if audio_required: |
| 701 | invalid_audio[slide.index] = str(exc) |
| 702 | continue |
| 703 | |
| 704 | if audio_required: |
| 705 | duration = probe_audio_duration(audio) |
| 706 | if duration is None: |
| 707 | invalid_audio[slide.index] = ( |
| 708 | f"unable to decode {audio.name} with ffprobe" |
| 709 | ) |
| 710 | continue |
| 711 | if timings_enabled: |
| 712 | audio_durations[slide.index] = duration |
| 713 | audio_paths[slide.index] = audio |
| 714 | |
| 715 | module_errors: list[str] = [] |
| 716 | if timings_enabled and "audio" not in modules: |
| 717 | module_errors.append("timings requires the audio module") |
| 718 | return MaterialReadiness( |
| 719 | note_paths=note_paths, |
| 720 | audio_paths=audio_paths, |
| 721 | audio_durations=audio_durations, |
| 722 | notes_count=len(note_paths), |
| 723 | audio_count=len(audio_paths), |
| 724 | missing_notes=missing_notes, |
| 725 | invalid_notes=invalid_notes, |
| 726 | missing_audio=missing_audio, |
| 727 | invalid_audio=invalid_audio, |
| 728 | module_errors=module_errors, |
| 729 | ) |
| 730 | |
| 731 | |
| 732 | def _material_readiness_messages(readiness: MaterialReadiness) -> list[str]: |
| 733 | messages = list(readiness.module_errors) |
| 734 | if readiness.missing_notes: |
| 735 | messages.append( |
| 736 | "missing notes for slide(s): " |
| 737 | + ", ".join(str(index) for index in readiness.missing_notes) |
| 738 | ) |
| 739 | if readiness.invalid_notes: |
| 740 | messages.append( |
| 741 | "invalid notes: " |
| 742 | + "; ".join( |
| 743 | f"slide {index}: {reason}" |
| 744 | for index, reason in sorted(readiness.invalid_notes.items()) |
| 745 | ) |
| 746 | ) |
| 747 | if readiness.missing_audio: |
| 748 | messages.append( |
| 749 | "missing audio for slide(s): " |
| 750 | + ", ".join(str(index) for index in readiness.missing_audio) |
| 751 | ) |
| 752 | if readiness.invalid_audio: |
| 753 | messages.append( |
| 754 | "invalid audio: " |
| 755 | + "; ".join( |
| 756 | f"slide {index}: {reason}" |
| 757 | for index, reason in sorted(readiness.invalid_audio.items()) |
| 758 | ) |
| 759 | ) |
| 760 | return messages |
| 761 | |
| 762 | |
| 763 | def _material_readiness_report_fields( |
| 764 | readiness: MaterialReadiness, |
| 765 | ) -> dict[str, object]: |
| 766 | return { |
| 767 | "notes_count": readiness.notes_count, |
| 768 | "audio_count": readiness.audio_count, |
| 769 | "missing_notes": readiness.missing_notes, |
| 770 | "invalid_notes": sorted(readiness.invalid_notes), |
| 771 | "invalid_note_reasons": readiness.invalid_notes, |
| 772 | "missing_audio": readiness.missing_audio, |
| 773 | "invalid_audio": sorted(readiness.invalid_audio), |
| 774 | "invalid_audio_reasons": readiness.invalid_audio, |
| 775 | "module_errors": readiness.module_errors, |
| 776 | } |
| 777 | |
| 778 | |
| 779 | def _add_override(content_types: str, part_name: str, content_type: str) -> str: |
| 780 | if re.search( |
| 781 | rf'<Override\b[^>]*\bPartName="/{re.escape(part_name)}"[^>]*/>', |
| 782 | content_types, |
| 783 | ): |
| 784 | return content_types |
| 785 | override = f' <Override PartName="/{part_name}" ContentType="{content_type}"/>' |
| 786 | return content_types.replace("</Types>", override + "\n</Types>") |
| 787 | |
| 788 | |
| 789 | def _add_notes_content_types(content_types: str, note_parts: set[str]) -> str: |
| 790 | content_types = _add_override(content_types, "ppt/theme/theme2.xml", CONTENT_TYPE_THEME) |
| 791 | content_types = _add_override( |
| 792 | content_types, |
| 793 | "ppt/notesMasters/notesMaster1.xml", |
| 794 | CONTENT_TYPE_NOTES_MASTER, |
| 795 | ) |
| 796 | for part_name in sorted(note_parts): |
| 797 | content_types = _add_override( |
| 798 | content_types, |
| 799 | part_name, |
| 800 | CONTENT_TYPE_NOTES_SLIDE, |
| 801 | ) |
| 802 | return content_types |
| 803 | |
| 804 | |
| 805 | def _apply_notes( |
| 806 | extract_dir: Path, |
| 807 | slide: SlidePart, |
| 808 | note_md: Path, |
| 809 | ) -> str | None: |
| 810 | notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8")) |
| 811 | if not notes_text: |
| 812 | return None |
| 813 | |
| 814 | _ensure_notes_master(extract_dir) |
| 815 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 816 | _ensure_rels_file(slide_rels) |
| 817 | notes_part = _notes_slide_part_for_slide(extract_dir, slide) |
| 818 | if notes_part is None: |
| 819 | notes_part = _allocate_notes_slide_part(extract_dir) |
| 820 | target = posixpath.relpath( |
| 821 | notes_part, |
| 822 | start=posixpath.dirname(slide.part_name), |
| 823 | ) |
| 824 | _append_relationship(slide_rels, NOTES_REL_TYPE, target) |
| 825 | |
| 826 | notes_xml_path = extract_dir / notes_part |
| 827 | notes_xml_path.parent.mkdir(parents=True, exist_ok=True) |
| 828 | notes_xml_path.write_text( |
| 829 | create_notes_slide_xml(slide.slide_number, notes_text), |
| 830 | encoding="utf-8", |
| 831 | ) |
| 832 | |
| 833 | notes_rels_path = _relationship_file_for_part(extract_dir, notes_part) |
| 834 | notes_rels_path.parent.mkdir(parents=True, exist_ok=True) |
| 835 | notes_rels_path.write_text( |
| 836 | create_notes_slide_rels_xml(slide.slide_number), |
| 837 | encoding="utf-8", |
| 838 | ) |
| 839 | return notes_part |
| 840 | |
| 841 | |
| 842 | def _native_audio_carriers( |
| 843 | extract_dir: Path, |
| 844 | slides: list[SlidePart], |
| 845 | ) -> dict[int, list[str]]: |
| 846 | """Return existing tool-owned narration carrier names by public slide.""" |
| 847 | carriers: dict[int, list[str]] = {} |
| 848 | for slide in slides: |
| 849 | slide_root = ET.parse(extract_dir / slide.part_name).getroot() |
| 850 | names = sorted( |
| 851 | { |
| 852 | name |
| 853 | for element in slide_root.iter( |
| 854 | f"{{{PRESENTATION_NS}}}cNvPr" |
| 855 | ) |
| 856 | if (name := element.attrib.get("name", "")).startswith( |
| 857 | "native_enhance_audio_" |
| 858 | ) |
| 859 | } |
| 860 | ) |
| 861 | if names: |
| 862 | carriers[slide.index] = names |
| 863 | return carriers |
| 864 | |
| 865 | |
| 866 | def _allocate_media_name(media_dir: Path, preferred_name: str) -> str: |
| 867 | preferred_path = media_dir / preferred_name |
| 868 | if not preferred_path.exists(): |
| 869 | return preferred_name |
| 870 | stem = preferred_path.stem |
| 871 | suffix = preferred_path.suffix |
| 872 | index = 2 |
| 873 | while True: |
| 874 | candidate_name = f"{stem}_{index}{suffix}" |
| 875 | if not (media_dir / candidate_name).exists(): |
| 876 | return candidate_name |
| 877 | index += 1 |
| 878 | |
| 879 | |
| 880 | def _ensure_audio_poster(media_dir: Path) -> str: |
| 881 | preferred_name = "native_enhance_audio_poster.png" |
| 882 | for candidate in sorted( |
| 883 | media_dir.glob("native_enhance_audio_poster*.png") |
| 884 | ): |
| 885 | if not candidate.is_file(): |
| 886 | continue |
| 887 | try: |
| 888 | if candidate.read_bytes() == AUDIO_MARKER_PNG_BYTES: |
| 889 | return candidate.name |
| 890 | except OSError: |
| 891 | continue |
| 892 | poster_name = _allocate_media_name(media_dir, preferred_name) |
| 893 | (media_dir / poster_name).write_bytes(AUDIO_MARKER_PNG_BYTES) |
| 894 | return poster_name |
| 895 | |
| 896 | |
| 897 | def _apply_audio( |
| 898 | extract_dir: Path, |
| 899 | slide: SlidePart, |
| 900 | audio_path: Path, |
| 901 | *, |
| 902 | enter: EnterUpdate, |
| 903 | timings_enabled: bool, |
| 904 | narration_padding: float, |
| 905 | audio_duration: float | None = None, |
| 906 | ) -> bool: |
| 907 | media_dir = extract_dir / "ppt" / "media" |
| 908 | media_dir.mkdir(parents=True, exist_ok=True) |
| 909 | |
| 910 | ext = audio_path.suffix.lower() |
| 911 | media_name = _allocate_media_name( |
| 912 | media_dir, |
| 913 | f"native_enhance_audio_{slide.index:03d}{ext}", |
| 914 | ) |
| 915 | shutil.copy2(audio_path, media_dir / media_name) |
| 916 | |
| 917 | poster_name = _ensure_audio_poster(media_dir) |
| 918 | |
| 919 | slide_rels = _relationship_file_for_part(extract_dir, slide.part_name) |
| 920 | _ensure_rels_file(slide_rels) |
| 921 | media_rid = _append_relationship(slide_rels, MEDIA_REL_TYPE, f"../media/{media_name}") |
| 922 | audio_rid = _append_relationship(slide_rels, AUDIO_REL_TYPE, f"../media/{media_name}") |
| 923 | poster_rid = _append_relationship(slide_rels, IMAGE_REL_TYPE, f"../media/{poster_name}") |
| 924 | |
| 925 | slide_xml_path = extract_dir / slide.part_name |
| 926 | slide_xml = slide_xml_path.read_text(encoding="utf-8") |
| 927 | source_animation_fingerprint = object_animation_fingerprint(slide_xml) |
| 928 | shape_id = next_shape_id(slide_xml) |
| 929 | slide_xml = inject_narration( |
| 930 | slide_xml, |
| 931 | shape_id=shape_id, |
| 932 | shape_name=media_name, |
| 933 | audio_rid=audio_rid, |
| 934 | media_rid=media_rid, |
| 935 | poster_rid=poster_rid, |
| 936 | ) |
| 937 | |
| 938 | advance = AdvanceUpdate(mode="preserve") |
| 939 | if timings_enabled: |
| 940 | duration = audio_duration |
| 941 | if duration is None: |
| 942 | duration = probe_audio_duration(audio_path) |
| 943 | if duration is None: |
| 944 | raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}") |
| 945 | advance = AdvanceUpdate( |
| 946 | mode="narration", |
| 947 | after=duration + narration_padding, |
| 948 | ) |
| 949 | |
| 950 | wrote_advance = False |
| 951 | if enter.policy != "preserve" or timings_enabled: |
| 952 | slide_xml, wrote_advance = apply_slide_motion_xml( |
| 953 | slide_xml, |
| 954 | enter=enter, |
| 955 | advance=advance, |
| 956 | ) |
| 957 | if object_animation_fingerprint(slide_xml) != source_animation_fingerprint: |
| 958 | raise RuntimeError( |
| 959 | f"Slide {slide.index} object animations changed while adding narration" |
| 960 | ) |
| 961 | slide_xml_path.write_text(slide_xml, encoding="utf-8") |
| 962 | return timings_enabled and wrote_advance |
| 963 | |
| 964 | |
| 965 | def _update_content_types( |
| 966 | extract_dir: Path, |
| 967 | note_parts: set[str], |
| 968 | audio_exts: set[str], |
| 969 | ) -> None: |
| 970 | content_types_path = extract_dir / "[Content_Types].xml" |
| 971 | content_types = content_types_path.read_text(encoding="utf-8") |
| 972 | if note_parts: |
| 973 | content_types = _add_notes_content_types(content_types, note_parts) |
| 974 | for ext in sorted(audio_exts): |
| 975 | content_type = AUDIO_CONTENT_TYPES.get(ext) |
| 976 | if content_type: |
| 977 | content_types = _add_default_content_type(content_types, ext, content_type) |
| 978 | if audio_exts: |
| 979 | content_types = _add_default_content_type(content_types, "png", "image/png") |
| 980 | content_types_path.write_text(content_types, encoding="utf-8") |
| 981 | |
| 982 | |
| 983 | def _project_paths(project_path: Path) -> tuple[Path, Path, Path, Path]: |
| 984 | project = _read_json(project_path / "project.json") |
| 985 | source_pptx = project_path / project["source_pptx"] |
| 986 | notes_dir = project_path / project["notes_dir"] |
| 987 | audio_dir = project_path / project["audio_dir"] |
| 988 | exports_dir = project_path / project["exports_dir"] |
| 989 | return source_pptx, notes_dir, audio_dir, exports_dir |
| 990 | |
| 991 | |
| 992 | def _output_path_error( |
| 993 | project_path: Path, |
| 994 | project: dict, |
| 995 | source_pptx: Path, |
| 996 | output_path: Path, |
| 997 | ) -> str | None: |
| 998 | if output_path.suffix.lower() != ".pptx": |
| 999 | return f"output must use a .pptx extension: {output_path}" |
| 1000 | if output_path == source_pptx.resolve(): |
| 1001 | return "output must not overwrite the archived source PPTX" |
| 1002 | |
| 1003 | source_import = project.get("source_import") |
| 1004 | if isinstance(source_import, dict): |
| 1005 | original_path = source_import.get("original_path") |
| 1006 | if isinstance(original_path, str) and original_path: |
| 1007 | try: |
| 1008 | original = Path(original_path).expanduser().resolve() |
| 1009 | except OSError: |
| 1010 | original = None |
| 1011 | if original is not None and output_path == original: |
| 1012 | return "output must not overwrite the original source PPTX" |
| 1013 | |
| 1014 | protected = ( |
| 1015 | project_path / "sources", |
| 1016 | project_path / "analysis", |
| 1017 | project_path / "notes", |
| 1018 | project_path / "audio", |
| 1019 | project_path / "validation", |
| 1020 | ) |
| 1021 | for directory in protected: |
| 1022 | if _is_relative_to(output_path, directory): |
| 1023 | return ( |
| 1024 | "output must not be written inside native-enhance control " |
| 1025 | f"directory: {directory}" |
| 1026 | ) |
| 1027 | return None |
| 1028 | |
| 1029 | |
| 1030 | def _plan_path(project_path: Path) -> Path: |
| 1031 | return project_path / "analysis" / "enhancement_plan.json" |
| 1032 | |
| 1033 | |
| 1034 | def _load_enhancement_plan(project_path: Path) -> dict: |
| 1035 | path = _plan_path(project_path) |
| 1036 | if not path.exists(): |
| 1037 | return {} |
| 1038 | return _read_json(path) |
| 1039 | |
| 1040 | |
| 1041 | def _enabled_modules(plan: dict) -> set[str]: |
| 1042 | modules = plan.get("modules") |
| 1043 | if not isinstance(modules, dict): |
| 1044 | return set(_WRITABLE_MODULES) |
| 1045 | enabled: set[str] = set() |
| 1046 | for name in _WRITABLE_MODULES: |
| 1047 | config = modules.get(name) |
| 1048 | if isinstance(config, dict) and config.get("enabled") is True: |
| 1049 | enabled.add(name) |
| 1050 | return enabled |
| 1051 | |
| 1052 | |
| 1053 | def _resolve_enter_update( |
| 1054 | *, |
| 1055 | cli_effect: str | None, |
| 1056 | configured_effect: object, |
| 1057 | configured_effect_options: object = None, |
| 1058 | transitions_enabled: bool, |
| 1059 | duration: float, |
| 1060 | ) -> EnterUpdate: |
| 1061 | if cli_effect is None and not transitions_enabled: |
| 1062 | if configured_effect == "none": |
| 1063 | normalize_transition_effect_request( |
| 1064 | configured_effect, |
| 1065 | configured_effect_options, |
| 1066 | ) |
| 1067 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1068 | return EnterUpdate(policy="preserve", duration=duration) |
| 1069 | |
| 1070 | effect = cli_effect if cli_effect is not None else configured_effect |
| 1071 | if effect == "none": |
| 1072 | normalize_transition_effect_request( |
| 1073 | effect, |
| 1074 | None if cli_effect is not None else configured_effect_options, |
| 1075 | ) |
| 1076 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1077 | effect, effect_options = normalize_transition_effect_request( |
| 1078 | effect, |
| 1079 | None if cli_effect is not None else configured_effect_options, |
| 1080 | allow_none=False, |
| 1081 | ) |
| 1082 | |
| 1083 | return EnterUpdate( |
| 1084 | policy="replace", |
| 1085 | effect=effect, |
| 1086 | duration=duration, |
| 1087 | effect_options=effect_options, |
| 1088 | ) |
| 1089 | |
| 1090 | |
| 1091 | def _plan_confirmed(plan: dict) -> bool: |
| 1092 | return plan.get("status") == "confirmed" |
| 1093 | |
| 1094 | |
| 1095 | def _native_transition_config( |
| 1096 | transition: str, |
| 1097 | duration: float, |
| 1098 | effect_options: object = None, |
| 1099 | ) -> dict[str, object]: |
| 1100 | if transition == "none": |
| 1101 | normalize_transition_effect_request(transition, effect_options) |
| 1102 | return {"effect": "none", "duration": duration} |
| 1103 | effect, effect_options = normalize_transition_effect_request( |
| 1104 | transition, |
| 1105 | effect_options, |
| 1106 | allow_none=False, |
| 1107 | ) |
| 1108 | config: dict[str, object] = { |
| 1109 | "effect": effect, |
| 1110 | "duration": duration, |
| 1111 | } |
| 1112 | if effect_options: |
| 1113 | config["effect_options"] = effect_options |
| 1114 | return config |
| 1115 | |
| 1116 | |
| 1117 | def _module_config(plan: dict, name: str) -> dict: |
| 1118 | modules = plan.get("modules") |
| 1119 | if not isinstance(modules, dict): |
| 1120 | return {} |
| 1121 | config = modules.get(name) |
| 1122 | return config if isinstance(config, dict) else {} |
| 1123 | |
| 1124 | |
| 1125 | def _preserved_enabled(plan: dict, name: str, default: bool) -> bool: |
| 1126 | config = _module_config(plan, name) |
| 1127 | if "enabled" not in config: |
| 1128 | return default |
| 1129 | value = config["enabled"] |
| 1130 | if not isinstance(value, bool): |
| 1131 | raise ValueError( |
| 1132 | f"enhancement plan module {name}.enabled must be a boolean" |
| 1133 | ) |
| 1134 | return value |
| 1135 | |
| 1136 | |
| 1137 | def _resolved_draft_transition_config( |
| 1138 | project: dict, |
| 1139 | existing_plan: dict, |
| 1140 | *, |
| 1141 | transition: str | None, |
| 1142 | transition_duration: float | None, |
| 1143 | apply_transition_without_audio: bool | None, |
| 1144 | ) -> tuple[bool, dict[str, object]]: |
| 1145 | existing = _module_config(existing_plan, "transitions") |
| 1146 | project_default = ( |
| 1147 | project.get("transition") |
| 1148 | if isinstance(project.get("transition"), dict) |
| 1149 | else {} |
| 1150 | ) |
| 1151 | |
| 1152 | if transition is not None: |
| 1153 | raw_effect: object = transition |
| 1154 | raw_options: object = None |
| 1155 | elif "effect" in existing: |
| 1156 | raw_effect = existing["effect"] |
| 1157 | raw_options = existing.get("effect_options") |
| 1158 | elif "effect" in project_default: |
| 1159 | raw_effect = project_default["effect"] |
| 1160 | raw_options = project_default.get("effect_options") |
| 1161 | else: |
| 1162 | raw_effect = "fade" |
| 1163 | raw_options = None |
| 1164 | |
| 1165 | if not isinstance(raw_effect, str): |
| 1166 | raise ValueError("transition effect must be a string") |
| 1167 | if transition_duration is not None: |
| 1168 | raw_duration: object = transition_duration |
| 1169 | elif "duration" in existing: |
| 1170 | raw_duration = existing["duration"] |
| 1171 | elif "duration" in project_default: |
| 1172 | raw_duration = project_default["duration"] |
| 1173 | else: |
| 1174 | raw_duration = 0.5 |
| 1175 | duration = validate_seconds( |
| 1176 | raw_duration, |
| 1177 | "transition duration", |
| 1178 | allow_zero=False, |
| 1179 | ) |
| 1180 | config = _native_transition_config( |
| 1181 | raw_effect, |
| 1182 | duration, |
| 1183 | raw_options, |
| 1184 | ) |
| 1185 | |
| 1186 | if apply_transition_without_audio is None: |
| 1187 | raw_apply_without_audio = existing.get( |
| 1188 | "apply_without_audio", |
| 1189 | False, |
| 1190 | ) |
| 1191 | else: |
| 1192 | raw_apply_without_audio = apply_transition_without_audio |
| 1193 | if not isinstance(raw_apply_without_audio, bool): |
| 1194 | raise ValueError("transition apply_without_audio must be a boolean") |
| 1195 | config["apply_without_audio"] = raw_apply_without_audio |
| 1196 | |
| 1197 | if "slides" in existing: |
| 1198 | slides = existing["slides"] |
| 1199 | if not isinstance(slides, dict): |
| 1200 | raise ValueError("transition slides must be an object") |
| 1201 | config["slides"] = { |
| 1202 | str(key): dict(value) if isinstance(value, dict) else value |
| 1203 | for key, value in slides.items() |
| 1204 | } |
| 1205 | |
| 1206 | if transition is not None: |
| 1207 | enabled = transition != "none" |
| 1208 | else: |
| 1209 | enabled = _preserved_enabled( |
| 1210 | existing_plan, |
| 1211 | "transitions", |
| 1212 | raw_effect != "none", |
| 1213 | ) |
| 1214 | return enabled, config |
| 1215 | |
| 1216 | |
| 1217 | def _build_enhancement_plan( |
| 1218 | project: dict, |
| 1219 | *, |
| 1220 | slide_count: int, |
| 1221 | notes_count: int, |
| 1222 | audio_count: int, |
| 1223 | transition: str | None, |
| 1224 | transition_duration: float | None, |
| 1225 | narration_padding: float | None, |
| 1226 | apply_transition_without_audio: bool | None, |
| 1227 | existing_plan: dict | None = None, |
| 1228 | ) -> dict: |
| 1229 | previous = existing_plan or {} |
| 1230 | audio_enabled = _preserved_enabled(previous, "audio", True) |
| 1231 | notes_enabled = _preserved_enabled(previous, "notes", True) or audio_enabled |
| 1232 | timings_enabled = _preserved_enabled(previous, "timings", True) |
| 1233 | previous_timings = _module_config(previous, "timings") |
| 1234 | raw_padding: object |
| 1235 | if narration_padding is not None: |
| 1236 | raw_padding = narration_padding |
| 1237 | else: |
| 1238 | raw_padding = previous_timings.get("narration_padding", 0.4) |
| 1239 | resolved_padding = validate_seconds( |
| 1240 | raw_padding, |
| 1241 | "narration padding", |
| 1242 | allow_zero=True, |
| 1243 | ) |
| 1244 | transitions_enabled, transition_config = _resolved_draft_transition_config( |
| 1245 | project, |
| 1246 | previous, |
| 1247 | transition=transition, |
| 1248 | transition_duration=transition_duration, |
| 1249 | apply_transition_without_audio=apply_transition_without_audio, |
| 1250 | ) |
| 1251 | return { |
| 1252 | "schema": PLAN_SCHEMA, |
| 1253 | "status": "draft", |
| 1254 | "source_pptx": project.get("source_pptx"), |
| 1255 | "slide_count": slide_count, |
| 1256 | "modules": { |
| 1257 | "notes": { |
| 1258 | "enabled": notes_enabled, |
| 1259 | "requires_confirmation": True, |
| 1260 | "status": ( |
| 1261 | "disabled" |
| 1262 | if not notes_enabled |
| 1263 | else ( |
| 1264 | "coverage_complete" |
| 1265 | if notes_count == slide_count |
| 1266 | else "needs_notes" |
| 1267 | ) |
| 1268 | ), |
| 1269 | "coverage": {"present": notes_count, "total": slide_count}, |
| 1270 | }, |
| 1271 | "audio": { |
| 1272 | "enabled": audio_enabled, |
| 1273 | "requires_confirmation": True, |
| 1274 | "status": ( |
| 1275 | "disabled" |
| 1276 | if not audio_enabled |
| 1277 | else ( |
| 1278 | "coverage_complete" |
| 1279 | if audio_count == slide_count |
| 1280 | else "needs_audio" |
| 1281 | ) |
| 1282 | ), |
| 1283 | "coverage": {"present": audio_count, "total": slide_count}, |
| 1284 | "decodability": "unchecked", |
| 1285 | }, |
| 1286 | "timings": { |
| 1287 | "enabled": timings_enabled, |
| 1288 | "requires_confirmation": True, |
| 1289 | "status": ( |
| 1290 | "disabled" |
| 1291 | if not timings_enabled |
| 1292 | else ( |
| 1293 | "audio_coverage_complete" |
| 1294 | if audio_enabled and audio_count == slide_count |
| 1295 | else "blocked_until_audio" |
| 1296 | ) |
| 1297 | ), |
| 1298 | "source": "audio_duration", |
| 1299 | "narration_padding": resolved_padding, |
| 1300 | }, |
| 1301 | "transitions": { |
| 1302 | "enabled": transitions_enabled, |
| 1303 | "requires_confirmation": True, |
| 1304 | "status": ( |
| 1305 | "ready" |
| 1306 | if ( |
| 1307 | transitions_enabled |
| 1308 | or transition_config.get("effect") == "none" |
| 1309 | or bool(transition_config.get("slides")) |
| 1310 | ) |
| 1311 | else "disabled" |
| 1312 | ), |
| 1313 | **transition_config, |
| 1314 | }, |
| 1315 | }, |
| 1316 | "not_in_v1": [ |
| 1317 | "object_animation", |
| 1318 | "visible_watermark", |
| 1319 | "footer_or_logo_insertion", |
| 1320 | "background_music", |
| 1321 | "media_compression", |
| 1322 | ], |
| 1323 | } |
| 1324 | |
| 1325 | |
| 1326 | def _resolve_slide_enter( |
| 1327 | base: EnterUpdate, |
| 1328 | override: dict, |
| 1329 | *, |
| 1330 | slide_index: int, |
| 1331 | ) -> EnterUpdate: |
| 1332 | unknown = sorted(set(override) - _TRANSITION_OVERRIDE_FIELDS) |
| 1333 | if unknown: |
| 1334 | raise ValueError( |
| 1335 | f"transition slides.{slide_index} has unknown field(s): " |
| 1336 | + ", ".join(unknown) |
| 1337 | ) |
| 1338 | |
| 1339 | raw_duration = override.get("duration", base.duration) |
| 1340 | duration = validate_seconds( |
| 1341 | raw_duration, |
| 1342 | f"transition slides.{slide_index}.duration", |
| 1343 | allow_zero=False, |
| 1344 | ) |
| 1345 | effect = override.get("effect") |
| 1346 | if effect == "preserve": |
| 1347 | if "effect_options" in override: |
| 1348 | raise ValueError( |
| 1349 | f"transition slides.{slide_index} preserve cannot have " |
| 1350 | "effect_options" |
| 1351 | ) |
| 1352 | return EnterUpdate(policy="preserve", duration=duration) |
| 1353 | |
| 1354 | if effect is None: |
| 1355 | if base.policy == "preserve": |
| 1356 | if "effect_options" in override: |
| 1357 | raise ValueError( |
| 1358 | f"transition slides.{slide_index} effect_options requires " |
| 1359 | "a native effect" |
| 1360 | ) |
| 1361 | return EnterUpdate(policy="preserve", duration=duration) |
| 1362 | if base.policy == "none": |
| 1363 | if "effect_options" in override: |
| 1364 | raise ValueError( |
| 1365 | f"transition slides.{slide_index} none cannot have " |
| 1366 | "effect_options" |
| 1367 | ) |
| 1368 | return EnterUpdate(policy="none", effect=None, duration=duration) |
| 1369 | effect = base.effect |
| 1370 | effect_options = override.get( |
| 1371 | "effect_options", |
| 1372 | base.effect_options, |
| 1373 | ) |
| 1374 | else: |
| 1375 | if not isinstance(effect, str): |
| 1376 | raise ValueError( |
| 1377 | f"transition slides.{slide_index}.effect must be a string" |
| 1378 | ) |
| 1379 | effect_options = override.get("effect_options") |
| 1380 | |
| 1381 | return _resolve_enter_update( |
| 1382 | cli_effect=None, |
| 1383 | configured_effect=effect, |
| 1384 | configured_effect_options=effect_options, |
| 1385 | transitions_enabled=True, |
| 1386 | duration=duration, |
| 1387 | ) |
| 1388 | |
| 1389 | |
| 1390 | def _validate_plan_modules( |
| 1391 | plan: dict, |
| 1392 | *, |
| 1393 | allow_legacy_audio_without_notes: bool = False, |
| 1394 | ) -> None: |
| 1395 | if plan and plan.get("schema") != PLAN_SCHEMA: |
| 1396 | raise ValueError( |
| 1397 | f"unsupported enhancement plan schema: {plan.get('schema')!r}" |
| 1398 | ) |
| 1399 | modules_cfg = plan.get("modules") |
| 1400 | if modules_cfg is None: |
| 1401 | return |
| 1402 | if not isinstance(modules_cfg, dict): |
| 1403 | raise ValueError("enhancement plan modules must be an object") |
| 1404 | |
| 1405 | unknown_modules = sorted(set(modules_cfg) - set(_WRITABLE_MODULES)) |
| 1406 | if unknown_modules: |
| 1407 | raise ValueError( |
| 1408 | "enhancement plan has unknown module(s): " |
| 1409 | + ", ".join(unknown_modules) |
| 1410 | ) |
| 1411 | if plan.get("schema") == PLAN_SCHEMA: |
| 1412 | missing_modules = [ |
| 1413 | name |
| 1414 | for name in _WRITABLE_MODULES |
| 1415 | if name not in modules_cfg |
| 1416 | ] |
| 1417 | if missing_modules: |
| 1418 | raise ValueError( |
| 1419 | "enhancement plan is missing module(s): " |
| 1420 | + ", ".join(missing_modules) |
| 1421 | ) |
| 1422 | for name in _WRITABLE_MODULES: |
| 1423 | config = modules_cfg.get(name) |
| 1424 | if config is not None and not isinstance(config, dict): |
| 1425 | raise ValueError( |
| 1426 | f"enhancement plan module {name} must be an object" |
| 1427 | ) |
| 1428 | if ( |
| 1429 | isinstance(config, dict) |
| 1430 | and ( |
| 1431 | "enabled" not in config |
| 1432 | or not isinstance(config["enabled"], bool) |
| 1433 | ) |
| 1434 | ): |
| 1435 | raise ValueError( |
| 1436 | f"enhancement plan module {name}.enabled must be a boolean" |
| 1437 | ) |
| 1438 | notes_config = modules_cfg.get("notes") |
| 1439 | audio_config = modules_cfg.get("audio") |
| 1440 | legacy_audio_without_notes = ( |
| 1441 | allow_legacy_audio_without_notes |
| 1442 | and isinstance(notes_config, dict) |
| 1443 | and notes_config.get("enabled") is False |
| 1444 | ) |
| 1445 | if ( |
| 1446 | isinstance(audio_config, dict) |
| 1447 | and audio_config.get("enabled") is True |
| 1448 | and not legacy_audio_without_notes |
| 1449 | and ( |
| 1450 | not isinstance(notes_config, dict) |
| 1451 | or notes_config.get("enabled") is not True |
| 1452 | ) |
| 1453 | ): |
| 1454 | raise ValueError( |
| 1455 | "enhancement plan audio requires notes.enabled: true" |
| 1456 | ) |
| 1457 | |
| 1458 | |
| 1459 | def _resolve_transition_plan( |
| 1460 | project: dict, |
| 1461 | plan: dict, |
| 1462 | slides: list[SlidePart], |
| 1463 | *, |
| 1464 | cli_effect: str | None = None, |
| 1465 | cli_duration: float | None = None, |
| 1466 | cli_apply_without_audio: bool = False, |
| 1467 | ) -> ResolvedTransitionPlan: |
| 1468 | _validate_plan_modules(plan) |
| 1469 | plan_slide_count = plan.get("slide_count") |
| 1470 | if plan_slide_count is not None and plan_slide_count != len(slides): |
| 1471 | raise ValueError( |
| 1472 | "enhancement plan slide_count no longer matches the archived " |
| 1473 | f"source: {plan_slide_count!r} != {len(slides)}" |
| 1474 | ) |
| 1475 | |
| 1476 | modules = _enabled_modules(plan) |
| 1477 | transitions_cfg = _module_config(plan, "transitions") |
| 1478 | unknown = sorted(set(transitions_cfg) - _TRANSITION_MODULE_FIELDS) |
| 1479 | if unknown: |
| 1480 | raise ValueError( |
| 1481 | "transition module has unknown field(s): " + ", ".join(unknown) |
| 1482 | ) |
| 1483 | |
| 1484 | project_transition = ( |
| 1485 | project.get("transition") |
| 1486 | if isinstance(project.get("transition"), dict) |
| 1487 | else {} |
| 1488 | ) |
| 1489 | if ( |
| 1490 | cli_effect is None |
| 1491 | and "effect_options" in transitions_cfg |
| 1492 | and "effect" not in transitions_cfg |
| 1493 | ): |
| 1494 | raise ValueError("transition effect_options requires an explicit effect") |
| 1495 | if ( |
| 1496 | cli_effect is None |
| 1497 | and "effect_options" in project_transition |
| 1498 | and "effect" not in project_transition |
| 1499 | and "effect" not in transitions_cfg |
| 1500 | ): |
| 1501 | raise ValueError("transition effect_options requires an explicit effect") |
| 1502 | |
| 1503 | if "effect" in transitions_cfg: |
| 1504 | configured_effect = transitions_cfg["effect"] |
| 1505 | configured_options = transitions_cfg.get("effect_options") |
| 1506 | elif "effect" in project_transition: |
| 1507 | configured_effect = project_transition["effect"] |
| 1508 | configured_options = project_transition.get("effect_options") |
| 1509 | else: |
| 1510 | configured_effect = "fade" |
| 1511 | configured_options = None |
| 1512 | |
| 1513 | if cli_duration is not None: |
| 1514 | raw_duration: object = cli_duration |
| 1515 | elif "duration" in transitions_cfg: |
| 1516 | raw_duration = transitions_cfg["duration"] |
| 1517 | elif "duration" in project_transition: |
| 1518 | raw_duration = project_transition["duration"] |
| 1519 | else: |
| 1520 | raw_duration = 0.5 |
| 1521 | duration = validate_seconds( |
| 1522 | raw_duration, |
| 1523 | "transition duration", |
| 1524 | allow_zero=False, |
| 1525 | ) |
| 1526 | |
| 1527 | selected_base = _resolve_enter_update( |
| 1528 | cli_effect=cli_effect, |
| 1529 | configured_effect=configured_effect, |
| 1530 | configured_effect_options=configured_options, |
| 1531 | transitions_enabled=True, |
| 1532 | duration=duration, |
| 1533 | ) |
| 1534 | global_enter = _resolve_enter_update( |
| 1535 | cli_effect=cli_effect, |
| 1536 | configured_effect=configured_effect, |
| 1537 | configured_effect_options=configured_options, |
| 1538 | transitions_enabled="transitions" in modules, |
| 1539 | duration=duration, |
| 1540 | ) |
| 1541 | |
| 1542 | raw_apply_without_audio = transitions_cfg.get( |
| 1543 | "apply_without_audio", |
| 1544 | False, |
| 1545 | ) |
| 1546 | if not isinstance(raw_apply_without_audio, bool): |
| 1547 | raise ValueError("transition apply_without_audio must be a boolean") |
| 1548 | apply_without_audio = ( |
| 1549 | cli_apply_without_audio or raw_apply_without_audio |
| 1550 | ) |
| 1551 | if ( |
| 1552 | "audio" not in modules |
| 1553 | and ( |
| 1554 | "transitions" in modules |
| 1555 | or cli_effect is not None |
| 1556 | or global_enter.policy == "none" |
| 1557 | ) |
| 1558 | ): |
| 1559 | # A confirmed global transition is independently actionable. The |
| 1560 | # narrated-only scope switch matters only while audio is enabled. |
| 1561 | # Explicit none remains an action even though the module is disabled. |
| 1562 | apply_without_audio = True |
| 1563 | |
| 1564 | raw_slides = transitions_cfg.get("slides", {}) |
| 1565 | if not isinstance(raw_slides, dict): |
| 1566 | raise ValueError("transition slides must be an object") |
| 1567 | valid_indices = {slide.index for slide in slides} |
| 1568 | slide_enters: dict[int, EnterUpdate] = {} |
| 1569 | for raw_index, override in raw_slides.items(): |
| 1570 | if ( |
| 1571 | not isinstance(raw_index, str) |
| 1572 | or re.fullmatch(r"[1-9]\d*", raw_index) is None |
| 1573 | ): |
| 1574 | raise ValueError( |
| 1575 | f"transition slide key must be a canonical 1-based index: " |
| 1576 | f"{raw_index!r}" |
| 1577 | ) |
| 1578 | slide_index = int(raw_index) |
| 1579 | if slide_index not in valid_indices: |
| 1580 | raise ValueError( |
| 1581 | f"transition slide index is outside the source roster: " |
| 1582 | f"{slide_index}" |
| 1583 | ) |
| 1584 | if not isinstance(override, dict): |
| 1585 | raise ValueError( |
| 1586 | f"transition slides.{slide_index} must be an object" |
| 1587 | ) |
| 1588 | slide_enters[slide_index] = _resolve_slide_enter( |
| 1589 | selected_base, |
| 1590 | override, |
| 1591 | slide_index=slide_index, |
| 1592 | ) |
| 1593 | |
| 1594 | return ResolvedTransitionPlan( |
| 1595 | global_enter=global_enter, |
| 1596 | slide_enters=slide_enters, |
| 1597 | apply_without_audio=apply_without_audio, |
| 1598 | ) |
| 1599 | |
| 1600 | |
| 1601 | def _apply_transition_only( |
| 1602 | extract_dir: Path, |
| 1603 | slide: SlidePart, |
| 1604 | enter: EnterUpdate, |
| 1605 | ) -> bool: |
| 1606 | if enter.policy == "preserve": |
| 1607 | return False |
| 1608 | slide_xml_path = extract_dir / slide.part_name |
| 1609 | slide_xml = slide_xml_path.read_text(encoding="utf-8") |
| 1610 | source_animation_fingerprint = object_animation_fingerprint(slide_xml) |
| 1611 | slide_xml, _uses_timings = apply_slide_motion_xml( |
| 1612 | slide_xml, |
| 1613 | enter=enter, |
| 1614 | advance=AdvanceUpdate(mode="preserve"), |
| 1615 | ) |
| 1616 | if object_animation_fingerprint(slide_xml) != source_animation_fingerprint: |
| 1617 | raise RuntimeError( |
| 1618 | f"Slide {slide.index} object animations changed while updating " |
| 1619 | "the transition" |
| 1620 | ) |
| 1621 | slide_xml_path.write_text(slide_xml, encoding="utf-8") |
| 1622 | return True |
| 1623 | |
| 1624 | |
| 1625 | def init_project(args: argparse.Namespace) -> int: |
| 1626 | source_pptx = Path(args.source_pptx).expanduser().resolve() |
| 1627 | if not source_pptx.exists() or source_pptx.suffix.lower() != ".pptx": |
| 1628 | print(f"error: expected an existing .pptx file: {source_pptx}", file=sys.stderr) |
| 1629 | return 1 |
| 1630 | |
| 1631 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1632 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1633 | if fatal_delivery_messages: |
| 1634 | for message in fatal_delivery_messages: |
| 1635 | print(f"error: {message}", file=sys.stderr) |
| 1636 | return 1 |
| 1637 | |
| 1638 | stem = _sanitize_slug(args.name or source_pptx.stem) |
| 1639 | date = datetime.now().strftime("%Y%m%d") |
| 1640 | project_path = ( |
| 1641 | Path(args.project_dir).expanduser().resolve() |
| 1642 | if args.project_dir |
| 1643 | else Path(args.projects_root).expanduser().resolve() / f"{stem}_native_enhance_{date}" |
| 1644 | ) |
| 1645 | if project_path.exists() and any(project_path.iterdir()): |
| 1646 | print(f"error: project directory already exists and is not empty: {project_path}", file=sys.stderr) |
| 1647 | return 1 |
| 1648 | |
| 1649 | for dirname in ("sources", "analysis", "notes", "audio", "exports", "validation"): |
| 1650 | (project_path / dirname).mkdir(parents=True, exist_ok=True) |
| 1651 | |
| 1652 | archived_pptx = project_path / "sources" / source_pptx.name |
| 1653 | projects_root = Path(args.projects_root).expanduser().resolve() |
| 1654 | source_import_mode = _archive_source_pptx(source_pptx, archived_pptx, projects_root) |
| 1655 | |
| 1656 | source_md = project_path / "sources" / f"{source_pptx.stem}.md" |
| 1657 | ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py" |
| 1658 | result = subprocess.run( |
| 1659 | [sys.executable, str(ppt_to_md), str(archived_pptx), "-o", str(source_md)], |
| 1660 | check=False, |
| 1661 | text=True, |
| 1662 | capture_output=True, |
| 1663 | ) |
| 1664 | if result.returncode != 0: |
| 1665 | print(result.stderr or result.stdout, file=sys.stderr) |
| 1666 | return result.returncode |
| 1667 | |
| 1668 | with tempfile.TemporaryDirectory(prefix="native-enhance-intake-") as tmp: |
| 1669 | extract_dir = Path(tmp) / "pptx" |
| 1670 | _extract_pptx(archived_pptx, extract_dir) |
| 1671 | slide_parts = read_slide_parts(extract_dir) |
| 1672 | source_sha256 = _file_sha256(archived_pptx) |
| 1673 | |
| 1674 | slide_index = { |
| 1675 | "schema": "native_pptx_enhancement_slide_index.v1", |
| 1676 | "source_pptx": f"sources/{source_pptx.name}", |
| 1677 | "slide_count": len(slide_parts), |
| 1678 | "slides": [ |
| 1679 | { |
| 1680 | "index": slide.index, |
| 1681 | "note_file": f"notes/{slide.index:03d}.md", |
| 1682 | "audio_stem": f"{slide.index:03d}", |
| 1683 | "part_name": slide.part_name, |
| 1684 | "slide_number": slide.slide_number, |
| 1685 | } |
| 1686 | for slide in slide_parts |
| 1687 | ], |
| 1688 | } |
| 1689 | _write_json(project_path / "analysis" / "slide_index.json", slide_index) |
| 1690 | |
| 1691 | project = { |
| 1692 | "schema": PROJECT_SCHEMA, |
| 1693 | "kind": "native_pptx_enhancement", |
| 1694 | "modules": [ |
| 1695 | "notes", |
| 1696 | "audio", |
| 1697 | "timings", |
| 1698 | "transitions", |
| 1699 | "delivery.check", |
| 1700 | ], |
| 1701 | "source_pptx": f"sources/{source_pptx.name}", |
| 1702 | "source_markdown": f"sources/{source_pptx.stem}.md", |
| 1703 | "source_import": { |
| 1704 | "mode": source_import_mode, |
| 1705 | "original_path": str(source_pptx), |
| 1706 | }, |
| 1707 | "source_sha256": source_sha256, |
| 1708 | "slide_count": len(slide_parts), |
| 1709 | "slide_part_roster": [slide.part_name for slide in slide_parts], |
| 1710 | "notes_dir": "notes", |
| 1711 | "audio_dir": "audio", |
| 1712 | "exports_dir": "exports", |
| 1713 | "transition": _native_transition_config( |
| 1714 | args.transition, |
| 1715 | args.transition_duration, |
| 1716 | ), |
| 1717 | "audio": { |
| 1718 | "provider": "", |
| 1719 | "voice": "", |
| 1720 | "rate": "", |
| 1721 | }, |
| 1722 | } |
| 1723 | _write_json(project_path / "project.json", project) |
| 1724 | plan = _build_enhancement_plan( |
| 1725 | project, |
| 1726 | slide_count=len(slide_parts), |
| 1727 | notes_count=0, |
| 1728 | audio_count=0, |
| 1729 | transition=args.transition, |
| 1730 | transition_duration=args.transition_duration, |
| 1731 | narration_padding=args.narration_padding, |
| 1732 | apply_transition_without_audio=args.apply_transition_without_audio, |
| 1733 | ) |
| 1734 | _write_json(_plan_path(project_path), plan) |
| 1735 | source_delivery_file = source_delivery.get("file") |
| 1736 | if isinstance(source_delivery_file, dict): |
| 1737 | source_delivery_file["path"] = str(archived_pptx.resolve()) |
| 1738 | _write_json( |
| 1739 | project_path / "validation" / "report.json", |
| 1740 | { |
| 1741 | "schema": VALIDATION_SCHEMA, |
| 1742 | "status": ( |
| 1743 | "passed-with-advisories" |
| 1744 | if _delivery_has_findings(source_delivery) |
| 1745 | else "passed" |
| 1746 | ), |
| 1747 | "phase": "intake", |
| 1748 | "source_delivery_policy": "preserve-baseline", |
| 1749 | "delivery_check": source_delivery, |
| 1750 | }, |
| 1751 | ) |
| 1752 | |
| 1753 | print(f"Project: {project_path}", file=sys.stderr) |
| 1754 | print(f"Slides: {len(slide_parts)}", file=sys.stderr) |
| 1755 | print(f"Source import: {source_import_mode}", file=sys.stderr) |
| 1756 | print(f"Source markdown: {source_md}", file=sys.stderr) |
| 1757 | print(f"Draft enhancement plan: {_plan_path(project_path)}", file=sys.stderr) |
| 1758 | print( |
| 1759 | "Review the plan with the user and set status to \"confirmed\" before generating notes/audio/applying.", |
| 1760 | file=sys.stderr, |
| 1761 | ) |
| 1762 | return 0 |
| 1763 | |
| 1764 | |
| 1765 | def plan_project(args: argparse.Namespace) -> int: |
| 1766 | project_path = Path(args.project_path).expanduser().resolve() |
| 1767 | project = _read_json(project_path / "project.json") |
| 1768 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 1769 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 1770 | return 1 |
| 1771 | |
| 1772 | source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) |
| 1773 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1774 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1775 | if fatal_delivery_messages: |
| 1776 | for message in fatal_delivery_messages: |
| 1777 | print(f"error: {message}", file=sys.stderr) |
| 1778 | return 1 |
| 1779 | |
| 1780 | with tempfile.TemporaryDirectory(prefix="native-enhance-plan-") as tmp: |
| 1781 | extract_dir = Path(tmp) / "pptx" |
| 1782 | _extract_pptx(source_pptx, extract_dir) |
| 1783 | slides = read_slide_parts(extract_dir) |
| 1784 | |
| 1785 | source_errors = _source_state_errors( |
| 1786 | project_path, |
| 1787 | project, |
| 1788 | source_pptx, |
| 1789 | slides, |
| 1790 | ) |
| 1791 | if source_errors: |
| 1792 | for error in source_errors: |
| 1793 | print(f"error: {error}", file=sys.stderr) |
| 1794 | return 1 |
| 1795 | |
| 1796 | existing_plan = _load_enhancement_plan(project_path) |
| 1797 | try: |
| 1798 | _validate_plan_modules( |
| 1799 | existing_plan, |
| 1800 | allow_legacy_audio_without_notes=True, |
| 1801 | ) |
| 1802 | readiness = _collect_material_readiness( |
| 1803 | slides, |
| 1804 | notes_dir, |
| 1805 | audio_dir, |
| 1806 | set(), |
| 1807 | ) |
| 1808 | plan = _build_enhancement_plan( |
| 1809 | project, |
| 1810 | slide_count=len(slides), |
| 1811 | notes_count=readiness.notes_count, |
| 1812 | audio_count=readiness.audio_count, |
| 1813 | transition=args.transition, |
| 1814 | transition_duration=args.transition_duration, |
| 1815 | narration_padding=args.narration_padding, |
| 1816 | apply_transition_without_audio=args.apply_transition_without_audio, |
| 1817 | existing_plan=existing_plan, |
| 1818 | ) |
| 1819 | _resolve_transition_plan(project, plan, slides) |
| 1820 | except ValueError as exc: |
| 1821 | print(f"error: {exc}", file=sys.stderr) |
| 1822 | return 1 |
| 1823 | _write_json(_plan_path(project_path), plan) |
| 1824 | print(json.dumps(plan, ensure_ascii=False, indent=2)) |
| 1825 | print(f"Plan written: {_plan_path(project_path)}", file=sys.stderr) |
| 1826 | print( |
| 1827 | "Confirm by editing status to \"confirmed\" after user approval, then run apply.", |
| 1828 | file=sys.stderr, |
| 1829 | ) |
| 1830 | return 0 |
| 1831 | |
| 1832 | |
| 1833 | def apply_project(args: argparse.Namespace) -> int: |
| 1834 | project_path = Path(args.project_path).expanduser().resolve() |
| 1835 | project = _read_json(project_path / "project.json") |
| 1836 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 1837 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 1838 | return 1 |
| 1839 | |
| 1840 | source_pptx, notes_dir, audio_dir, exports_dir = _project_paths(project_path) |
| 1841 | plan = _load_enhancement_plan(project_path) |
| 1842 | modules = _enabled_modules(plan) |
| 1843 | |
| 1844 | def fail_preflight( |
| 1845 | messages: list[str], |
| 1846 | *, |
| 1847 | status: str = "failed", |
| 1848 | **details: object, |
| 1849 | ) -> int: |
| 1850 | _write_preflight_report( |
| 1851 | project_path, |
| 1852 | plan, |
| 1853 | modules, |
| 1854 | status=status, |
| 1855 | errors=messages, |
| 1856 | **details, |
| 1857 | ) |
| 1858 | for message in messages: |
| 1859 | print(f"error: {message}", file=sys.stderr) |
| 1860 | return 1 |
| 1861 | |
| 1862 | _write_preflight_report( |
| 1863 | project_path, |
| 1864 | plan, |
| 1865 | modules, |
| 1866 | status="running", |
| 1867 | ) |
| 1868 | if not _plan_confirmed(plan) and not args.force: |
| 1869 | return fail_preflight( |
| 1870 | [ |
| 1871 | f"enhancement plan is not confirmed: {_plan_path(project_path)} " |
| 1872 | "(run plan, get user confirmation, set status to " |
| 1873 | "\"confirmed\", or pass --force)" |
| 1874 | ] |
| 1875 | ) |
| 1876 | |
| 1877 | try: |
| 1878 | _validate_plan_modules(plan) |
| 1879 | except ValueError as exc: |
| 1880 | return fail_preflight( |
| 1881 | [str(exc)], |
| 1882 | plan_errors=[str(exc)], |
| 1883 | ) |
| 1884 | |
| 1885 | source_delivery = audit_pptx_delivery(source_pptx) |
| 1886 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 1887 | if fatal_delivery_messages: |
| 1888 | return fail_preflight( |
| 1889 | fatal_delivery_messages, |
| 1890 | fatal_delivery_errors=fatal_delivery_messages, |
| 1891 | delivery_check=source_delivery, |
| 1892 | ) |
| 1893 | |
| 1894 | modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {} |
| 1895 | timings_cfg = modules_cfg.get("timings", {}) |
| 1896 | if not isinstance(timings_cfg, dict): |
| 1897 | timings_cfg = {} |
| 1898 | |
| 1899 | if args.narration_padding is not None: |
| 1900 | raw_narration_padding = args.narration_padding |
| 1901 | elif "narration_padding" in timings_cfg: |
| 1902 | raw_narration_padding = timings_cfg["narration_padding"] |
| 1903 | else: |
| 1904 | raw_narration_padding = 0.4 |
| 1905 | |
| 1906 | try: |
| 1907 | if "timings" in modules: |
| 1908 | narration_padding = validate_seconds( |
| 1909 | raw_narration_padding, |
| 1910 | "narration padding", |
| 1911 | allow_zero=True, |
| 1912 | ) |
| 1913 | else: |
| 1914 | narration_padding = 0.4 |
| 1915 | except ValueError as exc: |
| 1916 | return fail_preflight([str(exc)]) |
| 1917 | |
| 1918 | output_path = ( |
| 1919 | Path(args.output).expanduser().resolve() |
| 1920 | if args.output |
| 1921 | else exports_dir / f"{source_pptx.stem}_enhanced.pptx" |
| 1922 | ) |
| 1923 | output_error = _output_path_error( |
| 1924 | project_path, |
| 1925 | project, |
| 1926 | source_pptx, |
| 1927 | output_path, |
| 1928 | ) |
| 1929 | if output_error: |
| 1930 | return fail_preflight([output_error]) |
| 1931 | if output_path.exists() and not args.overwrite: |
| 1932 | return fail_preflight( |
| 1933 | [f"output already exists, pass --overwrite: {output_path}"] |
| 1934 | ) |
| 1935 | |
| 1936 | with tempfile.TemporaryDirectory(prefix="native-enhance-pptx-") as tmp: |
| 1937 | extract_dir = Path(tmp) / "pptx" |
| 1938 | _extract_pptx(source_pptx, extract_dir) |
| 1939 | slides = read_slide_parts(extract_dir) |
| 1940 | |
| 1941 | source_errors = _source_state_errors( |
| 1942 | project_path, |
| 1943 | project, |
| 1944 | source_pptx, |
| 1945 | slides, |
| 1946 | ) |
| 1947 | if source_errors: |
| 1948 | return fail_preflight( |
| 1949 | source_errors, |
| 1950 | source_errors=source_errors, |
| 1951 | delivery_check=source_delivery, |
| 1952 | ) |
| 1953 | |
| 1954 | try: |
| 1955 | resolved_transitions = _resolve_transition_plan( |
| 1956 | project, |
| 1957 | plan, |
| 1958 | slides, |
| 1959 | cli_effect=args.transition, |
| 1960 | cli_duration=args.transition_duration, |
| 1961 | cli_apply_without_audio=args.apply_transition_without_audio, |
| 1962 | ) |
| 1963 | except ValueError as exc: |
| 1964 | return fail_preflight( |
| 1965 | [str(exc)], |
| 1966 | transition_errors=[str(exc)], |
| 1967 | delivery_check=source_delivery, |
| 1968 | ) |
| 1969 | |
| 1970 | readiness = _collect_material_readiness( |
| 1971 | slides, |
| 1972 | notes_dir, |
| 1973 | audio_dir, |
| 1974 | modules, |
| 1975 | ) |
| 1976 | if not readiness.ready: |
| 1977 | return fail_preflight( |
| 1978 | _material_readiness_messages(readiness), |
| 1979 | status=( |
| 1980 | "failed" |
| 1981 | if readiness.module_errors |
| 1982 | else "needs-materials" |
| 1983 | ), |
| 1984 | notes_required="notes" in modules, |
| 1985 | audio_required=( |
| 1986 | "audio" in modules or "timings" in modules |
| 1987 | ), |
| 1988 | delivery_check=source_delivery, |
| 1989 | **_material_readiness_report_fields(readiness), |
| 1990 | ) |
| 1991 | |
| 1992 | if "audio" in modules: |
| 1993 | existing_carriers = _native_audio_carriers(extract_dir, slides) |
| 1994 | if existing_carriers: |
| 1995 | details = "; ".join( |
| 1996 | f"slide {index}: {', '.join(names)}" |
| 1997 | for index, names in sorted(existing_carriers.items()) |
| 1998 | ) |
| 1999 | return fail_preflight( |
| 2000 | [ |
| 2001 | "source PPTX already contains native-enhance narration " |
| 2002 | "carrier(s); refusing to append duplicate audio: " |
| 2003 | + details |
| 2004 | ], |
| 2005 | existing_native_audio_carriers=existing_carriers, |
| 2006 | delivery_check=source_delivery, |
| 2007 | ) |
| 2008 | |
| 2009 | note_parts: set[str] = set() |
| 2010 | audio_exts: set[str] = set() |
| 2011 | audio_count = 0 |
| 2012 | transition_only_count = 0 |
| 2013 | wrote_auto_advance = False |
| 2014 | for slide in slides: |
| 2015 | has_slide_transition = ( |
| 2016 | slide.index in resolved_transitions.slide_enters |
| 2017 | ) |
| 2018 | enter_update = resolved_transitions.slide_enters.get( |
| 2019 | slide.index, |
| 2020 | resolved_transitions.global_enter, |
| 2021 | ) |
| 2022 | note = readiness.note_paths.get(slide.index) |
| 2023 | if "notes" in modules and note: |
| 2024 | notes_part = _apply_notes(extract_dir, slide, note) |
| 2025 | if notes_part is not None: |
| 2026 | note_parts.add(notes_part) |
| 2027 | |
| 2028 | audio = readiness.audio_paths.get(slide.index) |
| 2029 | if "audio" in modules and audio: |
| 2030 | wrote_auto_advance = _apply_audio( |
| 2031 | extract_dir, |
| 2032 | slide, |
| 2033 | audio, |
| 2034 | enter=enter_update, |
| 2035 | timings_enabled="timings" in modules, |
| 2036 | narration_padding=narration_padding, |
| 2037 | audio_duration=readiness.audio_durations.get(slide.index), |
| 2038 | ) or wrote_auto_advance |
| 2039 | audio_exts.add(audio.suffix.lower()) |
| 2040 | audio_count += 1 |
| 2041 | continue |
| 2042 | |
| 2043 | if ( |
| 2044 | has_slide_transition |
| 2045 | or resolved_transitions.apply_without_audio |
| 2046 | ): |
| 2047 | transition_only_count += int( |
| 2048 | _apply_transition_only( |
| 2049 | extract_dir, |
| 2050 | slide, |
| 2051 | enter_update, |
| 2052 | ) |
| 2053 | ) |
| 2054 | |
| 2055 | _update_content_types(extract_dir, note_parts, audio_exts) |
| 2056 | if wrote_auto_advance: |
| 2057 | set_directory_use_timings(extract_dir) |
| 2058 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 2059 | with tempfile.TemporaryDirectory( |
| 2060 | prefix="native-enhance-output-", |
| 2061 | dir=output_path.parent, |
| 2062 | ) as output_tmp: |
| 2063 | candidate_path = Path(output_tmp) / output_path.name |
| 2064 | _zip_dir(extract_dir, candidate_path) |
| 2065 | try: |
| 2066 | validate_pptx_transition_package( |
| 2067 | candidate_path, |
| 2068 | require_use_timings=wrote_auto_advance, |
| 2069 | ) |
| 2070 | except ValueError as exc: |
| 2071 | raise RuntimeError( |
| 2072 | f"PPTX transition package validation failed: {exc}" |
| 2073 | ) from exc |
| 2074 | try: |
| 2075 | validate_pptx_animation_package( |
| 2076 | candidate_path, |
| 2077 | require_supported_effects=False, |
| 2078 | ) |
| 2079 | except ValueError as exc: |
| 2080 | raise RuntimeError( |
| 2081 | f"PPTX animation/timing package validation failed: {exc}" |
| 2082 | ) from exc |
| 2083 | candidate_delivery = audit_pptx_delivery(candidate_path) |
| 2084 | introduced_delivery_errors = _new_delivery_errors( |
| 2085 | source_delivery, |
| 2086 | candidate_delivery, |
| 2087 | ) |
| 2088 | if introduced_delivery_errors: |
| 2089 | raise RuntimeError( |
| 2090 | "PPTX delivery postflight introduced structural error(s): " |
| 2091 | + "; ".join( |
| 2092 | str(issue.get("message") or issue) |
| 2093 | for issue in introduced_delivery_errors |
| 2094 | ) |
| 2095 | ) |
| 2096 | candidate_slides = candidate_delivery.get("slides") |
| 2097 | candidate_slide_count = ( |
| 2098 | candidate_slides.get("count") |
| 2099 | if isinstance(candidate_slides, dict) |
| 2100 | else None |
| 2101 | ) |
| 2102 | if candidate_slide_count != len(slides): |
| 2103 | raise RuntimeError( |
| 2104 | "PPTX delivery postflight slide count changed: " |
| 2105 | f"{candidate_slide_count!r} != {len(slides)}" |
| 2106 | ) |
| 2107 | source_hidden_slides = _delivery_hidden_slide_indices( |
| 2108 | source_delivery |
| 2109 | ) |
| 2110 | candidate_hidden_slides = _delivery_hidden_slide_indices( |
| 2111 | candidate_delivery |
| 2112 | ) |
| 2113 | if ( |
| 2114 | source_hidden_slides is None |
| 2115 | or candidate_hidden_slides is None |
| 2116 | or candidate_hidden_slides != source_hidden_slides |
| 2117 | ): |
| 2118 | raise RuntimeError( |
| 2119 | "PPTX delivery postflight changed or could not verify " |
| 2120 | "hidden-slide state" |
| 2121 | ) |
| 2122 | candidate_path.replace(output_path) |
| 2123 | |
| 2124 | candidate_file = candidate_delivery.get("file") |
| 2125 | if isinstance(candidate_file, dict): |
| 2126 | candidate_file["path"] = str(output_path.resolve()) |
| 2127 | report_status = ( |
| 2128 | "passed-with-advisories" |
| 2129 | if ( |
| 2130 | _delivery_has_findings(source_delivery) |
| 2131 | or _delivery_has_findings(candidate_delivery) |
| 2132 | ) |
| 2133 | else "passed" |
| 2134 | ) |
| 2135 | validation_dir = project_path / "validation" |
| 2136 | validation_dir.mkdir(exist_ok=True) |
| 2137 | _write_json( |
| 2138 | validation_dir / "report.json", |
| 2139 | { |
| 2140 | "schema": VALIDATION_SCHEMA, |
| 2141 | "status": report_status, |
| 2142 | "phase": "postflight", |
| 2143 | "plan_status": plan.get("status") or "missing", |
| 2144 | "enabled_modules": sorted(modules), |
| 2145 | "slide_count": len(slides), |
| 2146 | "applied": { |
| 2147 | "notes": len(note_parts), |
| 2148 | "audio": audio_count, |
| 2149 | "transition_only_slides": transition_only_count, |
| 2150 | "automatic_advance": wrote_auto_advance, |
| 2151 | }, |
| 2152 | "transition_scope": { |
| 2153 | "global_enabled": "transitions" in modules, |
| 2154 | "global_policy": ( |
| 2155 | resolved_transitions.global_enter.policy |
| 2156 | ), |
| 2157 | "apply_without_audio": ( |
| 2158 | resolved_transitions.apply_without_audio |
| 2159 | ), |
| 2160 | "selected_slides": sorted( |
| 2161 | resolved_transitions.slide_enters |
| 2162 | ), |
| 2163 | }, |
| 2164 | "source_delivery_check": source_delivery, |
| 2165 | "output_delivery_check": candidate_delivery, |
| 2166 | "source_delivery_policy": "preserve-baseline", |
| 2167 | "introduced_delivery_errors": introduced_delivery_errors, |
| 2168 | }, |
| 2169 | ) |
| 2170 | |
| 2171 | print(f"Output: {output_path}", file=sys.stderr) |
| 2172 | print(f"Notes applied: {len(note_parts)}", file=sys.stderr) |
| 2173 | print(f"Audio embedded: {audio_count}", file=sys.stderr) |
| 2174 | if transition_only_count: |
| 2175 | print(f"Transition-only slides: {transition_only_count}", file=sys.stderr) |
| 2176 | return 0 |
| 2177 | |
| 2178 | |
| 2179 | def validate_project(args: argparse.Namespace) -> int: |
| 2180 | project_path = Path(args.project_path).expanduser().resolve() |
| 2181 | project = _read_json(project_path / "project.json") |
| 2182 | if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}: |
| 2183 | print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr) |
| 2184 | return 1 |
| 2185 | |
| 2186 | source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path) |
| 2187 | plan = _load_enhancement_plan(project_path) |
| 2188 | modules = _enabled_modules(plan) |
| 2189 | material_modules = {"notes"} if args.materials == "notes" else modules |
| 2190 | source_delivery = audit_pptx_delivery(source_pptx) |
| 2191 | validation_dir = project_path / "validation" |
| 2192 | validation_dir.mkdir(exist_ok=True) |
| 2193 | fatal_delivery_messages = _fatal_source_delivery_messages(source_delivery) |
| 2194 | if fatal_delivery_messages: |
| 2195 | report = _write_preflight_report( |
| 2196 | project_path, |
| 2197 | plan, |
| 2198 | modules, |
| 2199 | status="failed", |
| 2200 | material_scope=args.materials, |
| 2201 | fatal_delivery_errors=fatal_delivery_messages, |
| 2202 | delivery_check=source_delivery, |
| 2203 | ) |
| 2204 | print(json.dumps(report, ensure_ascii=False, indent=2)) |
| 2205 | return 1 |
| 2206 | |
| 2207 | with tempfile.TemporaryDirectory(prefix="native-enhance-validate-") as tmp: |
| 2208 | extract_dir = Path(tmp) / "pptx" |
| 2209 | _extract_pptx(source_pptx, extract_dir) |
| 2210 | slides = read_slide_parts(extract_dir) |
| 2211 | existing_carriers = ( |
| 2212 | _native_audio_carriers(extract_dir, slides) |
| 2213 | if "audio" in modules |
| 2214 | else {} |
| 2215 | ) |
| 2216 | |
| 2217 | source_errors = _source_state_errors( |
| 2218 | project_path, |
| 2219 | project, |
| 2220 | source_pptx, |
| 2221 | slides, |
| 2222 | ) |
| 2223 | try: |
| 2224 | _validate_plan_modules(plan) |
| 2225 | except ValueError as exc: |
| 2226 | plan_errors = [str(exc)] |
| 2227 | else: |
| 2228 | plan_errors = [] |
| 2229 | try: |
| 2230 | resolved_transitions = ( |
| 2231 | _resolve_transition_plan( |
| 2232 | project, |
| 2233 | plan, |
| 2234 | slides, |
| 2235 | ) |
| 2236 | if not plan_errors |
| 2237 | else None |
| 2238 | ) |
| 2239 | except ValueError as exc: |
| 2240 | transition_errors = [str(exc)] |
| 2241 | transition_slide_count = 0 |
| 2242 | transition_scope = None |
| 2243 | else: |
| 2244 | transition_errors = [] |
| 2245 | if resolved_transitions is None: |
| 2246 | transition_slide_count = 0 |
| 2247 | transition_scope = None |
| 2248 | else: |
| 2249 | transition_slide_count = len( |
| 2250 | resolved_transitions.slide_enters |
| 2251 | ) |
| 2252 | transition_scope = { |
| 2253 | "global_enabled": "transitions" in modules, |
| 2254 | "global_policy": resolved_transitions.global_enter.policy, |
| 2255 | "apply_without_audio": ( |
| 2256 | resolved_transitions.apply_without_audio |
| 2257 | ), |
| 2258 | "selected_slides": sorted( |
| 2259 | resolved_transitions.slide_enters |
| 2260 | ), |
| 2261 | } |
| 2262 | readiness = _collect_material_readiness( |
| 2263 | slides, |
| 2264 | notes_dir, |
| 2265 | audio_dir, |
| 2266 | modules, |
| 2267 | required_modules=material_modules, |
| 2268 | ) |
| 2269 | hard_failure = bool( |
| 2270 | source_errors |
| 2271 | or readiness.module_errors |
| 2272 | or plan_errors |
| 2273 | or transition_errors |
| 2274 | or existing_carriers |
| 2275 | ) |
| 2276 | if hard_failure: |
| 2277 | status = "failed" |
| 2278 | elif not readiness.ready: |
| 2279 | status = "needs-materials" |
| 2280 | else: |
| 2281 | status = ( |
| 2282 | "passed-with-advisories" |
| 2283 | if _delivery_has_findings(source_delivery) |
| 2284 | else "passed" |
| 2285 | ) |
| 2286 | report = _write_preflight_report( |
| 2287 | project_path, |
| 2288 | plan, |
| 2289 | modules, |
| 2290 | status=status, |
| 2291 | material_scope=args.materials, |
| 2292 | slide_count=len(slides), |
| 2293 | notes_required="notes" in material_modules, |
| 2294 | audio_required=( |
| 2295 | "audio" in material_modules or "timings" in material_modules |
| 2296 | ), |
| 2297 | plan_errors=plan_errors, |
| 2298 | transition_errors=transition_errors, |
| 2299 | transition_override_count=transition_slide_count, |
| 2300 | transition_scope=transition_scope, |
| 2301 | source_errors=source_errors, |
| 2302 | existing_native_audio_carriers=existing_carriers, |
| 2303 | source_delivery_policy="preserve-baseline", |
| 2304 | delivery_check=source_delivery, |
| 2305 | **_material_readiness_report_fields(readiness), |
| 2306 | ) |
| 2307 | print(json.dumps(report, ensure_ascii=False, indent=2)) |
| 2308 | if hard_failure: |
| 2309 | return 1 |
| 2310 | return 0 if readiness.ready else 2 |
| 2311 | |
| 2312 | |
| 2313 | def build_parser() -> argparse.ArgumentParser: |
| 2314 | parser = argparse.ArgumentParser( |
| 2315 | description="Create/apply a native existing-PPTX enhancement project without SVG conversion.", |
| 2316 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 2317 | ) |
| 2318 | subparsers = parser.add_subparsers(dest="command", required=True) |
| 2319 | |
| 2320 | init = subparsers.add_parser("init", help="create a native PPTX enhancement project") |
| 2321 | init.add_argument("source_pptx", help="source .pptx file") |
| 2322 | init.add_argument("--name", default=None, help="ASCII project name slug") |
| 2323 | init.add_argument("--project-dir", default=None, help="explicit project directory") |
| 2324 | init.add_argument("--projects-root", default="projects", help="projects root (default: projects)") |
| 2325 | init.add_argument( |
| 2326 | "--transition", |
| 2327 | default="fade", |
| 2328 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2329 | help="PowerPoint-native effect; old names are compatibility inputs", |
| 2330 | ) |
| 2331 | init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5) |
| 2332 | init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4) |
| 2333 | init.add_argument( |
| 2334 | "--apply-transition-without-audio", |
| 2335 | action="store_true", |
| 2336 | help=( |
| 2337 | "when audio is enabled, draft transitions for slides without audio " |
| 2338 | "as well" |
| 2339 | ), |
| 2340 | ) |
| 2341 | init.set_defaults(func=init_project) |
| 2342 | |
| 2343 | plan = subparsers.add_parser("plan", help="draft an enhancement module plan") |
| 2344 | plan.add_argument("project_path", help="native enhancement project directory") |
| 2345 | plan.add_argument( |
| 2346 | "--transition", |
| 2347 | default=None, |
| 2348 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2349 | help=( |
| 2350 | "replace the saved global PowerPoint-native effect; omitted values " |
| 2351 | "preserve the current plan" |
| 2352 | ), |
| 2353 | ) |
| 2354 | plan.add_argument( |
| 2355 | "--transition-duration", |
| 2356 | type=_positive_seconds_arg, |
| 2357 | default=None, |
| 2358 | ) |
| 2359 | plan.add_argument( |
| 2360 | "--narration-padding", |
| 2361 | type=_non_negative_seconds_arg, |
| 2362 | default=None, |
| 2363 | ) |
| 2364 | plan.add_argument( |
| 2365 | "--apply-transition-without-audio", |
| 2366 | action="store_true", |
| 2367 | default=None, |
| 2368 | help=( |
| 2369 | "when audio is enabled, include page transitions for slides " |
| 2370 | "without audio" |
| 2371 | ), |
| 2372 | ) |
| 2373 | plan.set_defaults(func=plan_project) |
| 2374 | |
| 2375 | apply = subparsers.add_parser( |
| 2376 | "apply", |
| 2377 | help="patch confirmed notes/audio/timings/transitions into a copied PPTX", |
| 2378 | ) |
| 2379 | apply.add_argument("project_path", help="native enhancement project directory") |
| 2380 | apply.add_argument("-o", "--output", default=None, help="output .pptx path") |
| 2381 | apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists") |
| 2382 | apply.add_argument( |
| 2383 | "--transition", |
| 2384 | default=None, |
| 2385 | choices=[*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS, "none"], |
| 2386 | help="PowerPoint-native effect; old names are compatibility inputs", |
| 2387 | ) |
| 2388 | apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None) |
| 2389 | apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None) |
| 2390 | apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan") |
| 2391 | apply.add_argument( |
| 2392 | "--apply-transition-without-audio", |
| 2393 | action="store_true", |
| 2394 | help=( |
| 2395 | "when audio is enabled, also write page transitions on slides " |
| 2396 | "without audio" |
| 2397 | ), |
| 2398 | ) |
| 2399 | apply.set_defaults(func=apply_project) |
| 2400 | |
| 2401 | validate = subparsers.add_parser( |
| 2402 | "validate", |
| 2403 | help="check source integrity, plan semantics, and material readiness", |
| 2404 | ) |
| 2405 | validate.add_argument("project_path", help="native enhancement project directory") |
| 2406 | validate.add_argument( |
| 2407 | "--materials", |
| 2408 | choices=("all", "notes"), |
| 2409 | default="all", |
| 2410 | help=( |
| 2411 | "required material scope: all enabled modules, or notes only " |
| 2412 | "before narration audio exists (default: all)" |
| 2413 | ), |
| 2414 | ) |
| 2415 | validate.set_defaults(func=validate_project) |
| 2416 | return parser |
| 2417 | |
| 2418 | |
| 2419 | def _record_preflight_exception( |
| 2420 | args: argparse.Namespace, |
| 2421 | exc: Exception, |
| 2422 | ) -> None: |
| 2423 | command = str(args.command) |
| 2424 | try: |
| 2425 | project_path = Path(args.project_path).expanduser().resolve() |
| 2426 | project = _read_json(project_path / "project.json") |
| 2427 | except (OSError, ValueError, KeyError, json.JSONDecodeError) as project_exc: |
| 2428 | if ( |
| 2429 | project_path.is_dir() |
| 2430 | and (project_path / "validation").is_dir() |
| 2431 | ): |
| 2432 | try: |
| 2433 | _write_preflight_report( |
| 2434 | project_path, |
| 2435 | {}, |
| 2436 | set(), |
| 2437 | status="failed", |
| 2438 | errors=[f"{command} aborted: {exc}"], |
| 2439 | project_errors=[ |
| 2440 | f"unable to read project.json: {project_exc}" |
| 2441 | ], |
| 2442 | ) |
| 2443 | except OSError: |
| 2444 | pass |
| 2445 | return |
| 2446 | if project.get("schema") not in { |
| 2447 | PROJECT_SCHEMA, |
| 2448 | *LEGACY_PROJECT_SCHEMAS, |
| 2449 | }: |
| 2450 | return |
| 2451 | plan_errors: list[str] = [] |
| 2452 | try: |
| 2453 | plan = _load_enhancement_plan(project_path) |
| 2454 | except (OSError, ValueError, json.JSONDecodeError) as plan_exc: |
| 2455 | plan = {} |
| 2456 | plan_errors.append(f"unable to read enhancement plan: {plan_exc}") |
| 2457 | try: |
| 2458 | _write_preflight_report( |
| 2459 | project_path, |
| 2460 | plan, |
| 2461 | set() if plan_errors else _enabled_modules(plan), |
| 2462 | status="failed", |
| 2463 | errors=[f"{command} aborted: {exc}"], |
| 2464 | plan_errors=plan_errors, |
| 2465 | ) |
| 2466 | except OSError: |
| 2467 | return |
| 2468 | |
| 2469 | |
| 2470 | def main(argv: list[str] | None = None) -> int: |
| 2471 | require_skill_integrity() |
| 2472 | parser = build_parser() |
| 2473 | args = parser.parse_args(argv) |
| 2474 | try: |
| 2475 | return args.func(args) |
| 2476 | except ( |
| 2477 | OSError, |
| 2478 | RuntimeError, |
| 2479 | ValueError, |
| 2480 | zipfile.BadZipFile, |
| 2481 | ET.ParseError, |
| 2482 | ) as exc: |
| 2483 | if args.command in {"apply", "validate"}: |
| 2484 | _record_preflight_exception(args, exc) |
| 2485 | print(f"error: {exc}", file=sys.stderr) |
| 2486 | return 1 |
| 2487 | |
| 2488 | |
| 2489 | if __name__ == "__main__": |
| 2490 | raise SystemExit(main()) |
| 2491 |