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