"""Narration audio discovery and PPTX XML helpers.""" from __future__ import annotations import base64 import json import re import subprocess from pathlib import Path MEDIA_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/media" AUDIO_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio" IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" AUDIO_CONTENT_TYPES = { ".m4a": "audio/mp4", ".mp3": "audio/mpeg", ".wav": "audio/wav", } NARRATION_EXTENSIONS = tuple(AUDIO_CONTENT_TYPES.keys()) TRANSPARENT_PNG_BYTES = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/" "lBf7WQAAAABJRU5ErkJggg==" ) def _normalize_title(title: str) -> str: text = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", title.strip()) return re.sub(r"_+", "_", text).strip("_").lower() def _leading_number(text: str) -> int | None: match = re.match(r"^(\d{1,3})", text.strip()) return int(match.group(1)) if match else None def find_narration_files(audio_dir: Path, svg_files: list[Path]) -> dict[str, Path]: """Return `{svg_stem: audio_path}` matched by exact stem, normalized stem, or index.""" if not audio_dir.exists() or not audio_dir.is_dir(): return {} audio_files = [ path for path in sorted(audio_dir.iterdir()) if path.is_file() and path.suffix.lower() in NARRATION_EXTENSIONS ] exact = {path.stem: path for path in audio_files} normalized: dict[str, Path] = {} numbered: dict[int, Path] = {} for path in audio_files: normalized.setdefault(_normalize_title(path.stem), path) number = _leading_number(path.stem) if number is not None: numbered.setdefault(number, path) matched: dict[str, Path] = {} for index, svg in enumerate(svg_files, 1): stem = svg.stem if stem in exact: matched[stem] = exact[stem] continue norm = _normalize_title(stem) if norm in normalized: matched[stem] = normalized[norm] continue if index in numbered: matched[stem] = numbered[index] return matched def probe_audio_duration(audio_path: Path) -> float | None: """Return duration in seconds using ffprobe when available.""" try: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", str(audio_path), ], check=True, capture_output=True, text=True, encoding="utf-8", ) data = json.loads(result.stdout or "{}") duration = float(data.get("format", {}).get("duration", 0)) return duration if duration > 0 else None except Exception: return None def next_shape_id(slide_xml: str) -> int: ids = [int(value) for value in re.findall(r']*\sid="(\d+)"', slide_xml)] return max(ids, default=1) + 1 def create_audio_pic_xml( shape_id: int, shape_name: str, audio_rid: str, media_rid: str, poster_rid: str, ) -> str: """Create a tiny audio picture shape carrying narration media.""" return f''' ''' def _next_timing_id(slide_xml: str) -> int: ids = [int(value) for value in re.findall(r']*\sid="(\d+)"', slide_xml)] return max(ids, default=1) + 1 def _create_audio_timing_xml(shape_id: int, ctn_id: int) -> str: return f''' ''' def inject_narration( slide_xml: str, *, shape_id: int, shape_name: str, audio_rid: str, media_rid: str, poster_rid: str, ) -> str: """Inject a hidden narration media shape and slide-entry autoplay timing.""" audio_pic_xml = create_audio_pic_xml( shape_id=shape_id, shape_name=shape_name, audio_rid=audio_rid, media_rid=media_rid, poster_rid=poster_rid, ) slide_xml = slide_xml.replace("", audio_pic_xml + "\n ", 1) audio_timing_xml = _create_audio_timing_xml(shape_id, _next_timing_id(slide_xml)) if "" not in slide_xml: timing_xml = f''' {audio_timing_xml} ''' return slide_xml.replace("", timing_xml + "\n", 1) pattern = re.compile(r'(]*>\s*)', re.S) if pattern.search(slide_xml): return pattern.sub(r"\1\n " + audio_timing_xml, slide_xml, count=1) return slide_xml.replace("", audio_timing_xml + "\n ", 1) def apply_recorded_timing( slide_xml: str, *, advance_after: float, transition_duration: float, transition_effect: str | None = "fade", ) -> str: """Set slide auto-advance timing so exported video follows narration length.""" adv_ms = max(1, int(advance_after * 1000)) dur_ms = max(1, int(transition_duration * 1000)) transition_match = re.search(r"]*>", slide_xml) if transition_match: tag = transition_match.group(0) if "advTm=" in tag: new_tag = re.sub(r'\sadvTm="[^"]*"', f' advTm="{adv_ms}"', tag, count=1) else: new_tag = tag[:-1] + f' advTm="{adv_ms}">' return slide_xml[:transition_match.start()] + new_tag + slide_xml[transition_match.end():] effect = transition_effect or "fade" transition_xml = f''' ''' if "" in slide_xml: return slide_xml.replace("", transition_xml + "\n ", 1) return slide_xml.replace("", transition_xml + "\n", 1)