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 @@
"""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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;'))
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