969 lines
35 KiB
Python
969 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PPT Master - Native Existing PPTX Enhancer
|
|
|
|
Create and apply a lightweight project for enhancing an existing PPTX without
|
|
entering the SVG generation pipeline or modifying the original file.
|
|
|
|
V1 enhancement modules: speaker notes, narration audio, slide auto-advance
|
|
timings, and optional page transitions.
|
|
|
|
Usage:
|
|
python3 scripts/native_enhance_pptx.py init <source.pptx> [--name project_name]
|
|
python3 scripts/native_enhance_pptx.py apply <project_path> [--output output.pptx]
|
|
python3 scripts/native_enhance_pptx.py validate <project_path>
|
|
|
|
Examples:
|
|
python3 scripts/native_enhance_pptx.py init projects/source.pptx --name fire_station
|
|
python3 scripts/native_enhance_pptx.py apply projects/fire_station_native_enhance_20260626
|
|
python3 scripts/native_enhance_pptx.py validate projects/fire_station_native_enhance_20260626
|
|
|
|
Dependencies:
|
|
ffprobe for audio-duration-based auto-advance timings.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree as ET
|
|
|
|
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
|
from pptx_animations import ( # noqa: E402
|
|
object_animation_fingerprint,
|
|
validate_pptx_animation_package,
|
|
)
|
|
from pptx_transitions import ( # noqa: E402
|
|
AdvanceUpdate,
|
|
EnterUpdate,
|
|
TRANSITIONS,
|
|
apply_slide_motion_xml,
|
|
set_directory_use_timings,
|
|
validate_pptx_transition_package,
|
|
validate_seconds,
|
|
)
|
|
from svg_to_pptx.pptx_package.builder import ( # noqa: E402
|
|
_add_default_content_type,
|
|
_append_relationship,
|
|
_ensure_notes_master,
|
|
)
|
|
from svg_to_pptx.pptx_package.narration import ( # noqa: E402
|
|
AUDIO_CONTENT_TYPES,
|
|
AUDIO_MARKER_PNG_BYTES,
|
|
AUDIO_REL_TYPE,
|
|
IMAGE_REL_TYPE,
|
|
MEDIA_REL_TYPE,
|
|
NARRATION_EXTENSIONS,
|
|
inject_narration,
|
|
next_shape_id,
|
|
probe_audio_duration,
|
|
)
|
|
from svg_to_pptx.pptx_package.notes import ( # noqa: E402
|
|
create_notes_slide_rels_xml,
|
|
create_notes_slide_xml,
|
|
markdown_to_plain_text,
|
|
)
|
|
|
|
configure_utf8_stdio()
|
|
|
|
|
|
PROJECT_SCHEMA = "native_pptx_enhancement_project.v1"
|
|
LEGACY_PROJECT_SCHEMAS = {"native_narration_pptx_project.v1"}
|
|
NOTES_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
|
|
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
CONTENT_TYPE_NOTES_SLIDE = (
|
|
"application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
|
|
)
|
|
CONTENT_TYPE_NOTES_MASTER = (
|
|
"application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"
|
|
)
|
|
CONTENT_TYPE_THEME = "application/vnd.openxmlformats-officedocument.theme+xml"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SlidePart:
|
|
index: int
|
|
part_name: str
|
|
slide_number: int
|
|
|
|
|
|
def _sanitize_slug(value: str) -> str:
|
|
slug = re.sub(r"[^0-9A-Za-z_-]+", "_", value).strip("_")
|
|
return slug or "native_enhance"
|
|
|
|
|
|
def _positive_seconds_arg(value: str) -> float:
|
|
try:
|
|
return validate_seconds(value, "transition duration", allow_zero=False)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
|
|
|
|
def _non_negative_seconds_arg(value: str) -> float:
|
|
try:
|
|
return validate_seconds(value, "narration padding", allow_zero=True)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
|
|
|
|
def _read_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _write_json(path: Path, data: dict) -> None:
|
|
path.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
try:
|
|
path.resolve().relative_to(parent.resolve())
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _archive_source_pptx(source_pptx: Path, archived_pptx: Path, projects_root: Path) -> str:
|
|
"""Move project-local sources into the project; copy external sources."""
|
|
archived_pptx.parent.mkdir(parents=True, exist_ok=True)
|
|
if source_pptx.resolve() == archived_pptx.resolve():
|
|
return "reuse"
|
|
if _is_relative_to(source_pptx, projects_root):
|
|
shutil.move(str(source_pptx), str(archived_pptx))
|
|
return "move"
|
|
shutil.copy2(source_pptx, archived_pptx)
|
|
return "copy"
|
|
|
|
|
|
def _relationship_file_for_part(extract_dir: Path, part_name: str) -> Path:
|
|
part = Path(part_name)
|
|
return extract_dir / part.parent / "_rels" / f"{part.name}.rels"
|
|
|
|
|
|
def _ensure_rels_file(path: Path) -> None:
|
|
if path.exists():
|
|
return
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
f'<Relationships xmlns="{PACKAGE_REL_NS}">\n</Relationships>',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _remove_relationships_by_type(rels_path: Path, rel_type: str) -> None:
|
|
if not rels_path.exists():
|
|
return
|
|
content = rels_path.read_text(encoding="utf-8")
|
|
content = re.sub(
|
|
rf'\s*<Relationship\b[^>]*\bType="{re.escape(rel_type)}"[^>]*/>',
|
|
"",
|
|
content,
|
|
)
|
|
rels_path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def _target_to_part(target: str) -> str:
|
|
target = target.lstrip("/")
|
|
if target.startswith("ppt/"):
|
|
return target
|
|
return f"ppt/{target}"
|
|
|
|
|
|
def _slide_number_from_part(part_name: str) -> int:
|
|
match = re.search(r"slide(\d+)\.xml$", part_name)
|
|
if not match:
|
|
raise ValueError(f"Unsupported slide part name: {part_name}")
|
|
return int(match.group(1))
|
|
|
|
|
|
def read_slide_parts(extract_dir: Path) -> list[SlidePart]:
|
|
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
|
rels_path = extract_dir / "ppt" / "_rels" / "presentation.xml.rels"
|
|
if not presentation_path.exists() or not rels_path.exists():
|
|
raise RuntimeError("PPTX package is missing presentation.xml or its relationships")
|
|
|
|
rels_root = ET.parse(rels_path).getroot()
|
|
rels: dict[str, str] = {}
|
|
for rel in rels_root.findall(f"{{{PACKAGE_REL_NS}}}Relationship"):
|
|
rel_id = rel.attrib.get("Id")
|
|
target = rel.attrib.get("Target")
|
|
if rel_id and target:
|
|
rels[rel_id] = target
|
|
|
|
presentation_root = ET.parse(presentation_path).getroot()
|
|
slide_parts: list[SlidePart] = []
|
|
for index, slide_id in enumerate(
|
|
presentation_root.findall(f".//{{{PRESENTATION_NS}}}sldId"),
|
|
1,
|
|
):
|
|
rel_id = slide_id.attrib.get(f"{{{REL_NS}}}id")
|
|
if not rel_id or rel_id not in rels:
|
|
continue
|
|
part_name = _target_to_part(rels[rel_id])
|
|
slide_parts.append(
|
|
SlidePart(
|
|
index=index,
|
|
part_name=part_name,
|
|
slide_number=_slide_number_from_part(part_name),
|
|
)
|
|
)
|
|
if not slide_parts:
|
|
raise RuntimeError("No slides found in presentation.xml")
|
|
return slide_parts
|
|
|
|
|
|
def _zip_dir(source_dir: Path, output_path: Path) -> None:
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for path in sorted(source_dir.rglob("*")):
|
|
if path.is_file():
|
|
zf.write(path, path.relative_to(source_dir).as_posix())
|
|
|
|
|
|
def _extract_pptx(source_pptx: Path, extract_dir: Path) -> None:
|
|
with zipfile.ZipFile(source_pptx, "r") as zf:
|
|
zf.extractall(extract_dir)
|
|
|
|
|
|
def _note_path(notes_dir: Path, index: int) -> Path | None:
|
|
candidates = [
|
|
notes_dir / f"{index:03d}.md",
|
|
notes_dir / f"{index:02d}.md",
|
|
notes_dir / f"{index}.md",
|
|
notes_dir / f"slide{index:03d}.md",
|
|
notes_dir / f"slide{index:02d}.md",
|
|
notes_dir / f"slide{index}.md",
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _audio_path(audio_dir: Path, index: int) -> Path | None:
|
|
stems = [
|
|
f"{index:03d}",
|
|
f"{index:02d}",
|
|
str(index),
|
|
f"slide{index:03d}",
|
|
f"slide{index:02d}",
|
|
f"slide{index}",
|
|
]
|
|
for stem in stems:
|
|
for ext in NARRATION_EXTENSIONS:
|
|
candidate = audio_dir / f"{stem}{ext}"
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _add_override(content_types: str, part_name: str, content_type: str) -> str:
|
|
if re.search(
|
|
rf'<Override\b[^>]*\bPartName="/{re.escape(part_name)}"[^>]*/>',
|
|
content_types,
|
|
):
|
|
return content_types
|
|
override = f' <Override PartName="/{part_name}" ContentType="{content_type}"/>'
|
|
return content_types.replace("</Types>", override + "\n</Types>")
|
|
|
|
|
|
def _add_notes_content_types(content_types: str, note_indices: set[int]) -> str:
|
|
content_types = _add_override(content_types, "ppt/theme/theme2.xml", CONTENT_TYPE_THEME)
|
|
content_types = _add_override(
|
|
content_types,
|
|
"ppt/notesMasters/notesMaster1.xml",
|
|
CONTENT_TYPE_NOTES_MASTER,
|
|
)
|
|
for index in sorted(note_indices):
|
|
content_types = _add_override(
|
|
content_types,
|
|
f"ppt/notesSlides/notesSlide{index}.xml",
|
|
CONTENT_TYPE_NOTES_SLIDE,
|
|
)
|
|
return content_types
|
|
|
|
|
|
def _apply_notes(extract_dir: Path, slide: SlidePart, note_md: Path) -> None:
|
|
notes_text = markdown_to_plain_text(note_md.read_text(encoding="utf-8"))
|
|
if not notes_text:
|
|
return
|
|
|
|
_ensure_notes_master(extract_dir)
|
|
notes_dir = extract_dir / "ppt" / "notesSlides"
|
|
notes_dir.mkdir(parents=True, exist_ok=True)
|
|
notes_xml_path = notes_dir / f"notesSlide{slide.index}.xml"
|
|
notes_xml_path.write_text(
|
|
create_notes_slide_xml(slide.slide_number, notes_text),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
notes_rels_dir = notes_dir / "_rels"
|
|
notes_rels_dir.mkdir(parents=True, exist_ok=True)
|
|
notes_rels_path = notes_rels_dir / f"notesSlide{slide.index}.xml.rels"
|
|
notes_rels_path.write_text(
|
|
create_notes_slide_rels_xml(slide.slide_number),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
slide_rels = _relationship_file_for_part(extract_dir, slide.part_name)
|
|
_ensure_rels_file(slide_rels)
|
|
_remove_relationships_by_type(slide_rels, NOTES_REL_TYPE)
|
|
_append_relationship(
|
|
slide_rels,
|
|
NOTES_REL_TYPE,
|
|
f"../notesSlides/notesSlide{slide.index}.xml",
|
|
)
|
|
|
|
|
|
def _apply_audio(
|
|
extract_dir: Path,
|
|
slide: SlidePart,
|
|
audio_path: Path,
|
|
*,
|
|
enter: EnterUpdate,
|
|
timings_enabled: bool,
|
|
narration_padding: float,
|
|
) -> bool:
|
|
media_dir = extract_dir / "ppt" / "media"
|
|
media_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
ext = audio_path.suffix.lower()
|
|
media_name = f"native_enhance_audio_{slide.index:03d}{ext}"
|
|
shutil.copy2(audio_path, media_dir / media_name)
|
|
|
|
poster_name = "native_enhance_audio_poster.png"
|
|
poster_path = media_dir / poster_name
|
|
if not poster_path.exists():
|
|
poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES)
|
|
|
|
slide_rels = _relationship_file_for_part(extract_dir, slide.part_name)
|
|
_ensure_rels_file(slide_rels)
|
|
media_rid = _append_relationship(slide_rels, MEDIA_REL_TYPE, f"../media/{media_name}")
|
|
audio_rid = _append_relationship(slide_rels, AUDIO_REL_TYPE, f"../media/{media_name}")
|
|
poster_rid = _append_relationship(slide_rels, IMAGE_REL_TYPE, f"../media/{poster_name}")
|
|
|
|
slide_xml_path = extract_dir / slide.part_name
|
|
slide_xml = slide_xml_path.read_text(encoding="utf-8")
|
|
source_animation_fingerprint = object_animation_fingerprint(slide_xml)
|
|
shape_id = next_shape_id(slide_xml)
|
|
slide_xml = inject_narration(
|
|
slide_xml,
|
|
shape_id=shape_id,
|
|
shape_name=media_name,
|
|
audio_rid=audio_rid,
|
|
media_rid=media_rid,
|
|
poster_rid=poster_rid,
|
|
)
|
|
|
|
advance = AdvanceUpdate(mode="preserve")
|
|
if timings_enabled:
|
|
duration = probe_audio_duration(audio_path)
|
|
if duration is None:
|
|
raise RuntimeError(f"Unable to read narration duration with ffprobe: {audio_path}")
|
|
advance = AdvanceUpdate(
|
|
mode="narration",
|
|
after=duration + narration_padding,
|
|
)
|
|
|
|
wrote_advance = False
|
|
if enter.policy != "preserve" or timings_enabled:
|
|
slide_xml, wrote_advance = apply_slide_motion_xml(
|
|
slide_xml,
|
|
enter=enter,
|
|
advance=advance,
|
|
)
|
|
if object_animation_fingerprint(slide_xml) != source_animation_fingerprint:
|
|
raise RuntimeError(
|
|
f"Slide {slide.index} object animations changed while adding narration"
|
|
)
|
|
slide_xml_path.write_text(slide_xml, encoding="utf-8")
|
|
return timings_enabled and wrote_advance
|
|
|
|
|
|
def _update_content_types(extract_dir: Path, note_indices: set[int], audio_exts: set[str]) -> None:
|
|
content_types_path = extract_dir / "[Content_Types].xml"
|
|
content_types = content_types_path.read_text(encoding="utf-8")
|
|
if note_indices:
|
|
content_types = _add_notes_content_types(content_types, note_indices)
|
|
for ext in sorted(audio_exts):
|
|
content_type = AUDIO_CONTENT_TYPES.get(ext)
|
|
if content_type:
|
|
content_types = _add_default_content_type(content_types, ext, content_type)
|
|
if audio_exts:
|
|
content_types = _add_default_content_type(content_types, "png", "image/png")
|
|
content_types_path.write_text(content_types, encoding="utf-8")
|
|
|
|
|
|
def _project_paths(project_path: Path) -> tuple[Path, Path, Path, Path]:
|
|
project = _read_json(project_path / "project.json")
|
|
source_pptx = project_path / project["source_pptx"]
|
|
notes_dir = project_path / project["notes_dir"]
|
|
audio_dir = project_path / project["audio_dir"]
|
|
exports_dir = project_path / project["exports_dir"]
|
|
return source_pptx, notes_dir, audio_dir, exports_dir
|
|
|
|
|
|
def _plan_path(project_path: Path) -> Path:
|
|
return project_path / "analysis" / "enhancement_plan.json"
|
|
|
|
|
|
def _load_enhancement_plan(project_path: Path) -> dict:
|
|
path = _plan_path(project_path)
|
|
if not path.exists():
|
|
return {}
|
|
return _read_json(path)
|
|
|
|
|
|
def _enabled_modules(plan: dict) -> set[str]:
|
|
modules = plan.get("modules")
|
|
if not isinstance(modules, dict):
|
|
return {"notes", "audio", "timings", "transitions"}
|
|
enabled: set[str] = set()
|
|
for name, config in modules.items():
|
|
if isinstance(config, dict) and config.get("enabled") is True:
|
|
enabled.add(str(name))
|
|
return enabled
|
|
|
|
|
|
def _resolve_enter_update(
|
|
*,
|
|
cli_effect: str | None,
|
|
configured_effect: object,
|
|
transitions_enabled: bool,
|
|
duration: float,
|
|
) -> EnterUpdate:
|
|
if cli_effect is None and not transitions_enabled:
|
|
if configured_effect == "none":
|
|
return EnterUpdate(policy="none", effect=None, duration=duration)
|
|
return EnterUpdate(policy="preserve", duration=duration)
|
|
|
|
effect = cli_effect if cli_effect is not None else configured_effect
|
|
if not isinstance(effect, str):
|
|
raise ValueError(f"transition effect must be a string: {effect!r}")
|
|
if effect == "none":
|
|
return EnterUpdate(policy="none", effect=None, duration=duration)
|
|
if effect not in TRANSITIONS:
|
|
valid = ", ".join(sorted(TRANSITIONS))
|
|
raise ValueError(
|
|
f"unknown transition effect {effect!r}; valid effects: {valid}, none"
|
|
)
|
|
|
|
return EnterUpdate(policy="replace", effect=effect, duration=duration)
|
|
|
|
|
|
def _plan_confirmed(plan: dict) -> bool:
|
|
return plan.get("status") == "confirmed"
|
|
|
|
|
|
def _build_enhancement_plan(
|
|
project: dict,
|
|
*,
|
|
slide_count: int,
|
|
notes_count: int,
|
|
audio_count: int,
|
|
transition: str,
|
|
transition_duration: float,
|
|
narration_padding: float,
|
|
apply_transition_without_audio: bool,
|
|
) -> dict:
|
|
return {
|
|
"schema": "native_pptx_enhancement_plan.v1",
|
|
"status": "draft",
|
|
"source_pptx": project.get("source_pptx"),
|
|
"slide_count": slide_count,
|
|
"modules": {
|
|
"notes": {
|
|
"enabled": True,
|
|
"requires_confirmation": True,
|
|
"status": "ready" if notes_count == slide_count else "needs_notes",
|
|
"coverage": {"ready": notes_count, "total": slide_count},
|
|
},
|
|
"audio": {
|
|
"enabled": True,
|
|
"requires_confirmation": True,
|
|
"status": "ready" if audio_count == slide_count else "needs_audio",
|
|
"coverage": {"ready": audio_count, "total": slide_count},
|
|
},
|
|
"timings": {
|
|
"enabled": True,
|
|
"requires_confirmation": True,
|
|
"status": "ready" if audio_count == slide_count else "blocked_until_audio",
|
|
"source": "audio_duration",
|
|
"narration_padding": narration_padding,
|
|
},
|
|
"transitions": {
|
|
"enabled": transition != "none",
|
|
"requires_confirmation": True,
|
|
"status": "ready",
|
|
"effect": transition,
|
|
"duration": transition_duration,
|
|
"apply_without_audio": apply_transition_without_audio,
|
|
},
|
|
},
|
|
"not_in_v1": [
|
|
"object_animation",
|
|
"visible_watermark",
|
|
"footer_or_logo_insertion",
|
|
"background_music",
|
|
"media_compression",
|
|
],
|
|
}
|
|
|
|
|
|
def init_project(args: argparse.Namespace) -> int:
|
|
source_pptx = Path(args.source_pptx).expanduser().resolve()
|
|
if not source_pptx.exists() or source_pptx.suffix.lower() != ".pptx":
|
|
print(f"error: expected an existing .pptx file: {source_pptx}", file=sys.stderr)
|
|
return 1
|
|
|
|
stem = _sanitize_slug(args.name or source_pptx.stem)
|
|
date = datetime.now().strftime("%Y%m%d")
|
|
project_path = (
|
|
Path(args.project_dir).expanduser().resolve()
|
|
if args.project_dir
|
|
else Path(args.projects_root).expanduser().resolve() / f"{stem}_native_enhance_{date}"
|
|
)
|
|
if project_path.exists() and any(project_path.iterdir()):
|
|
print(f"error: project directory already exists and is not empty: {project_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
for dirname in ("sources", "analysis", "notes", "audio", "exports", "validation"):
|
|
(project_path / dirname).mkdir(parents=True, exist_ok=True)
|
|
|
|
archived_pptx = project_path / "sources" / source_pptx.name
|
|
projects_root = Path(args.projects_root).expanduser().resolve()
|
|
source_import_mode = _archive_source_pptx(source_pptx, archived_pptx, projects_root)
|
|
|
|
source_md = project_path / "sources" / f"{source_pptx.stem}.md"
|
|
ppt_to_md = _SCRIPTS_DIR / "source_to_md" / "ppt_to_md.py"
|
|
result = subprocess.run(
|
|
[sys.executable, str(ppt_to_md), str(archived_pptx), "-o", str(source_md)],
|
|
check=False,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(result.stderr or result.stdout, file=sys.stderr)
|
|
return result.returncode
|
|
|
|
with tempfile.TemporaryDirectory(prefix="native-enhance-intake-") as tmp:
|
|
extract_dir = Path(tmp) / "pptx"
|
|
_extract_pptx(archived_pptx, extract_dir)
|
|
slide_parts = read_slide_parts(extract_dir)
|
|
|
|
slide_index = {
|
|
"schema": "native_pptx_enhancement_slide_index.v1",
|
|
"source_pptx": f"sources/{source_pptx.name}",
|
|
"slide_count": len(slide_parts),
|
|
"slides": [
|
|
{
|
|
"index": slide.index,
|
|
"note_file": f"notes/{slide.index:03d}.md",
|
|
"audio_stem": f"{slide.index:03d}",
|
|
"part_name": slide.part_name,
|
|
"slide_number": slide.slide_number,
|
|
}
|
|
for slide in slide_parts
|
|
],
|
|
}
|
|
_write_json(project_path / "analysis" / "slide_index.json", slide_index)
|
|
|
|
project = {
|
|
"schema": PROJECT_SCHEMA,
|
|
"kind": "native_pptx_enhancement",
|
|
"modules": ["notes", "audio", "timings", "transitions"],
|
|
"source_pptx": f"sources/{source_pptx.name}",
|
|
"source_markdown": f"sources/{source_pptx.stem}.md",
|
|
"source_import": {
|
|
"mode": source_import_mode,
|
|
"original_path": str(source_pptx),
|
|
},
|
|
"slide_count": len(slide_parts),
|
|
"notes_dir": "notes",
|
|
"audio_dir": "audio",
|
|
"exports_dir": "exports",
|
|
"transition": {
|
|
"effect": args.transition,
|
|
"duration": args.transition_duration,
|
|
},
|
|
"audio": {
|
|
"provider": "",
|
|
"voice": "",
|
|
"rate": "",
|
|
},
|
|
}
|
|
_write_json(project_path / "project.json", project)
|
|
plan = _build_enhancement_plan(
|
|
project,
|
|
slide_count=len(slide_parts),
|
|
notes_count=0,
|
|
audio_count=0,
|
|
transition=args.transition,
|
|
transition_duration=args.transition_duration,
|
|
narration_padding=args.narration_padding,
|
|
apply_transition_without_audio=args.apply_transition_without_audio,
|
|
)
|
|
_write_json(_plan_path(project_path), plan)
|
|
|
|
print(f"Project: {project_path}", file=sys.stderr)
|
|
print(f"Slides: {len(slide_parts)}", file=sys.stderr)
|
|
print(f"Source import: {source_import_mode}", file=sys.stderr)
|
|
print(f"Source markdown: {source_md}", file=sys.stderr)
|
|
print(f"Draft enhancement plan: {_plan_path(project_path)}", file=sys.stderr)
|
|
print(
|
|
"Review the plan with the user and set status to \"confirmed\" before generating notes/audio/applying.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
def plan_project(args: argparse.Namespace) -> int:
|
|
project_path = Path(args.project_path).expanduser().resolve()
|
|
project = _read_json(project_path / "project.json")
|
|
if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}:
|
|
print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path)
|
|
with tempfile.TemporaryDirectory(prefix="native-enhance-plan-") as tmp:
|
|
extract_dir = Path(tmp) / "pptx"
|
|
_extract_pptx(source_pptx, extract_dir)
|
|
slides = read_slide_parts(extract_dir)
|
|
|
|
notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None)
|
|
audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None)
|
|
plan = _build_enhancement_plan(
|
|
project,
|
|
slide_count=len(slides),
|
|
notes_count=notes_count,
|
|
audio_count=audio_count,
|
|
transition=args.transition,
|
|
transition_duration=args.transition_duration,
|
|
narration_padding=args.narration_padding,
|
|
apply_transition_without_audio=args.apply_transition_without_audio,
|
|
)
|
|
_write_json(_plan_path(project_path), plan)
|
|
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
print(f"Plan written: {_plan_path(project_path)}", file=sys.stderr)
|
|
print(
|
|
"Confirm by editing status to \"confirmed\" after user approval, then run apply.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
def apply_project(args: argparse.Namespace) -> int:
|
|
project_path = Path(args.project_path).expanduser().resolve()
|
|
project = _read_json(project_path / "project.json")
|
|
if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}:
|
|
print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
source_pptx, notes_dir, audio_dir, exports_dir = _project_paths(project_path)
|
|
transition_cfg = project.get("transition", {}) if isinstance(project.get("transition"), dict) else {}
|
|
plan = _load_enhancement_plan(project_path)
|
|
if not _plan_confirmed(plan) and not args.force:
|
|
print(
|
|
f"error: enhancement plan is not confirmed: {_plan_path(project_path)} "
|
|
"(run plan, get user confirmation, set status to \"confirmed\", or pass --force)",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
modules = _enabled_modules(plan)
|
|
modules_cfg = plan.get("modules") if isinstance(plan.get("modules"), dict) else {}
|
|
transitions_cfg = modules_cfg.get("transitions", {})
|
|
if not isinstance(transitions_cfg, dict):
|
|
transitions_cfg = {}
|
|
timings_cfg = modules_cfg.get("timings", {})
|
|
if not isinstance(timings_cfg, dict):
|
|
timings_cfg = {}
|
|
|
|
if "effect" in transitions_cfg:
|
|
configured_effect = transitions_cfg["effect"]
|
|
elif "effect" in transition_cfg:
|
|
configured_effect = transition_cfg["effect"]
|
|
else:
|
|
configured_effect = "fade"
|
|
|
|
if args.transition_duration is not None:
|
|
raw_transition_duration = args.transition_duration
|
|
elif "duration" in transitions_cfg:
|
|
raw_transition_duration = transitions_cfg["duration"]
|
|
elif "duration" in transition_cfg:
|
|
raw_transition_duration = transition_cfg["duration"]
|
|
else:
|
|
raw_transition_duration = 0.5
|
|
|
|
if args.narration_padding is not None:
|
|
raw_narration_padding = args.narration_padding
|
|
elif "narration_padding" in timings_cfg:
|
|
raw_narration_padding = timings_cfg["narration_padding"]
|
|
else:
|
|
raw_narration_padding = 0.4
|
|
|
|
try:
|
|
if args.transition is not None or "transitions" in modules:
|
|
transition_duration = validate_seconds(
|
|
raw_transition_duration,
|
|
"transition duration",
|
|
allow_zero=False,
|
|
)
|
|
else:
|
|
transition_duration = 0.5
|
|
if "timings" in modules:
|
|
narration_padding = validate_seconds(
|
|
raw_narration_padding,
|
|
"narration padding",
|
|
allow_zero=True,
|
|
)
|
|
else:
|
|
narration_padding = 0.4
|
|
enter_update = _resolve_enter_update(
|
|
cli_effect=args.transition,
|
|
configured_effect=configured_effect,
|
|
transitions_enabled="transitions" in modules,
|
|
duration=transition_duration,
|
|
)
|
|
except ValueError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
apply_transition_without_audio = (
|
|
args.apply_transition_without_audio
|
|
or bool(transitions_cfg.get("apply_without_audio"))
|
|
)
|
|
|
|
output_path = (
|
|
Path(args.output).expanduser().resolve()
|
|
if args.output
|
|
else exports_dir / f"{source_pptx.stem}_enhanced.pptx"
|
|
)
|
|
if output_path.exists() and not args.overwrite:
|
|
print(f"error: output already exists, pass --overwrite: {output_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
with tempfile.TemporaryDirectory(prefix="native-enhance-pptx-") as tmp:
|
|
extract_dir = Path(tmp) / "pptx"
|
|
_extract_pptx(source_pptx, extract_dir)
|
|
slides = read_slide_parts(extract_dir)
|
|
|
|
note_indices: set[int] = set()
|
|
audio_exts: set[str] = set()
|
|
audio_count = 0
|
|
transition_only_count = 0
|
|
wrote_auto_advance = False
|
|
for slide in slides:
|
|
note = _note_path(notes_dir, slide.index)
|
|
if "notes" in modules and note:
|
|
_apply_notes(extract_dir, slide, note)
|
|
note_indices.add(slide.index)
|
|
|
|
audio = _audio_path(audio_dir, slide.index)
|
|
if "audio" in modules and audio:
|
|
wrote_auto_advance = _apply_audio(
|
|
extract_dir,
|
|
slide,
|
|
audio,
|
|
enter=enter_update,
|
|
timings_enabled="timings" in modules,
|
|
narration_padding=narration_padding,
|
|
) or wrote_auto_advance
|
|
audio_exts.add(audio.suffix.lower())
|
|
audio_count += 1
|
|
continue
|
|
|
|
if (
|
|
apply_transition_without_audio
|
|
and enter_update.policy != "preserve"
|
|
):
|
|
slide_xml_path = extract_dir / slide.part_name
|
|
slide_xml = slide_xml_path.read_text(encoding="utf-8")
|
|
source_animation_fingerprint = object_animation_fingerprint(
|
|
slide_xml
|
|
)
|
|
slide_xml, _uses_timings = apply_slide_motion_xml(
|
|
slide_xml,
|
|
enter=enter_update,
|
|
advance=AdvanceUpdate(mode="preserve"),
|
|
)
|
|
if (
|
|
object_animation_fingerprint(slide_xml)
|
|
!= source_animation_fingerprint
|
|
):
|
|
raise RuntimeError(
|
|
f"Slide {slide.index} object animations changed while "
|
|
"updating the transition"
|
|
)
|
|
slide_xml_path.write_text(
|
|
slide_xml,
|
|
encoding="utf-8",
|
|
)
|
|
transition_only_count += 1
|
|
|
|
_update_content_types(extract_dir, note_indices, audio_exts)
|
|
if wrote_auto_advance:
|
|
set_directory_use_timings(extract_dir)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="native-enhance-output-",
|
|
dir=output_path.parent,
|
|
) as output_tmp:
|
|
candidate_path = Path(output_tmp) / output_path.name
|
|
_zip_dir(extract_dir, candidate_path)
|
|
try:
|
|
validate_pptx_transition_package(
|
|
candidate_path,
|
|
require_use_timings=wrote_auto_advance,
|
|
)
|
|
except ValueError as exc:
|
|
raise RuntimeError(
|
|
f"PPTX transition package validation failed: {exc}"
|
|
) from exc
|
|
try:
|
|
validate_pptx_animation_package(
|
|
candidate_path,
|
|
require_supported_effects=False,
|
|
)
|
|
except ValueError as exc:
|
|
raise RuntimeError(
|
|
f"PPTX animation/timing package validation failed: {exc}"
|
|
) from exc
|
|
candidate_path.replace(output_path)
|
|
|
|
print(f"Output: {output_path}", file=sys.stderr)
|
|
print(f"Notes applied: {len(note_indices)}", file=sys.stderr)
|
|
print(f"Audio embedded: {audio_count}", file=sys.stderr)
|
|
if transition_only_count:
|
|
print(f"Transition-only slides: {transition_only_count}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
def validate_project(args: argparse.Namespace) -> int:
|
|
project_path = Path(args.project_path).expanduser().resolve()
|
|
project = _read_json(project_path / "project.json")
|
|
if project.get("schema") not in {PROJECT_SCHEMA, *LEGACY_PROJECT_SCHEMAS}:
|
|
print(f"error: not a native PPTX enhancement project: {project_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
source_pptx, notes_dir, audio_dir, _exports_dir = _project_paths(project_path)
|
|
with tempfile.TemporaryDirectory(prefix="native-enhance-validate-") as tmp:
|
|
extract_dir = Path(tmp) / "pptx"
|
|
_extract_pptx(source_pptx, extract_dir)
|
|
slides = read_slide_parts(extract_dir)
|
|
|
|
plan = _load_enhancement_plan(project_path)
|
|
modules = _enabled_modules(plan)
|
|
notes_count = sum(1 for slide in slides if _note_path(notes_dir, slide.index) is not None)
|
|
audio_count = sum(1 for slide in slides if _audio_path(audio_dir, slide.index) is not None)
|
|
missing_notes = (
|
|
[slide.index for slide in slides if _note_path(notes_dir, slide.index) is None]
|
|
if "notes" in modules
|
|
else []
|
|
)
|
|
missing_audio = (
|
|
[slide.index for slide in slides if _audio_path(audio_dir, slide.index) is None]
|
|
if "audio" in modules
|
|
else []
|
|
)
|
|
report = {
|
|
"schema": "native_pptx_enhancement_validation.v1",
|
|
"slide_count": len(slides),
|
|
"plan_status": plan.get("status") or "missing",
|
|
"enabled_modules": sorted(modules),
|
|
"notes_required": "notes" in modules,
|
|
"audio_required": "audio" in modules,
|
|
"notes_count": notes_count,
|
|
"audio_count": audio_count,
|
|
"missing_notes": missing_notes,
|
|
"missing_audio": missing_audio,
|
|
}
|
|
validation_dir = project_path / "validation"
|
|
validation_dir.mkdir(exist_ok=True)
|
|
_write_json(validation_dir / "report.json", report)
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0 if not missing_notes and not missing_audio else 2
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Create/apply a native existing-PPTX enhancement project without SVG conversion.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
init = subparsers.add_parser("init", help="create a native PPTX enhancement project")
|
|
init.add_argument("source_pptx", help="source .pptx file")
|
|
init.add_argument("--name", default=None, help="ASCII project name slug")
|
|
init.add_argument("--project-dir", default=None, help="explicit project directory")
|
|
init.add_argument("--projects-root", default="projects", help="projects root (default: projects)")
|
|
init.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()))
|
|
init.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
|
|
init.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
|
|
init.add_argument(
|
|
"--apply-transition-without-audio",
|
|
action="store_true",
|
|
help="draft the plan with page transitions for slides without audio",
|
|
)
|
|
init.set_defaults(func=init_project)
|
|
|
|
plan = subparsers.add_parser("plan", help="draft an enhancement module plan")
|
|
plan.add_argument("project_path", help="native enhancement project directory")
|
|
plan.add_argument("--transition", default="fade", choices=sorted(TRANSITIONS.keys()) + ["none"])
|
|
plan.add_argument("--transition-duration", type=_positive_seconds_arg, default=0.5)
|
|
plan.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=0.4)
|
|
plan.add_argument(
|
|
"--apply-transition-without-audio",
|
|
action="store_true",
|
|
help="include page transitions for slides without audio",
|
|
)
|
|
plan.set_defaults(func=plan_project)
|
|
|
|
apply = subparsers.add_parser("apply", help="patch notes/audio/timings into a copied PPTX")
|
|
apply.add_argument("project_path", help="native narration project directory")
|
|
apply.add_argument("-o", "--output", default=None, help="output .pptx path")
|
|
apply.add_argument("--overwrite", action="store_true", help="overwrite output if it exists")
|
|
apply.add_argument("--transition", default=None, choices=sorted(TRANSITIONS.keys()) + ["none"])
|
|
apply.add_argument("--transition-duration", type=_positive_seconds_arg, default=None)
|
|
apply.add_argument("--narration-padding", type=_non_negative_seconds_arg, default=None)
|
|
apply.add_argument("--force", action="store_true", help="apply without a confirmed enhancement plan")
|
|
apply.add_argument(
|
|
"--apply-transition-without-audio",
|
|
action="store_true",
|
|
help="also write page transitions on slides that do not have audio",
|
|
)
|
|
apply.set_defaults(func=apply_project)
|
|
|
|
validate = subparsers.add_parser("validate", help="check notes/audio coverage")
|
|
validate.add_argument("project_path", help="native narration project directory")
|
|
validate.set_defaults(func=validate_project)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|