Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""PPTX package assembly helpers for SVG-to-PPTX export."""
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
"""Slide dimensions, format detection, EMU conversion, and constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from ..canvas_contract import (
|
||||
CanvasContractError,
|
||||
ProjectViewBox,
|
||||
parse_project_viewbox,
|
||||
read_project_viewbox,
|
||||
require_consistent_project_viewboxes,
|
||||
)
|
||||
|
||||
# Import project utility modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
try:
|
||||
from project_utils import get_project_info
|
||||
from config import CANVAS_FORMATS
|
||||
except ImportError:
|
||||
CANVAS_FORMATS = {
|
||||
'ppt169': {'name': 'PPT 16:9', 'dimensions': '1280×720', 'viewbox': '0 0 1280 720'},
|
||||
}
|
||||
|
||||
def get_project_info(path: str) -> dict:
|
||||
return {'format': 'unknown', 'name': Path(path).name}
|
||||
|
||||
# EMU conversion constants
|
||||
EMU_PER_INCH = 914400
|
||||
EMU_PER_PIXEL = EMU_PER_INCH / 96
|
||||
|
||||
# XML namespaces
|
||||
NAMESPACES = {
|
||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
||||
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
|
||||
}
|
||||
|
||||
# Register namespaces for ElementTree output
|
||||
for prefix, uri in NAMESPACES.items():
|
||||
ET.register_namespace(prefix, uri)
|
||||
|
||||
|
||||
def get_slide_dimensions(
|
||||
canvas_format: str,
|
||||
custom_pixels: tuple[int, int] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Get slide dimensions in EMU units.
|
||||
|
||||
Args:
|
||||
canvas_format: Canvas format key (e.g. 'ppt169').
|
||||
custom_pixels: Optional custom pixel dimensions override.
|
||||
|
||||
Returns:
|
||||
(width_emu, height_emu) tuple.
|
||||
"""
|
||||
if custom_pixels:
|
||||
width_px, height_px = custom_pixels
|
||||
else:
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
canvas_format = 'ppt169'
|
||||
|
||||
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
|
||||
match = re.match(r'(\d+)[×x](\d+)', dimensions)
|
||||
if match:
|
||||
width_px = int(match.group(1))
|
||||
height_px = int(match.group(2))
|
||||
else:
|
||||
width_px, height_px = 1280, 720
|
||||
|
||||
return int(width_px * EMU_PER_PIXEL), int(height_px * EMU_PER_PIXEL)
|
||||
|
||||
|
||||
def get_pixel_dimensions(
|
||||
canvas_format: str,
|
||||
custom_pixels: tuple[int, int] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Get canvas pixel dimensions.
|
||||
|
||||
Args:
|
||||
canvas_format: Canvas format key.
|
||||
custom_pixels: Optional custom pixel dimensions override.
|
||||
|
||||
Returns:
|
||||
(width_px, height_px) tuple.
|
||||
"""
|
||||
if custom_pixels:
|
||||
return custom_pixels
|
||||
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
canvas_format = 'ppt169'
|
||||
|
||||
dimensions = CANVAS_FORMATS[canvas_format]['dimensions']
|
||||
match = re.match(r'(\d+)[×x](\d+)', dimensions)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return 1280, 720
|
||||
|
||||
|
||||
def get_viewbox_dimensions(svg_path: Path) -> tuple[float, float]:
|
||||
"""Extract pixel dimensions from SVG viewBox.
|
||||
|
||||
Args:
|
||||
svg_path: Path to the SVG file.
|
||||
|
||||
Returns:
|
||||
(width, height) in SVG pixels.
|
||||
|
||||
Raises:
|
||||
CanvasContractError: The root canvas is missing or invalid.
|
||||
"""
|
||||
return read_project_viewbox(svg_path).pixel_dimensions
|
||||
|
||||
|
||||
def detect_format_from_svg(svg_path: Path) -> str | None:
|
||||
"""Detect canvas format from an SVG file's viewBox.
|
||||
|
||||
Args:
|
||||
svg_path: Path to the SVG file.
|
||||
|
||||
Returns:
|
||||
Canvas format key (e.g. 'ppt169'), or None if not detected.
|
||||
"""
|
||||
viewbox = read_project_viewbox(svg_path)
|
||||
for fmt_key, fmt_info in CANVAS_FORMATS.items():
|
||||
expected = parse_project_viewbox(
|
||||
fmt_info['viewbox'],
|
||||
context=f"registered canvas {fmt_key!r}",
|
||||
)
|
||||
if viewbox == expected:
|
||||
return fmt_key
|
||||
return None
|
||||
|
||||
|
||||
def resolve_svg_canvas(
|
||||
svg_files: list[Path],
|
||||
*,
|
||||
canvas_format: str | None = None,
|
||||
expected_viewbox: str | None = None,
|
||||
) -> tuple[ProjectViewBox, str | None]:
|
||||
"""Resolve one fail-closed canvas for every public/internal SVG."""
|
||||
format_viewbox: str | None = None
|
||||
if canvas_format is not None:
|
||||
if canvas_format not in CANVAS_FORMATS:
|
||||
raise CanvasContractError(f"Unsupported canvas format: {canvas_format}")
|
||||
format_viewbox = CANVAS_FORMATS[canvas_format]['viewbox']
|
||||
|
||||
if expected_viewbox is not None and format_viewbox is not None:
|
||||
locked = parse_project_viewbox(
|
||||
expected_viewbox,
|
||||
context="locked canvas viewBox",
|
||||
)
|
||||
selected = parse_project_viewbox(
|
||||
format_viewbox,
|
||||
context=f"canvas format {canvas_format!r}",
|
||||
)
|
||||
if locked != selected:
|
||||
raise CanvasContractError(
|
||||
f"canvas format {canvas_format!r} ({selected.canonical}) conflicts "
|
||||
f"with the locked canvas ({locked.canonical})"
|
||||
)
|
||||
|
||||
required = expected_viewbox if expected_viewbox is not None else format_viewbox
|
||||
if expected_viewbox is not None:
|
||||
expected_label = "the locked canvas"
|
||||
elif canvas_format is not None:
|
||||
expected_label = f"canvas format {canvas_format!r}"
|
||||
else:
|
||||
expected_label = "the first SVG canvas"
|
||||
viewbox = require_consistent_project_viewboxes(
|
||||
svg_files,
|
||||
expected_viewbox=required,
|
||||
expected_label=expected_label,
|
||||
)
|
||||
detected_format = canvas_format
|
||||
if detected_format is None:
|
||||
for fmt_key, fmt_info in CANVAS_FORMATS.items():
|
||||
registered = parse_project_viewbox(
|
||||
fmt_info['viewbox'],
|
||||
context=f"registered canvas {fmt_key!r}",
|
||||
)
|
||||
if viewbox == registered:
|
||||
detected_format = fmt_key
|
||||
break
|
||||
return viewbox, detected_format
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Find SVG and notes files in a project directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from slide_roster import discover_slide_svgs
|
||||
|
||||
|
||||
class NotesFileReadError(RuntimeError):
|
||||
"""Report a matched notes file that cannot be decoded or read."""
|
||||
|
||||
|
||||
def find_svg_files(
|
||||
project_path: Path,
|
||||
source: str = 'output',
|
||||
*,
|
||||
allow_fallback: bool = True,
|
||||
) -> tuple[list[Path], str]:
|
||||
"""Find SVG files in the project.
|
||||
|
||||
Args:
|
||||
project_path: Project directory path.
|
||||
source: SVG source directory alias or name.
|
||||
- 'output': svg_output (hand-authored source; native default)
|
||||
- 'final': svg_final (post-processed preview; diagnostic input)
|
||||
- or any subdirectory name
|
||||
allow_fallback: Try svg_output and then the project root when the
|
||||
requested directory is missing.
|
||||
|
||||
Returns:
|
||||
(list_of_svg_files, actual_directory_name) tuple.
|
||||
"""
|
||||
dir_map = {
|
||||
'output': 'svg_output',
|
||||
'final': 'svg_final',
|
||||
}
|
||||
|
||||
dir_name = dir_map.get(source, source)
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if not allow_fallback:
|
||||
return [], dir_name
|
||||
print(f" Warning: {dir_name} directory does not exist, trying svg_output")
|
||||
dir_name = 'svg_output'
|
||||
svg_dir = project_path / dir_name
|
||||
|
||||
if not svg_dir.exists():
|
||||
if project_path.is_dir():
|
||||
svg_dir = project_path
|
||||
dir_name = project_path.name
|
||||
else:
|
||||
return [], ''
|
||||
|
||||
return discover_slide_svgs(svg_dir), dir_name
|
||||
|
||||
|
||||
def find_notes_files(
|
||||
project_path: Path,
|
||||
svg_files: list[Path] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Find notes files and map them to SVG files.
|
||||
|
||||
Supports two matching modes (mixed matching supported):
|
||||
1. Match by filename (priority): notes/01_cover.md -> 01_cover.svg
|
||||
2. Match by index (backward compatible): notes/slide01.md -> 1st SVG
|
||||
|
||||
Args:
|
||||
project_path: Project directory path.
|
||||
svg_files: SVG file list (for filename matching).
|
||||
|
||||
Returns:
|
||||
Dict mapping SVG filename stem to notes content.
|
||||
"""
|
||||
notes_dir = project_path / 'notes'
|
||||
notes: dict[str, str] = {}
|
||||
|
||||
if not notes_dir.exists():
|
||||
return notes
|
||||
|
||||
svg_stems_mapping: dict[str, int] = {}
|
||||
svg_index_mapping: dict[int, str] = {}
|
||||
if svg_files:
|
||||
for i, svg_path in enumerate(svg_files, 1):
|
||||
svg_stems_mapping[svg_path.stem] = i
|
||||
svg_index_mapping[i] = svg_path.stem
|
||||
|
||||
for notes_file in notes_dir.glob('*.md'):
|
||||
stem = notes_file.stem
|
||||
|
||||
# Try index-based matching (backward compat with slide01.md format).
|
||||
match = re.search(r'slide[_]?(\d+)', stem)
|
||||
mapped_stem = (
|
||||
svg_index_mapping.get(int(match.group(1)))
|
||||
if match
|
||||
else None
|
||||
)
|
||||
filename_match = stem in svg_stems_mapping
|
||||
if mapped_stem is None and not filename_match:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = notes_file.read_text(encoding='utf-8').strip()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise NotesFileReadError(
|
||||
f"Cannot read matched notes file {notes_file}: {exc}"
|
||||
) from exc
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if mapped_stem:
|
||||
notes[mapped_stem] = content
|
||||
|
||||
# Filename-based matching overrides index-based matching.
|
||||
if filename_match:
|
||||
notes[stem] = content
|
||||
|
||||
return notes
|
||||
@@ -0,0 +1,160 @@
|
||||
"""SVG to PNG conversion for Office compatibility mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# SVG to PNG library detection
|
||||
# Prefer CairoSVG (better quality), fall back to svglib
|
||||
PNG_RENDERER: str | None = None
|
||||
|
||||
try:
|
||||
import cairosvg
|
||||
PNG_RENDERER = 'cairosvg'
|
||||
except (ImportError, OSError):
|
||||
try:
|
||||
from svglib.svglib import svg2rlg
|
||||
from reportlab.graphics import renderPM
|
||||
PNG_RENDERER = 'svglib'
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def get_png_renderer_info() -> tuple[str | None, str, str | None]:
|
||||
"""Get PNG renderer status information.
|
||||
|
||||
Returns:
|
||||
(renderer_name, status_text, install_hint) tuple.
|
||||
"""
|
||||
if PNG_RENDERER == 'cairosvg':
|
||||
return ('cairosvg', '(full gradient/filter support)', None)
|
||||
elif PNG_RENDERER == 'svglib':
|
||||
return ('svglib', '(some gradients may be lost)',
|
||||
'Install cairosvg for better results: pip install cairosvg')
|
||||
else:
|
||||
return (None, '(not installed)',
|
||||
'Install via: pip install cairosvg or pip install svglib reportlab')
|
||||
|
||||
|
||||
def convert_svg_to_png(
|
||||
svg_path: Path,
|
||||
png_path: Path,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> bool:
|
||||
"""Convert SVG to PNG using the available renderer.
|
||||
|
||||
Args:
|
||||
svg_path: SVG file path.
|
||||
png_path: Output PNG file path.
|
||||
width: Output width in pixels.
|
||||
height: Output height in pixels.
|
||||
|
||||
Returns:
|
||||
Whether the conversion was successful.
|
||||
"""
|
||||
if PNG_RENDERER is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if PNG_RENDERER == 'cairosvg':
|
||||
cairosvg.svg2png(
|
||||
url=str(svg_path),
|
||||
write_to=str(png_path),
|
||||
output_width=width,
|
||||
output_height=height,
|
||||
)
|
||||
return True
|
||||
|
||||
elif PNG_RENDERER == 'svglib':
|
||||
drawing = svg2rlg(str(svg_path))
|
||||
if drawing is None:
|
||||
print(f" Warning: Unable to parse SVG ({svg_path.name})")
|
||||
return False
|
||||
renderPM.drawToFile(
|
||||
drawing,
|
||||
str(png_path),
|
||||
fmt="PNG",
|
||||
configPIL={'quality': 95},
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" Warning: SVG to PNG conversion failed ({svg_path.name}): {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _cache_key(svg_path: Path, width: int | None, height: int | None) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(svg_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
h.update(chunk)
|
||||
return f"{h.hexdigest()}_{width or 0}x{height or 0}_{PNG_RENDERER or 'none'}"
|
||||
|
||||
|
||||
def convert_svg_to_png_cached(
|
||||
svg_path: Path,
|
||||
png_path: Path,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
cache_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""Cache-aware SVG→PNG conversion.
|
||||
|
||||
Returns True on success (cache hit or fresh render). Cache key bakes in
|
||||
SVG content hash + size + renderer name; switching renderers invalidates
|
||||
naturally. Failures are never cached.
|
||||
"""
|
||||
if cache_dir is None:
|
||||
return convert_svg_to_png(svg_path, png_path, width, height)
|
||||
|
||||
if PNG_RENDERER is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
key = _cache_key(svg_path, width, height)
|
||||
except OSError as e:
|
||||
print(f" Warning: Failed to hash SVG ({svg_path.name}): {e}")
|
||||
return convert_svg_to_png(svg_path, png_path, width, height)
|
||||
|
||||
cached = cache_dir / f"{key}.png"
|
||||
if cached.is_file():
|
||||
try:
|
||||
shutil.copy(cached, png_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f" Warning: Cache copy failed, re-rendering ({svg_path.name}): {e}")
|
||||
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(suffix='.png', dir=str(cache_dir))
|
||||
tmp_path = Path(tmp_name)
|
||||
import os
|
||||
os.close(tmp_fd)
|
||||
|
||||
ok = convert_svg_to_png(svg_path, tmp_path, width, height)
|
||||
if not ok:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
os.replace(tmp_path, cached)
|
||||
except OSError:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
shutil.copy(cached, png_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f" Warning: Cache copy failed ({svg_path.name}): {e}")
|
||||
return False
|
||||
@@ -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
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Markdown to plain text conversion and notes slide XML generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from language_tags import normalize_language_tag
|
||||
|
||||
from ..drawingml.utils import (
|
||||
detect_text_lang,
|
||||
text_has_rtl_characters,
|
||||
text_uses_rtl,
|
||||
)
|
||||
|
||||
|
||||
def markdown_to_plain_text(md_content: str) -> str:
|
||||
"""Convert Markdown notes to plain text for PPTX notes.
|
||||
|
||||
Args:
|
||||
md_content: Markdown formatted notes content.
|
||||
|
||||
Returns:
|
||||
Plain text content.
|
||||
"""
|
||||
def strip_inline_bold(text: str) -> str:
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
for line in md_content.split('\n'):
|
||||
if line.startswith('#'):
|
||||
text = re.sub(r'^#+\s*', '', line).strip()
|
||||
text = strip_inline_bold(text)
|
||||
if text:
|
||||
lines.append(text)
|
||||
lines.append('')
|
||||
elif line.strip().startswith('- '):
|
||||
item_text = line.strip()[2:]
|
||||
item_text = strip_inline_bold(item_text)
|
||||
lines.append('• ' + item_text)
|
||||
elif line.strip():
|
||||
text = strip_inline_bold(line.strip())
|
||||
lines.append(text)
|
||||
else:
|
||||
lines.append('')
|
||||
|
||||
# Merge consecutive empty lines
|
||||
result: list[str] = []
|
||||
is_prev_empty = False
|
||||
for line in lines:
|
||||
if line == '':
|
||||
if not is_prev_empty:
|
||||
result.append(line)
|
||||
is_prev_empty = True
|
||||
else:
|
||||
result.append(line)
|
||||
is_prev_empty = False
|
||||
|
||||
return '\n'.join(result).strip()
|
||||
|
||||
|
||||
def create_notes_slide_xml(
|
||||
slide_num: int,
|
||||
notes_text: str,
|
||||
primary_language: str | None = None,
|
||||
) -> str:
|
||||
"""Create notes slide XML.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
notes_text: Notes text in plain text format.
|
||||
primary_language: Canonical BCP-47 deck language, when available.
|
||||
|
||||
Returns:
|
||||
Notes slide XML string.
|
||||
"""
|
||||
primary_language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else None
|
||||
)
|
||||
default_language = primary_language or 'en-US'
|
||||
notes_text = (notes_text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
paragraphs: list[str] = []
|
||||
for para in notes_text.split('\n'):
|
||||
if para.strip():
|
||||
lang = detect_text_lang(para, primary_language)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl(
|
||||
para,
|
||||
primary_language,
|
||||
) else ''
|
||||
run_rtl = (
|
||||
'<a:rtl val="1"/>'
|
||||
if text_has_rtl_characters(para)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(f'''<a:p>
|
||||
<a:pPr{paragraph_rtl}/>
|
||||
<a:r>
|
||||
<a:rPr lang="{lang}" dirty="0">{run_rtl}</a:rPr>
|
||||
<a:t>{para}</a:t>
|
||||
</a:r>
|
||||
</a:p>''')
|
||||
else:
|
||||
paragraph_rtl = (
|
||||
' rtl="1"'
|
||||
if primary_language and text_uses_rtl('', primary_language)
|
||||
else ''
|
||||
)
|
||||
paragraphs.append(
|
||||
f'<a:p><a:pPr{paragraph_rtl}/>'
|
||||
f'<a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
|
||||
paragraphs_xml = (
|
||||
'\n '.join(paragraphs)
|
||||
if paragraphs
|
||||
else (
|
||||
f'<a:p><a:endParaRPr lang="{default_language}" dirty="0"/></a:p>'
|
||||
)
|
||||
)
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Slide Image Placeholder 1"/>
|
||||
<p:cNvSpPr>
|
||||
<a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/>
|
||||
</p:cNvSpPr>
|
||||
<p:nvPr>
|
||||
<p:ph type="sldImg" idx="2"/>
|
||||
</p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr/>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="3" name="Notes Placeholder 2"/>
|
||||
<p:cNvSpPr>
|
||||
<a:spLocks noGrp="1"/>
|
||||
</p:cNvSpPr>
|
||||
<p:nvPr>
|
||||
<p:ph type="body" sz="quarter" idx="3"/>
|
||||
</p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr/>
|
||||
<p:txBody>
|
||||
<a:bodyPr/>
|
||||
<a:lstStyle/>
|
||||
{paragraphs_xml}
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping/>
|
||||
</p:clrMapOvr>
|
||||
</p:notes>'''
|
||||
|
||||
|
||||
def create_notes_slide_rels_xml(slide_num: int) -> str:
|
||||
"""Create notes slide relationship file XML.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
|
||||
Returns:
|
||||
Relationship file XML string.
|
||||
"""
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide{slide_num}.xml"/>
|
||||
</Relationships>'''
|
||||
|
||||
|
||||
def create_notes_master_xml(primary_language: str | None = None) -> str:
|
||||
"""Create a minimal PowerPoint-compatible notes master XML."""
|
||||
language = (
|
||||
normalize_language_tag(primary_language)
|
||||
if primary_language is not None
|
||||
else 'en-US'
|
||||
)
|
||||
paragraph_rtl = ' rtl="1"' if text_uses_rtl('', language) else ''
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Header Placeholder 1"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="hdr" sz="quarter"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="3" name="Date Placeholder 2"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="dt" sz="half" idx="1"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="3884613" y="0"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="4" name="Slide Image Placeholder 3"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="sldImg" idx="2"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="1143000" y="685800"/>
|
||||
<a:ext cx="4572000" cy="3429000"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="5" name="Notes Placeholder 4"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="body" sz="quarter" idx="3"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="685800" y="4343400"/>
|
||||
<a:ext cx="5486400" cy="4114800"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="6" name="Footer Placeholder 5"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="ftr" sz="quarter" idx="4"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="8685213"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="7" name="Slide Number Placeholder 6"/>
|
||||
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
|
||||
<p:nvPr><p:ph type="sldNum" sz="quarter" idx="5"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="3884613" y="8685213"/>
|
||||
<a:ext cx="2971800" cy="457200"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
</p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr{paragraph_rtl}/><a:endParaRPr lang="{language}"/></a:p></p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2"
|
||||
accent1="accent1" accent2="accent2" accent3="accent3"
|
||||
accent4="accent4" accent5="accent5" accent6="accent6"
|
||||
hlink="hlink" folHlink="folHlink"/>
|
||||
<p:hf/>
|
||||
<p:notesStyle>
|
||||
<a:lvl1pPr marL="0" algn="l"{paragraph_rtl}>
|
||||
<a:defRPr sz="1200" lang="{language}"/>
|
||||
</a:lvl1pPr>
|
||||
</p:notesStyle>
|
||||
</p:notesMaster>'''
|
||||
|
||||
|
||||
def create_notes_master_rels_xml() -> str:
|
||||
"""Create notes master relationships XML."""
|
||||
return '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme2.xml"/>
|
||||
</Relationships>'''
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Slide XML and slide relationship XML generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pptx_transitions import create_transition_xml
|
||||
|
||||
|
||||
def create_slide_xml_with_svg(
|
||||
slide_num: int,
|
||||
png_rid: str,
|
||||
svg_rid: str,
|
||||
width_emu: int,
|
||||
height_emu: int,
|
||||
transition: str | None = 'fade',
|
||||
transition_duration: float = 0.5,
|
||||
auto_advance: float | None = None,
|
||||
use_compat_mode: bool = True,
|
||||
transition_effect_options: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
"""Create slide XML containing an SVG image.
|
||||
|
||||
Args:
|
||||
slide_num: Slide number.
|
||||
png_rid: PNG fallback image relationship ID.
|
||||
svg_rid: SVG relationship ID.
|
||||
width_emu: Width in EMU.
|
||||
height_emu: Height in EMU.
|
||||
transition: Transition effect name.
|
||||
transition_effect_options: PowerPoint Effect Options for the selected
|
||||
native transition.
|
||||
transition_duration: Transition duration in seconds.
|
||||
auto_advance: Auto-advance interval in seconds.
|
||||
use_compat_mode: Whether to use compatibility mode (PNG + SVG dual format).
|
||||
"""
|
||||
transition_xml = ''
|
||||
if transition is not None or auto_advance is not None:
|
||||
transition_fragment = create_transition_xml(
|
||||
effect=transition,
|
||||
duration=transition_duration,
|
||||
advance_after=auto_advance,
|
||||
effect_options=transition_effect_options,
|
||||
)
|
||||
if transition_fragment:
|
||||
transition_xml = '\n' + transition_fragment
|
||||
|
||||
if use_compat_mode:
|
||||
blip_xml = f'''<a:blip r:embed="{png_rid}">
|
||||
<a:extLst>
|
||||
<a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}">
|
||||
<asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="{svg_rid}"/>
|
||||
</a:ext>
|
||||
</a:extLst>
|
||||
</a:blip>'''
|
||||
else:
|
||||
blip_xml = f'<a:blip r:embed="{svg_rid}"/>'
|
||||
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
<p:pic>
|
||||
<p:nvPicPr>
|
||||
<p:cNvPr id="2" name="SVG Image {slide_num}"/>
|
||||
<p:cNvPicPr>
|
||||
<a:picLocks noChangeAspect="1"/>
|
||||
</p:cNvPicPr>
|
||||
<p:nvPr/>
|
||||
</p:nvPicPr>
|
||||
<p:blipFill>
|
||||
{blip_xml}
|
||||
<a:stretch>
|
||||
<a:fillRect/>
|
||||
</a:stretch>
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="{width_emu}" cy="{height_emu}"/>
|
||||
</a:xfrm>
|
||||
<a:prstGeom prst="rect">
|
||||
<a:avLst/>
|
||||
</a:prstGeom>
|
||||
</p:spPr>
|
||||
</p:pic>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping/>
|
||||
</p:clrMapOvr>{transition_xml}
|
||||
</p:sld>'''
|
||||
|
||||
|
||||
def create_slide_rels_xml(
|
||||
png_rid: str,
|
||||
png_filename: str,
|
||||
svg_rid: str,
|
||||
svg_filename: str,
|
||||
use_compat_mode: bool = True,
|
||||
slide_layout_target: str = "../slideLayouts/slideLayout1.xml",
|
||||
) -> str:
|
||||
"""Create slide relationship file XML.
|
||||
|
||||
Args:
|
||||
png_rid: PNG image relationship ID.
|
||||
png_filename: PNG filename.
|
||||
svg_rid: SVG relationship ID.
|
||||
svg_filename: SVG filename.
|
||||
use_compat_mode: Whether to use compatibility mode.
|
||||
slide_layout_target: Target for the slide's actual layout part.
|
||||
"""
|
||||
if use_compat_mode:
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
|
||||
<Relationship Id="{png_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{png_filename}"/>
|
||||
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
|
||||
</Relationships>'''
|
||||
else:
|
||||
return f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="{slide_layout_target}"/>
|
||||
<Relationship Id="{svg_rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/{svg_filename}"/>
|
||||
</Relationships>'''
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user