返回 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 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
2491 lines PYTHON