Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,696 @@
"""Narration audio discovery and PPTX XML helpers."""
from __future__ import annotations
import base64
import json
import re
import subprocess
from collections.abc import Iterable
from pathlib import Path
from xml.etree import ElementTree as ET
from pptx_transitions import (
AdvanceUpdate,
EnterUpdate,
MAX_OOXML_UNSIGNED_INT,
P14_NS,
PML_NS,
apply_slide_motion_xml,
parse_source_xml,
read_slide_transition_xml,
serialize_source_xml,
validate_seconds,
)
DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
MARKUP_COMPATIBILITY_NS = (
"http://schemas.openxmlformats.org/markup-compatibility/2006"
)
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())
DEFAULT_NARRATION_START_FLOOR = 0.8
AUDIO_MARKER_SIZE_EMU = 457200 # 48 SVG px
AUDIO_MARKER_OFF_CANVAS_EMU = -AUDIO_MARKER_SIZE_EMU
AUDIO_MARKER_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABsUlEQVR4nO2aQZaDIAyG"
"cd4cQRdzgHqxeiy9mB5gFvUO7Qon0gAhJEDf+G/a92rJ9ycEqNaYS3XVSQ94uz+esWu"
"2ZRCLKzIQBdqnXDPsL+dA+8Qx88UJpAHPHTfJsRY4Jmo1yBUoCZ8Sj2SgNHxK3KiBWv"
"DU+EEDteGtQhxeA63AW/l4WMuolNa5zx4DNaCd/XXuWfAY15sBTXguOJTL9501GlG50"
"Ovc/xpjzDjtP+5nqj0gkXEL7763OhmQmj4S4FZY1iGnaAUkwQMxTlWouoz65CYBq4LV"
"0cTU6VMgw8frOO3e6273x3Nbhq7JCmCCVYDTqDkDMOuUajdnIFWXgdq6DEgLNm5oGbV"
"qzoBPcOmES+qxkW3L0FE2s1BWJDa5cdqjm5gxf7ddmqyAC4+dQq1EDYzTTpq3mTFO56"
"KTAam7xpJGsOxDTtUpJGEEZhw7lRb5SWlNcJs8dJx+q4DkwwcEJLsiLh86hTRNGMM3g"
"nFVXUYlGt1rQLsKqfLxBCvQiokQR3QK1TYRi0/qgVomKHHJTVzaBDUeC0rzBnBqoljL"
"qFY1OOP+3yf1PpX+r8TH6wW14c3/7xdFRAAAAABJRU5ErkJggg=="
)
for _prefix, _uri in (
("p", PML_NS),
("a", DRAWINGML_NS),
("r", RELATIONSHIPS_NS),
("p14", P14_NS),
):
try:
ET.register_namespace(_prefix, _uri)
except (AttributeError, ValueError):
pass
def _qn(namespace: str, tag: str) -> str:
return f"{{{namespace}}}{tag}"
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: dict[str, list[Path]] = {}
normalized: dict[str, list[Path]] = {}
numbered: dict[int, list[Path]] = {}
for path in audio_files:
exact.setdefault(path.stem, []).append(path)
normalized.setdefault(_normalize_title(path.stem), []).append(path)
number = _leading_number(path.stem)
if number is not None:
numbered.setdefault(number, []).append(path)
matched: dict[str, Path] = {}
claimed_by: dict[Path, str] = {}
for index, svg in enumerate(svg_files, 1):
stem = svg.stem
candidates = exact.get(stem)
if not candidates:
candidates = normalized.get(_normalize_title(stem))
if not candidates:
candidates = numbered.get(index)
if not candidates:
continue
if len(candidates) > 1:
names = ", ".join(path.name for path in candidates)
raise ValueError(
f"multiple narration audio files match slide {stem!r}: "
f"{names}; keep exactly one supported file for this slide"
)
candidate = candidates[0]
previous_stem = claimed_by.get(candidate)
if previous_stem is not None:
raise ValueError(
f"narration audio file {candidate.name!r} matches multiple slides: "
f"{previous_stem!r}, {stem!r}; provide one distinct audio file "
"per slide"
)
matched[stem] = candidate
claimed_by[candidate] = stem
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:
"""Return the next slide-local non-visual shape id."""
root = parse_source_xml(slide_xml)
if root.tag != _qn(PML_NS, "sld"):
raise ValueError("narration source XML root must be p:sld")
ids = _numeric_ids(
root.iter(_qn(PML_NS, "cNvPr")),
"shape",
minimum=1,
)
next_id = max(ids, default=1) + 1
if next_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError("narration source has no available shape identifiers")
return next_id
def _create_audio_pic_element(
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
) -> ET.Element:
pic = ET.Element(_qn(PML_NS, "pic"))
nv_pic_pr = ET.SubElement(pic, _qn(PML_NS, "nvPicPr"))
c_nv_pr = ET.SubElement(
nv_pic_pr,
_qn(PML_NS, "cNvPr"),
{"id": str(shape_id), "name": shape_name},
)
ET.SubElement(
c_nv_pr,
_qn(DRAWINGML_NS, "hlinkClick"),
{
_qn(RELATIONSHIPS_NS, "id"): "",
"action": "ppaction://media",
},
)
c_nv_pic_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "cNvPicPr"))
ET.SubElement(
c_nv_pic_pr,
_qn(DRAWINGML_NS, "picLocks"),
{"noChangeAspect": "1"},
)
nv_pr = ET.SubElement(nv_pic_pr, _qn(PML_NS, "nvPr"))
ET.SubElement(
nv_pr,
_qn(DRAWINGML_NS, "audioFile"),
{_qn(RELATIONSHIPS_NS, "link"): audio_rid},
)
ext_list = ET.SubElement(nv_pr, _qn(PML_NS, "extLst"))
extension = ET.SubElement(
ext_list,
_qn(PML_NS, "ext"),
{"uri": "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"},
)
ET.SubElement(
extension,
_qn(P14_NS, "media"),
{_qn(RELATIONSHIPS_NS, "embed"): media_rid},
)
blip_fill = ET.SubElement(pic, _qn(PML_NS, "blipFill"))
ET.SubElement(
blip_fill,
_qn(DRAWINGML_NS, "blip"),
{_qn(RELATIONSHIPS_NS, "embed"): poster_rid},
)
stretch = ET.SubElement(blip_fill, _qn(DRAWINGML_NS, "stretch"))
ET.SubElement(stretch, _qn(DRAWINGML_NS, "fillRect"))
shape_properties = ET.SubElement(pic, _qn(PML_NS, "spPr"))
transform = ET.SubElement(shape_properties, _qn(DRAWINGML_NS, "xfrm"))
ET.SubElement(
transform,
_qn(DRAWINGML_NS, "off"),
{
"x": str(AUDIO_MARKER_OFF_CANVAS_EMU),
"y": str(AUDIO_MARKER_OFF_CANVAS_EMU),
},
)
ET.SubElement(
transform,
_qn(DRAWINGML_NS, "ext"),
{
"cx": str(AUDIO_MARKER_SIZE_EMU),
"cy": str(AUDIO_MARKER_SIZE_EMU),
},
)
geometry = ET.SubElement(
shape_properties,
_qn(DRAWINGML_NS, "prstGeom"),
{"prst": "rect"},
)
ET.SubElement(geometry, _qn(DRAWINGML_NS, "avLst"))
return pic
def create_audio_pic_xml(
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
) -> str:
"""Create an off-canvas audio picture shape carrying narration media."""
element = _create_audio_pic_element(
shape_id,
shape_name,
audio_rid,
media_rid,
poster_rid,
)
return ET.tostring(element, encoding="unicode")
def _numeric_ids(
elements: Iterable[ET.Element],
label: str,
*,
minimum: int = 0,
maximum: int = MAX_OOXML_UNSIGNED_INT,
) -> list[int]:
ids: list[int] = []
seen: set[int] = set()
for element in elements:
raw_id = element.get("id")
try:
numeric_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ValueError(f"narration source has invalid {label} id: {raw_id!r}") from exc
if numeric_id < minimum:
raise ValueError(
f"narration source has {label} id below {minimum}: {numeric_id}"
)
if numeric_id > maximum:
raise ValueError(
f"narration source has {label} id above {maximum}: {numeric_id}"
)
if numeric_id in seen:
raise ValueError(f"narration source has duplicate {label} id: {numeric_id}")
ids.append(numeric_id)
seen.add(numeric_id)
return ids
def narration_lead_in_seconds(
transition_duration: float,
*,
start_floor: float = DEFAULT_NARRATION_START_FLOOR,
) -> float:
"""Return silence after a slide transition before narration starts."""
transition_seconds = validate_seconds(
transition_duration,
"narration transition duration",
allow_zero=True,
)
floor_seconds = validate_seconds(
start_floor,
"narration start floor",
allow_zero=True,
)
return max(0.0, floor_seconds - transition_seconds)
def _create_audio_timing_element(
shape_id: int,
ctn_id: int,
start_delay_ms: int,
) -> ET.Element:
audio = ET.Element(_qn(PML_NS, "audio"))
media_node = ET.SubElement(
audio,
_qn(PML_NS, "cMediaNode"),
{"vol": "80000"},
)
time_node = ET.SubElement(
media_node,
_qn(PML_NS, "cTn"),
{"id": str(ctn_id), "fill": "hold", "display": "0"},
)
start_conditions = ET.SubElement(time_node, _qn(PML_NS, "stCondLst"))
ET.SubElement(
start_conditions,
_qn(PML_NS, "cond"),
{"delay": str(start_delay_ms)},
)
target = ET.SubElement(media_node, _qn(PML_NS, "tgtEl"))
ET.SubElement(target, _qn(PML_NS, "spTgt"), {"spid": str(shape_id)})
return audio
def _direct_child(parent: ET.Element, tag: str, label: str) -> ET.Element:
children = [child for child in parent if child.tag == tag]
if len(children) != 1:
raise ValueError(
f"narration source must contain exactly one direct {label}; found {len(children)}"
)
return children[0]
def _existing_timing_root(timing: ET.Element) -> ET.Element:
children = list(timing)
for tag, label in (
(_qn(PML_NS, "tnLst"), "p:tnLst"),
(_qn(PML_NS, "bldLst"), "p:bldLst"),
(_qn(PML_NS, "extLst"), "p:extLst"),
):
if sum(child.tag == tag for child in children) > 1:
raise ValueError(f"narration source timing has multiple {label} elements")
node_list = _direct_child(timing, _qn(PML_NS, "tnLst"), "p:timing/p:tnLst")
node_index = children.index(node_list)
for tag, label in (
(_qn(PML_NS, "bldLst"), "p:bldLst"),
(_qn(PML_NS, "extLst"), "p:extLst"),
):
sibling = next((child for child in children if child.tag == tag), None)
if sibling is not None and node_index > children.index(sibling):
raise ValueError(f"narration source p:tnLst must precede {label}")
timing_roots = [
element
for element in node_list.iter(_qn(PML_NS, "cTn"))
if element.get("nodeType") == "tmRoot"
]
if len(timing_roots) != 1:
raise ValueError(
"narration source timing must contain exactly one tmRoot; "
f"found {len(timing_roots)}"
)
return timing_roots[0]
def _new_timing(audio_timing: ET.Element, root_id: int) -> ET.Element:
timing = ET.Element(_qn(PML_NS, "timing"))
node_list = ET.SubElement(timing, _qn(PML_NS, "tnLst"))
parallel = ET.SubElement(node_list, _qn(PML_NS, "par"))
timing_root = ET.SubElement(
parallel,
_qn(PML_NS, "cTn"),
{
"id": str(root_id),
"dur": "indefinite",
"restart": "never",
"nodeType": "tmRoot",
},
)
child_nodes = ET.SubElement(timing_root, _qn(PML_NS, "childTnLst"))
child_nodes.append(audio_timing)
return timing
def _root_extension_index(slide: ET.Element) -> int | None:
extension_lists = [
index
for index, child in enumerate(slide)
if child.tag == _qn(PML_NS, "extLst")
]
if len(extension_lists) > 1:
raise ValueError("narration source has multiple root p:extLst elements")
if extension_lists and extension_lists[0] != len(slide) - 1:
raise ValueError("narration source root p:extLst is not the last slide child")
return extension_lists[0] if extension_lists else None
def _validate_root_timing_position(slide: ET.Element, timing: ET.Element) -> None:
children = list(slide)
timing_index = children.index(timing)
for tag, label in (
(_qn(PML_NS, "cSld"), "p:cSld"),
(_qn(PML_NS, "clrMapOvr"), "p:clrMapOvr"),
):
siblings = [index for index, child in enumerate(children) if child.tag == tag]
if len(siblings) > 1:
raise ValueError(f"narration source has multiple root {label} elements")
if siblings and siblings[0] > timing_index:
raise ValueError(f"narration source root p:timing must follow {label}")
extension_index = _root_extension_index(slide)
if extension_index is not None and timing_index > extension_index:
raise ValueError("narration source root p:timing must precede p:extLst")
def _insert_root_timing(slide: ET.Element, timing: ET.Element) -> None:
extension_index = _root_extension_index(slide)
insert_at = extension_index if extension_index is not None else len(slide)
slide.insert(insert_at, timing)
def _animation_timing_branches(
slide: ET.Element,
) -> tuple[ET.Element | None, list[ET.Element]]:
"""Return the root timing anchor and every active/fallback timing branch."""
direct = [
child for child in slide
if child.tag == _qn(PML_NS, "timing")
]
alternates: list[tuple[ET.Element, list[ET.Element]]] = []
for child in slide:
if child.tag != _qn(MARKUP_COMPATIBILITY_NS, "AlternateContent"):
continue
timings = [
timing
for branch in list(child)
for timing in list(branch)
if timing.tag == _qn(PML_NS, "timing")
]
if timings:
alternates.append((child, timings))
if direct and alternates:
raise ValueError(
"narration source contains both direct and AlternateContent timing"
)
if len(direct) > 1 or len(alternates) > 1:
raise ValueError("narration source has multiple root animation timings")
if direct:
return direct[0], direct
if alternates:
anchor, timings = alternates[0]
if len(timings) != 2:
raise ValueError(
"narration source animation AlternateContent must contain "
"one Choice and one Fallback timing"
)
return anchor, timings
nested = list(slide.iter(_qn(PML_NS, "timing")))
if nested:
raise ValueError(
"narration source contains unsupported non-root p:timing"
)
return None, []
def inject_narration(
slide_xml: str,
*,
shape_id: int,
shape_name: str,
audio_rid: str,
media_rid: str,
poster_rid: str,
start_delay: float = 0.0,
) -> str:
"""Inject a hidden narration shape and delayed slide-entry autoplay timing."""
if isinstance(shape_id, bool) or not isinstance(shape_id, int) or shape_id <= 0:
raise ValueError("narration shape_id must be a positive integer")
if shape_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration shape_id exceeds the OOXML unsigned-integer limit: "
f"{shape_id}"
)
start_delay_seconds = validate_seconds(
start_delay,
"narration start delay",
allow_zero=True,
)
start_delay_ms = round(start_delay_seconds * 1000)
if start_delay_ms > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration start delay exceeds the OOXML unsigned-integer limit: "
f"{start_delay_ms} ms"
)
root = parse_source_xml(slide_xml)
if root.tag != _qn(PML_NS, "sld"):
raise ValueError("narration source XML root must be p:sld")
common_slide_data = _direct_child(root, _qn(PML_NS, "cSld"), "p:sld/p:cSld")
shape_tree = _direct_child(
common_slide_data,
_qn(PML_NS, "spTree"),
"p:cSld/p:spTree",
)
shape_ids = _numeric_ids(
root.iter(_qn(PML_NS, "cNvPr")),
"shape",
minimum=1,
)
if shape_id in shape_ids:
raise ValueError(f"narration shape id already exists on slide: {shape_id}")
timing_anchor, timing_branches = _animation_timing_branches(root)
timing_id_sets = [
_numeric_ids(timing.iter(_qn(PML_NS, "cTn")), "timing node")
for timing in timing_branches
]
timing_ids = [
timing_id
for timing_set in timing_id_sets
for timing_id in timing_set
]
next_timing_id = max(timing_ids, default=0) + 1
if next_timing_id > MAX_OOXML_UNSIGNED_INT:
raise ValueError("narration source has no available timing node identifiers")
if not timing_branches and next_timing_id + 1 > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration source has no identifiers available for a new timing root"
)
audio_picture = _create_audio_pic_element(
shape_id,
shape_name,
audio_rid,
media_rid,
poster_rid,
)
shape_tree.append(audio_picture)
if timing_branches:
if timing_anchor is None:
raise AssertionError("timing branches lost their root anchor")
_validate_root_timing_position(root, timing_anchor)
for timing in timing_branches:
timing_root = _existing_timing_root(timing)
child_nodes = _direct_child(
timing_root,
_qn(PML_NS, "childTnLst"),
"tmRoot/p:childTnLst",
)
child_nodes.append(
_create_audio_timing_element(
shape_id,
next_timing_id,
start_delay_ms,
)
)
else:
audio_timing = _create_audio_timing_element(
shape_id,
next_timing_id + 1,
start_delay_ms,
)
_insert_root_timing(root, _new_timing(audio_timing, next_timing_id))
return serialize_source_xml(root, slide_xml).decode("utf-8")
def read_narration_start_delay_xml(slide_xml: str) -> int:
"""Return the latest embedded narration picture's autoplay delay in ms."""
root = parse_source_xml(slide_xml)
if root.tag != _qn(PML_NS, "sld"):
raise ValueError("narration source XML root must be p:sld")
audio_shape_properties: list[ET.Element] = []
for picture in root.iter(_qn(PML_NS, "pic")):
if not any(
element.tag == _qn(DRAWINGML_NS, "audioFile")
for element in picture.iter()
):
continue
properties = list(picture.iter(_qn(PML_NS, "cNvPr")))
if len(properties) != 1:
raise ValueError(
"narration audio picture must contain exactly one p:cNvPr"
)
audio_shape_properties.extend(properties)
if not audio_shape_properties:
raise ValueError("narration source has no embedded audio picture")
narration_shape_id = max(
_numeric_ids(
audio_shape_properties,
"narration audio shape",
minimum=1,
)
)
delays: list[int] = []
for audio in root.iter(_qn(PML_NS, "audio")):
media_node = audio.find(_qn(PML_NS, "cMediaNode"))
if media_node is None:
continue
target = media_node.find(
f"{_qn(PML_NS, 'tgtEl')}/{_qn(PML_NS, 'spTgt')}"
)
if target is None or target.get("spid") != str(narration_shape_id):
continue
time_node = _direct_child(
media_node,
_qn(PML_NS, "cTn"),
"p:audio/p:cMediaNode/p:cTn",
)
start_conditions = _direct_child(
time_node,
_qn(PML_NS, "stCondLst"),
"p:cTn/p:stCondLst",
)
condition = _direct_child(
start_conditions,
_qn(PML_NS, "cond"),
"p:stCondLst/p:cond",
)
raw_delay = condition.get("delay")
try:
delay = int(raw_delay)
except (TypeError, ValueError) as exc:
raise ValueError(
f"narration timing has invalid start delay: {raw_delay!r}"
) from exc
if delay < 0 or delay > MAX_OOXML_UNSIGNED_INT:
raise ValueError(
"narration timing start delay is outside the OOXML "
f"unsigned-integer range: {delay}"
)
delays.append(delay)
if not delays:
raise ValueError("narration source has no autoplay timing for its audio picture")
if len(set(delays)) != 1:
raise ValueError(
"narration source timing branches disagree on autoplay delay: "
f"{sorted(set(delays))}"
)
return delays[0]
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."""
summary = read_slide_transition_xml(slide_xml)
if summary.logical_count:
enter = EnterUpdate(policy="preserve")
elif transition_effect is None or transition_effect == "none":
enter = EnterUpdate(policy="none")
else:
enter = EnterUpdate(
policy="replace",
effect=transition_effect,
duration=transition_duration,
)
updated, _uses_timings = apply_slide_motion_xml(
slide_xml,
enter=enter,
advance=AdvanceUpdate(mode="narration", after=advance_after),
)
return updated